Writing Tests

Now that you know how the harness runs, let us write tests with it. A test file is just PHP that declares named tests with the global test() helper and states what should be true with the fluent expect() API. There is no boilerplate to return and no object to thread through: you call test() and expect() directly.

The shape of a test file

For pure code, a test file calls test() at the top level and asserts with expect():

<?php

test('validated input is normalized', function (): void {
	$data = validate(['age' => '42'], ['age' => 'required|int']);

	expect($data['age'])->toBe(42);
});

Each test() gets a descriptive name and a closure body. The name is what you match with --filter, so make it readable. If you prefer the spec-style phrasing, it() is an alias that reads naturally: it('normalizes validated input', ...) is recorded as it normalizes validated input.

Expectations

expect($value) returns an expectation you refine with matchers. Each matcher throws a readable failure when it does not hold, and any matcher accepts an optional trailing message. The core ones:

expect($got)->toBe($want);        // strict === (identity)
expect($got)->toEqual($want);     // loose == (value)
expect($condition)->toBeTrue();
expect($condition)->toBeFalse();
expect($value)->toBeNull();
expect($n)->toBeGreaterThan(0);   // also toBeGreaterThanOrEqual / toBeLessThan / ...OrEqual
expect($service)->toBeInstanceOf(Clock::class);
expect($list)->toContain('needle');

Prefix any matcher with ->not to negate it: expect($a)->not->toBe($b) is the "not equal" check. For results whose order is not guaranteed (a query without a total ORDER BY, for instance), compare them as a multiset with strict, type-aware element equality:

expect($rows)->toEqualCanonicalizing($expectedRows); // same elements, order-independent

When you are asserting a named invariant in a property test, toHold() reads better than a bare toBeTrue() and labels the failure clearly:

expect($decoded === $original)->toHold('round-trip must be lossless');

Error paths

toThrow() asserts that a callable throws, optionally of a specific class, and returns the caught throwable so you can inspect it. Its negation, ->not->toThrow(), asserts the callable runs cleanly and returns its result:

expect(fn () => validate([], ['email' => 'required|email']))
	->toThrow(Fast\Validation\ValidationException::class);

$result = expect(fn () => parse_config($input))->not->toThrow();

The optional second argument to toThrow() is a message shown when the assertion fails, not a claim about the exception's own message:

expect(fn () => new Response(200, ['Bad Name' => 'x']))
	->toThrow(InvalidArgumentException::class, 'header name with a space was accepted');

Skipping when a dependency is missing

When a test cannot run because an extension or a DSN is absent, mark it skipped rather than failed with the chained skipUnless() modifier. A skipped test is reported as SKIPPED, never as a false pass:

test('streams rows from PostgreSQL', function (): void {
	// ...
})->skipUnless(
	extension_loaded('pdo_pgsql') && getenv('FAST_PG_DSN') !== false,
	'pgsql driver or FAST_PG_DSN not available',
);

The condition is evaluated when the test is registered, so keep it to environment checks like extension_loaded(), getenv(), or PHP_OS_FAMILY. Its sibling skip() skips unconditionally (or on a condition you pass).

Shared setup with beforeEach / afterEach

When several tests in a file need the same setup or teardown, hoist it into beforeEach() and afterEach() instead of repeating it. Each runs once around every executed test in the file, in the order you registered them:

beforeEach(function (): void {
	Env::reset();
});

afterEach(function (): void {
	Clock::restore();
});

test('...', function (): void { /* ... */ });
test('...', function (): void { /* ... */ });

afterEach runs even when a test fails, so it is the right place for cleanup that must always happen. Skipped tests run neither hook. There is deliberately no beforeAll/afterAll or nested describe: keep shared state minimal and explicit.

Property tests

Beyond example-based tests, prop() runs a closure over many generated inputs, which is great for finding edge cases you would not think to write by hand. The closure receives a seeded Prng (and the case index), so every run is reproducible:

use Fast\Tests\Harness\Prng;

prop('escaping is idempotent for safe text', function (Prng $rng): void {
	$s = $rng->string(0, 64);

	expect(e(e($s)))->toBe(e($s));
}, iterations: 500);

A property failure prints the seed, so you can replay the exact input with --seed=N.

Testing HTTP behavior

For anything involving the real HTTP wire, boot a fixture server through Fast\Tests\Harness\HttpServer, send it raw requests, and assert on the responses. Always stop it in a finally so it never leaks:

test('root route responds', function (): void {
	$server = new Fast\Tests\Harness\HttpServer(/* ... */);
	try {
		$response = $server->raw("GET / HTTP/1.1\r\nHost: x\r\n\r\n");

		expect(str_contains($response, '200'))->toBeTrue();
	} finally {
		$server->stop();
	}
});

HttpServer binds an ephemeral port you read with port(), and gives you raw() for a single request, pipeline() for several, and lower-level open()/readRaw() when you need fine control.

Write tests against the contract, not the code

The most valuable habit in testing Fast apps is to write your assertions from the documented behavior, not by mirroring the implementation:

Prefer, in order: differential comparisons against an independent oracle, properties and invariants, metamorphic relationships, adversarial and fuzz inputs, and only then exact examples, reserved for when the precise bytes or value are the public contract. Do not rewrite an expected result just to match what the code currently does, and do not weaken a real contract to make a test go green. A test that only restates the implementation cannot catch the implementation being wrong.

Favor small, semantic assertions over broad snapshots: check the status, the header, the state change, the ordering guarantee, or the resource release explicitly, rather than diffing a whole blob of HTML or JSON.

That completes the documentation. You now have the full tour of Fast, from your first route to a hardened, tested deployment. Happy building!