italix / testing
In-process PSR-15 HTTP client, schema-derived row factories, rollback isolation and a plain test runner
Requires
- php: >=7.4
- ext-json: *
- guzzlehttp/psr7: ^2.0
- italix/contracts: ^2.0
- psr/http-message: ^1.0 || ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- italix/orm: ^2.0
Suggests
- ext-pdo: Required by PdoRowWriter and by rollback isolation
- italix/mvc: Provides Engine::pipeline(), the handler this client drives
- italix/orm: Provides the Table schemas that factories are derived from
- phpunit/phpunit: ^9.0 || ^10.0 || ^11.0 — PhpunitSuite runs these suites from PHPUnit without rewriting them
This package is not auto-updated.
Last update: 2026-08-31 06:12:12 UTC
README
An in-process HTTP client, row factories derived from the schema, rollback isolation, and a runner that is a function and two counters.
Zero test-framework dependencies. Engine is already PSR-15 and Table already describes every
column, so most of this library is glue that was missing rather than machinery that is new.
php src/Libs/Italix/Testing/tests/ClientTest.php php src/Libs/Italix/Testing/tests/FactoryTest.php
The client
use Italix\Mvc\Engine; use function Italix\Testing\{ix_client, suite, test, summary}; suite('Acme — routes, auth and CSRF'); $config = require "src/sites/{$host}/conf.php"; $config['base_dir'] = $base_dir; $config['host'] = $host; $config['cache.disabled'] = true; // read routes.php on every run $client = ix_client((new Engine($config))->pipeline()); $res = $client->as(['admin_id' => 1])->get('/it/admin/customers/index.html'); test('the list renders', $res->status() === 200); test('the customer is there', $res->contains('Rossi')); test('nobody else\'s is', !$res->contains('Bianchi'));
pipeline() or boot() — the difference matters.
| What it drives | |
|---|---|
ix_client($engine->pipeline()) |
the full stack: locale, CSRF, authentication, routing, controller, view |
ix_client($engine->boot()) |
routing and controller only, no global middleware |
Use the first by default. A test that uses the second is not evidence that a route is reachable, only that the action works.
Sessions behave like a cookie jar
as() seeds the session; whatever the application writes during a request is visible to the next
request made through the same instance. That is what lets a redirect-then-follow test see its flash
message.
$admin = $client->as(['admin_id' => 1]); $admin->post('/it/admin/customers/new/edit.html', [...]); // writes a flash $admin->get('/it/admin/customers/index.html'); // sees it
as(), with_header() and with_csrf() each return a new client, so a seeded client cannot
leak into an anonymous one by accident.
CSRF is opt-in
$client->post('/it/admin/logout'); // 419 — no token $client->with_csrf()->post('/it/admin/logout'); // passes the check
Automatic injection would make the test that matters most impossible to write.
Factories
Rows come from the schema, not from a fixture file:
use function Italix\Testing\{factory, writer, in_rollback}; $w = writer($dm->get_connection()); $customer = factory(Customer::table(), $w) ->with(['last_name' => 'Rossi', 'tenant_id' => $tenant_id]) ->create();
| Column | What the factory does |
|---|---|
| primary key | left to the database |
not_null(), no default |
filled with a type-appropriate value, truncated to the declared length |
| nullable | left out, so the values a test does set stand out |
| has a default | left to the database — only it knows what CURRENT_TIMESTAMP means |
| unknown type | stops the run, naming the column and the type |
unknown column in with() |
stops the run — usually a typo |
Values are deterministic (test_1, test_2, …) drawn from a process-wide sequence, not random: a
suite that fails must fail again on the next run.
The point of deriving from the schema is what happens later. A migration that adds a not_null()
column leaves a hand-written fixture inserting a row the database no longer accepts, and the test
fails somewhere unrelated to what it was checking. A derived factory fills it.
Isolation
in_rollback($pdo, static function () use ($dm, $client): void { // every INSERT here disappears when the closure returns });
Rollback rather than truncation: truncating destroys the reference data an application needs to boot — countries, provinces, the seeded admin — and rebuilding it is a second fixture that drifts from the first. A rollback leaves the database exactly as it was found, which also means a suite can run against a development database without being a risk to it.
The rollback happens whether the closure returns or throws, and an exception propagates unchanged —
the isolation is a side effect, not a catch.
Two limits, stated rather than discovered:
- MySQL commits DDL implicitly. A test that creates or alters a table escapes the rollback. Migrations are not testable this way.
- Nesting is refused, not emulated. Savepoints would make the failure mode subtle; an exception makes it obvious. A commit inside the closure is reported for the same reason.
The runner
use function Italix\Testing\{suite, section, test, summary}; suite('Italix Encode — Html'); section('attributes'); test('a bad name is refused', $threw, 'expected TestingException'); exit(summary()); // 0 clean, 1 failures
$details prints only on failure, so it can afford to be expensive to read: the actual value, the
offending input, the query that ran.
suite() also starts the PHP session before printing anything — see below.
JUnit, for whatever reads the build
ITALIX_JUNIT=build/junit php src/Libs/Italix/I18n/tests/I18nTest.php
ix test --junit=build/junit
Written as well as the human output. A build server does not read ticks, so a suite either passed or failed and the hundred assertions inside it were a wall of scrollback; every CI in common use consumes JUnit XML and turns it into per-assertion annotations on the diff.
section() becomes JUnit's classname, which is what those tools group by — so a heading reading
"the two halves of a message are not equally trustworthy" arrives in the report as the heading it
already was. A path ending in / receives one file per suite; anything else is the file to write.
Control characters are stripped before escaping: a failure detail can hold an HTTP body or a byte out
of a fuzzer, and a \x00 is never legal in XML 1.0 — entity-encoded or not — so one of them would
make the whole report unparseable and the build would report nothing at all.
From PHPUnit, without rewriting anything
final class ItalixSuites extends \Italix\Testing\PhpunitSuite { public static function suite_patterns(): array { return [__DIR__ . '/../src/Libs/Italix/*/tests/*Test.php']; } }
Not because PHPUnit is better — these suites read as prose and a class-and-method convention flattens that. It is about who can run them: checking out one of these libraries and running its tests otherwise means first installing this package, and a maintainer evaluating an i18n library will not install somebody's test framework to see whether the i18n library works.
One suite file becomes one PHPUnit test, in a child process, asserting on the exit code. Do not
enable processIsolation on top of it — several suites boot one application and send it several
requests specifically to reproduce a resident worker, and isolating those per test removes the
condition under test.
Two behaviours worth knowing
Sessions must start before any output. Measured on PHP 8.1 CLI: the first printed byte sets
headers_sent(), after which session_start() cannot succeed and passing options does not help.
suite() therefore starts it first. If it is too late, $_SESSION is still an ordinary array and
everything works — application middleware cannot tell the difference — but that middleware's own
session_start() call will warn into the middle of the results.
TestingException is not a failed assertion. A failed test() is a result; a TestingException
is the harness saying it does not know what to do — an unknown column type, an identifier it refuses
to interpolate, a transaction it cannot roll back. Those stop the run, because a harness that
guesses produces green suites that mean nothing.