Testing

Unit tests, worker-mode tests that catch state leaks, integration tests against real servers, and the static scans that catch what tests cannot.

Shell
$ composer test                              # everything
$ vendor/bin/phpunit --testsuite unit        # unit only
$ vendor/bin/phpunit --testsuite worker      # process-model tests only
$ vendor/bin/phpunit --testsuite integration # needs real servers; skips without them
$ vendor/bin/phpunit --filter test_name      # one test
$ vendor/bin/phpunit tests/Unit/Http/UriTest.php
$ composer stan                              # PHPStan, max level

Four kinds of test

DirectoryCatches
tests/Unit/Ordinary component behaviour.
tests/Worker/State leaking between requests in one process.
tests/Integration/SQL or SMTP a real server rejects.
tests/Unit/PortabilityTest.phpCode that only works on one SAPI.

Testing a handler

Boot an application and hand it a request. No HTTP server involved:

PHP
<?php
use PhpOrbit\Kernel\Application;
use PhpOrbit\Kernel\Blueprint;
use PhpOrbit\Tests\Support\Requests;

public function test_it_greets_by_name(): void
{
    $app = Application::boot(static function (Blueprint $app): void {
        $app->routes->get('/greet/{name}', static fn (ServerRequest $r): Response =>
            Response::text('hello ' . $r->attribute('name')));
    });

    $response = $app->handle(Requests::get('/greet/ada'));

    self::assertSame(Status::Ok, $response->status);
    self::assertSame('hello ada', $response->body);
}

The request helper

PHP
<?php
Requests::get('/articles?page=2');
Requests::post('/articles', 'title=Hello');
Requests::of(Method::Delete, '/articles/1', headers: ['X-Trace' => 'abc']);

Worker-mode tests

Why they exist

State-leak bugs are invisible under per-request execution. Apache and FPM destroy the process between requests, so a leak has nothing to leak into. Under a worker the same bug serves one user's data to the next. Every test in tests/Worker/ therefore boots once and handles at least twice.

The canonical shape — serve the same route twice and assert the second is unaffected by the first:

PHP
<?php
public function test_a_scoped_service_starts_fresh_for_each_request(): void
{
    $app = Application::boot(static function (Blueprint $app): void {
        $app->container->scoped(Counter::class, static fn (): Counter => new Counter());

        $app->routes->get('/count', static fn (ServerRequest $r, RequestScope $scope): Response =>
            Response::text((string) $scope->get(Counter::class)->increment()));
    });

    self::assertSame('1', $app->handle(Requests::get('/count'))->body);
    self::assertSame('1', $app->handle(Requests::get('/count'))->body);
    self::assertSame('1', $app->handle(Requests::get('/count'))->body);
}

Pair it with the opposite case, or the test can pass by being vacuous:

PHP
<?php
public function test_a_singleton_persists_across_requests(): void
{
    // ... registered with singleton() instead ...

    self::assertSame('1', $app->handle(Requests::get('/count'))->body);
    self::assertSame('2', $app->handle(Requests::get('/count'))->body);
}

Teardown on the error path

PHP
<?php
public function test_teardown_runs_even_when_the_handler_throws(): void
{
    $released = 0;

    $app = Application::boot(static function (Blueprint $app) use (&$released): void {
        $app->routes->get('/boom', static function (ServerRequest $r, RequestScope $scope) use (&$released): Response {
            $scope->onClose(static function () use (&$released): void {
                $released++;
            });

            throw new RuntimeException('handler failed');
        });
    });

    self::assertSame(Status::InternalServerError, $app->handle(Requests::get('/boom'))->status);
    self::assertSame(Status::InternalServerError, $app->handle(Requests::get('/boom'))->status);
    self::assertSame(2, $released);
}

Memory growth

PHP
<?php
public function test_memory_does_not_grow_across_many_requests(): void
{
    // Warm up first, so first-call allocations are not counted as growth.
    for ($i = 0; $i < 200; $i++) {
        $app->handle(Requests::get('/count/' . $i));
    }

    gc_collect_cycles();
    $baseline = memory_get_usage();

    for ($i = 0; $i < 2000; $i++) {
        $app->handle(Requests::get('/count/' . $i));
    }

    gc_collect_cycles();

    self::assertLessThan(256 * 1024, memory_get_usage() - $baseline);
}

A slow leak is invisible across a handful of requests and fatal across a worker's lifetime.

Testing over real HTTP

OrbitServerTest starts ./orbit serve as a subprocess and drives it over TCP, which is what substantiates “runs on itself” — the pipeline is answered over a socket, not called in-process.

PHP
<?php
public function test_it_serves_the_index(): void
{
    $response = $this->request("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");

    self::assertStringStartsWith('HTTP/1.1 200 OK', $response);
    self::assertStringContainsString('X-Content-Type-Options: nosniff', $response);
}

Testing the database

SQLite in memory is fast enough to give every test a fresh schema:

PHP
<?php
protected function setUp(): void
{
    $this->database = new Connection(new PDO('sqlite::memory:', null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]));

    (new Migrator($this->database, __DIR__ . '/../../database/migrations'))->migrate();
}

Running the real migrations means the tests also prove the migrations work.

Integration tests

SQLite answers the question “does this code run?” It cannot answer “does MySQL accept this?” — and that is a different question, because a double quote is a string literal on MySQL, a bare OFFSET is a syntax error on two of the three engines, and lastInsertId() means something else again on PostgreSQL. The unit tests assert the SQL phporbit generates by inspecting toSql(). The integration tests assert a server accepts it.

The same applies to mail: the unit tests drive the SMTP conversation over a socket pair with scripted replies, which proves phporbit says the right things — not that a server understands them.

Shell
$ MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_DATABASE=orbit_test \
  MYSQL_USERNAME=orbit MYSQL_PASSWORD=orbit \
  vendor/bin/phpunit --testsuite integration
They skip, they do not fail

Every test in this suite asks for its server first and calls markTestSkipped() when the environment variables are unset or nothing is listening. That keeps composer test useful on a laptop with only SQLite installed. The cost is that a green run proves nothing on its own — which is why CI passes --fail-on-skipped, turning a missing service into a failure there.

Writing one is a matter of using the trait and naming what you need:

PHP
<?php
final class MyIntegrationTest extends TestCase
{
    use RequiresService;

    protected function setUp(): void
    {
        $env = $this->requireEnvironment(['MYSQL_HOST', 'MYSQL_PORT'], 'MySQL');

        $this->requireReachable($env['MYSQL_HOST'], (int) $env['MYSQL_PORT'], 'MySQL');
    }
}

Continuous integration

.github/workflows/ci.yml runs the things a laptop cannot check. Each job exists because something is otherwise only asserted:

JobWhat it establishes
TestsBoth gates on PHP 8.3, 8.4 and 8.5. The floor is where new syntax breaks silently.
IntegrationMySQL, PostgreSQL and Mailpit as service containers, with --fail-on-skipped.
Per-request SAPIBoots the demo through a web SAPI and fetches real pages. The suite runs under the CLI, so this is the only place a CLI-only construct actually surfaces.
Long-lived workerThe other process model, on its own.
DocumentationRebuilds docs/ and fails if the committed pages differ.

The per-request job asks for / and fails if the response carries check-fail — the class a failing self-check row wears. It also checks the page rendered at all first, because an empty response contains no failures either.

The static scans

Some bugs cannot be caught by running code, because the suite runs under one SAPI. PortabilityTest reads the source instead and fails on:

  • STDERR, STDOUT, STDIN outside the CLI-only entrypoints.
  • Superglobals outside the SAPI adapters.
  • Any use of PHP's session extension.

It tokenises rather than pattern-matches, so the prose explaining why a construct is banned does not itself trip the check — and it carries a test proving the scanner reads code rather than comments, so it cannot pass by being blind.

This exists because of a real bug

An earlier version used STDERR in app/bootstrap.php. Every CLI path worked, the whole suite passed, and the application fatal-errored the first time a web server booted it. A unit test could never have caught it.

Static analysis

Shell
$ composer stan

PHPStan at max level, over src, tests, app, public, database and orbit. No baseline and no ignoreErrors — a finding is a defect to fix, not a warning to record. A baseline turns “we have no type errors” into “we have a list of type errors we have agreed not to look at”.

What to test when extending the framework

  • Both process models. If it holds state, add a worker test that handles at least twice.
  • The error path. Resources must be released when a handler throws, not only when it returns.
  • The refusal. Assert that invalid input is rejected, not merely that valid input works — most safety properties are about what does not happen.