Testing
ext-infer has two test layers:
- PHPT — integration tests that exercise the extension from PHP. This is where the real correctness coverage lives.
- Rust unit tests — for pure-Rust helpers (currently none; see Why no Rust unit tests? below).
Plus formatting and clippy. CI runs all of the above on every push.
Running PHPT locally
The test harness lives in tests/phpt/.
make test runs the full suite against a debug build:
make test
What that command actually does:
- Build (
cargo build). - Sanity-load — confirm the extension actually loaded into PHP.
- Fetch
run-tests.phpfrom PHP-src matching the current minor (if not already cached). - Run
php run-tests.php -q --show-diff tests/phpt/withTEST_PHP_EXECUTABLEandTEST_PHP_ARGSset so the freshly built.so/.dylibis loaded.
Tests gated on a real model use the INFER_TEST_MODEL environment
variable:
INFER_TEST_MODEL=$PWD/models/Qwen3-0.6B-Q8_0.gguf make test
Without the variable, model-gated tests skip cleanly. CI runs in this
“no model” mode by default; setting INFER_TEST_MODEL runs the full
suite.
Writing a PHPT test
Files in tests/phpt/ follow the standard PHPT format:
--TEST--
Model::chat() returns a Response with the model's answer
--SKIPIF--
<?php
if (!extension_loaded('infer')) {
echo 'skip ext-infer not loaded';
exit;
}
$path = getenv('INFER_TEST_MODEL');
if (!$path || !is_file($path)) {
echo 'skip INFER_TEST_MODEL not set to an existing GGUF file';
}
?>
--FILE--
<?php
$model = \Displace\Infer\Model::load(getenv('INFER_TEST_MODEL'));
$r = $model->chat(\Displace\Infer\Prompt::user('hi'), maxTokens: 32);
echo $r->finishReason() === 'eos' || $r->finishReason() === 'length' ? "ok\n" : "bad\n";
$model->close();
?>
--EXPECT--
ok
Filename convention: NNN-short-description.phpt. NNN ordering is
loose — it determines the order run-tests.php runs them in, which
doesn’t really matter.
Three sections every model-gated test needs:
--SKIPIF--—skipifextension_loaded('infer')is false (the harness invocation always passes-d extension=…, so this catches setup mistakes) and skip ifINFER_TEST_MODELis unset.--FILE--— the actual PHP under test.--EXPECT--or--EXPECTF--— expected output. Use--EXPECTF--if you need wildcards (%s,%d).
For tests that DON’T need a model, drop the INFER_TEST_MODEL check
from --SKIPIF--. They’ll run in CI’s no-model leg.
Running Rust unit tests
cargo test --lib
…would be the command, but see the next section.
Why no Rust unit tests?
Earlier versions had Rust unit tests in src/response.rs and
src/embedding.rs covering pure-Rust helpers. They were dropped
because cargo test --lib builds an executable that statically links
the crate, which pulls in references to the ext-php-rs runtime
symbols (zend_throw_exception, _emalloc, …) — symbols only
resolved when loaded into a real PHP host. On a clean checkout,
cargo test --lib fails to link.
PHPT covers the same correctness ground end-to-end, so this is a net
win for CI simplicity. If a pure-Rust helper grows complex enough to
warrant unit tests in isolation, the path forward is to factor it
into a sibling crate that has no ext-php-rs dependency.
Linting
make fmt-check # cargo fmt --all --check
make clippy # cargo clippy --all-targets -- -D warnings
CI runs both with -D warnings. Local lints are pinned to the
same Rust toolchain as the build (via rust-toolchain.toml).
CI structure
.github/workflows/ci.yml
runs on every push and PR:
rustfmt + clippyon ubuntu-latest with PHP 8.4. Fast (~1 minute warm-cache).- Test matrix — 6 legs:
{ubuntu-latest, macos-14}×{8.3, 8.4, 8.5}. Each builds the extension, loads it, runs the no-model PHPT legs. Cache is scoped per-PHP-minor (see the comment inci.ymlabout why this matters forext-php-rsbinding regeneration).
What CI does not do:
- Run model-gated PHPT tests. Adding a fixture model to CI is on the roadmap; for now, run them locally before tagging.
- Exercise ZTS PHP. See Threading & ZTS.
Pre-flight checklist
Before opening a PR, the maintainers run:
cargo fmt --all --check # no diff
cargo clippy --all-targets -- -D warnings # clean
INFER_TEST_MODEL=$PWD/models/Qwen3-0.6B-Q8_0.gguf make test # all green
If any of those fail, the PR will fail CI for the same reason — fix locally first.
Next
- Releasing — what runs in the release workflow (a different beast than CI).
- Building from source — getting to the point where
make testcan even run.