italix/testing

In-process PSR-15 HTTP client, schema-derived row factories, rollback isolation and a plain test runner

Maintainers

Package info

github.com/italix-net/testing

pkg:composer/italix/testing

Transparency log

Statistics

Installs: 2

Dependents: 21

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-08-30 07:43 UTC

This package is not auto-updated.

Last update: 2026-08-31 06:12:12 UTC


README

PHP Version License

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.