Testing¶
Kinetis tests your application through the application — the real container, the real discovery, the real middleware pipeline — rather than against a hand-assembled approximation of it. A test that passes tells you the request would have worked.
Testing a route¶
Extend Kinetis\Testing\ApplicationTestCase and point it at your project
root. It boots the application before each test and gives you a client:
use Kinetis\Testing\ApplicationTestCase;
final class OrderControllerTest extends ApplicationTestCase
{
protected function projectRoot(): string
{
return dirname(__DIR__);
}
public function test_it_lists_orders(): void
{
$this->client->get('/orders')
->assertOk()
->assertJsonPath('0.sku', 'A1');
}
public function test_it_rejects_an_invalid_order(): void
{
$this->client->post('/orders', ['sku' => '', 'quantity' => 2])
->assertValidationError('sku');
}
}
Nothing is registered by hand: routes, middleware, event listeners, and
package bootstraps are discovered exactly as they are at runtime, and
bootstrap.php runs. A route you just wrote is testable without touching
the test’s setup.
Three properties are available on the test case: $this->client for
requests, $this->app for the booted container, and $this->application
for the TestApplication itself.
Overriding configuration¶
Return whatever a test run should differ on — a test database, a fake
endpoint. These win over both the real environment and .env:
protected function configOverrides(): array
{
return ['DB_NAME' => 'app_test', 'MAILER_DSN' => 'null://null'];
}
Replacing what a test should not reach¶
Configuration only goes so far: some things a request touches are
services, not settings — a payment gateway, a WebSocket server, a queue
you would rather hold a job than run it. Register a replacement in
registerTestDoubles(), which runs after your own bootstrap.php and
before the container locks, so a binding made here replaces the one the
application made:
use Kinetis\Config\Config;
use Kinetis\Container\AppScope;
protected function registerTestDoubles(AppScope $app, Config $config): void
{
$app->instance(PaymentGateway::class, new FakeGateway());
}
That window is the only one there is: AppScope refuses new bindings
once boot() has run, so a double registered from a test body would be
too late.
kinetis/pingpong’s own controller test is this in practice — it
replaces the Soketi publisher and the queue, which leaves a real MySQL
as the only thing it needs, and the suite runs against a bare database
rather than only inside the full compose stack.
Making requests¶
$this->client->get('/users', query: ['page' => 2, 'limit' => 10]);
$this->client->post('/orders', body: ['sku' => 'ABC123', 'quantity' => 2]);
$this->client->put('/users/42', body: ['name' => 'Ada']);
$this->client->delete('/users/42', headers: ['Authorization' => 'Bearer test-token']);
body is a plain array, JSON-encoded automatically, with
Content-Type: application/json set unless you pass your own. Every verb
method calls request(), which is available directly for anything the
shorthands don’t cover:
$this->client->request('PATCH', '/users/42', body: $payload, headers: $headers);
Asserting on the response¶
Responses come back as Kinetis\Testing\TestResponse — a PSR-7 response
with assertions attached, so getStatusCode()/getBody() still work and
the response can still be passed to anything expecting plain PSR-7.
$response = $this->client->post('/orders', ['sku' => 'A1', 'quantity' => 2]);
$response->assertCreated()
->assertHeader('Content-Type', 'application/json')
->assertJsonPath('order.sku', 'A1')
->assertJsonPathMissing('order.internal_cost');
Assertion |
Passes when |
|---|---|
|
the status matches exactly |
|
200 / 201 / 404 |
|
any 2xx |
|
the header is present, and equals |
|
the whole decoded body matches exactly |
|
the value at a dot path — |
|
nothing is at that path |
|
the response is 422 and names that field |
|
the raw body contains the text |
A failed assertion prints the response body alongside the mismatch, since
an unexpected status is usually explained by what the body says. json()
and body() are there for anything the assertions don’t cover, and both
can be called repeatedly — reading the body doesn’t consume it.
Testing against a database¶
A test that writes rows has to leave the database as it found it, or the
next test inherits its data. kinetis/persistence ships two strategies;
both are PHPUnit traits, and both ask the test which connection to
isolate.
Rolling back: DatabaseTransactions¶
Opens a transaction before each test and rolls it back after. Fast — nothing is ever written — and it covers writes the application makes through the container’s own client, not just ones the test issues directly.
use Kinetis\Persistence\Contract\MysqlLink;
use Kinetis\Persistence\Contract\SqlLink;
use Kinetis\Persistence\Testing\DatabaseTransactions;
use Kinetis\Testing\ApplicationTestCase;
final class OrderRepositoryTest extends ApplicationTestCase
{
use DatabaseTransactions;
protected function projectRoot(): string
{
return dirname(__DIR__);
}
protected function databaseLink(): SqlLink
{
return $this->app->get(MysqlLink::class);
}
public function test_it_stores_an_order(): void
{
$this->client->post('/orders', ['sku' => 'A1', 'quantity' => 2])->assertCreated();
self::assertSame(1, $this->orderCount());
}
}
That works because the PDO drivers hold a single connection, and a transaction opened on it encloses every later statement on it. Two cases fall outside that, and both are loud rather than silent:
Code that opens its own transaction. The drivers reject nested transactions deliberately, so
TransactionGuard::transaction()or an explicitbeginTransaction()in the code under test throws while this trait holds one open.DB_DRIVER=native. The async drivers pool several connections, so a transaction on one isolates nothing the others do. The trait skips the test rather than reporting isolation it isn’t providing. Test suites run under the CLI, whereautoalready selects PDO, so this only comes up if a suite forcesnative.
Use the other strategy for either.
Emptying tables: DatabaseTruncation¶
Deletes the rows in the tables you name, before each test. Slower, and it holds no transaction of its own — so it works for code that manages its own transactions, and for any driver.
use Kinetis\Persistence\Testing\DatabaseTruncation;
final class CheckoutTest extends ApplicationTestCase
{
use DatabaseTruncation;
protected function projectRoot(): string
{
return dirname(__DIR__);
}
protected function databaseLink(): SqlLink
{
return $this->app->get(MysqlLink::class);
}
/** @return list<string> */
protected function tablesToTruncate(): array
{
return ['order_items', 'orders'];
}
}
Tables are listed explicitly rather than discovered: a suite that empties every table it can find eventually empties one holding reference data the application needs, and that failure looks like a bug in the code under test. List child tables before their parents where a foreign key would otherwise block the delete.
Truncation happens before each test rather than after, so a failing test leaves its rows behind to inspect.
Note
Schema creation belongs outside both traits — in a migration run once
before the suite, not in a test. On MySQL, CREATE TABLE commits the
surrounding transaction implicitly, which would silently end
DatabaseTransactions’ isolation for the rest of that test.
Without the base class¶
ApplicationTestCase is thin wiring over Kinetis\Testing\TestApplication,
which has no PHPUnit dependency at all. Use it directly to share one
application across a whole test class, or from a different runner:
$application = TestApplication::boot(__DIR__ . '/..', ['DB_NAME' => 'app_test']);
$application->client()->get('/orders')->assertOk();
$repository = $application->get(OrderRepository::class);
TestApplication::withRouter() builds one from an explicit route table
instead of discovery, for a test that wants a fixed set of routes rather
than whatever the project contains:
$router = new Router();
$router->register(OrderController::class);
$client = TestApplication::withRouter($router)->client();
Conformance-testing a runtime adapter¶
A runtime adapter turns whatever its environment delivers — superglobals
and php://input, an API Gateway event — into a PSR-7 request, and turns
the PSR-7 response back. Two adapters built through different code have
to agree on what that conversion means: which header a repeated header
becomes, where cookies end up, that a PUT or PATCH form body parses
the same as a POST one (url-encoded and multipart alike), that the
declared Content-Length and a large body both arrive intact, that a
binary body arrives byte for byte, that two Set-Cookie headers leave
as two cookies, what happens to a body the environment cannot parse. Kinetis\Testing\Runtime expresses each of
those once, as a PHPUnit base class, and runs the whole list against any
adapter that provides a driver:
use Kinetis\Testing\Runtime\RuntimeAdapterConformanceTestCase;
use Kinetis\Testing\Runtime\RuntimeAdapterDriver;
final class SwooleConformanceTest extends RuntimeAdapterConformanceTestCase
{
protected function driver(): RuntimeAdapterDriver
{
return new SwooleDriver();
}
}
The driver is the only adapter-specific code. It pushes one
WireRequest (method, path, query string, headers as repeatable pairs,
cookies, raw body) through the adapter, has the handler answer with the
given ResponseSpec, and reports an Outcome: the ObservedRequest
the handler saw (null if the adapter never reached it) and the
WireResponse the environment received — or an AdapterRejection, when
the adapter refused outright.
interface RuntimeAdapterDriver
{
public function dispatch(WireRequest $request, ResponseSpec $response): Outcome;
public function expectedClientIp(): string;
public function supportsStreaming(): bool;
public function unparseableFormRequest(): WireRequest;
}
The last three are facts the environment decides, not the test: the
address it reports as REMOTE_ADDR (a real socket’s peer for a SAPI,
whatever the driver injects as sourceIp for Lambda), whether a
StreamedResponse can reach the client incrementally, and what a form
body it cannot parse looks like (past post_max_size for a SAPI; no
usable boundary for an adapter that parses the body itself). The suite
asserts against the declaration either way — a streaming environment
must deliver every chunk in order, a non-streaming one must refuse the
response rather than buffer it — so every method runs on every adapter.
Nothing is skipped.
The core adapters run this suite themselves. SuperglobalsBridge, which
FpmAdapter and FrankenPhpAdapter share, is driven through a real
php -S process (the only way php://input and request_parse_body()
see a genuine request); kinetis/bref-adapter drives
BrefLambdaAdapter::handleEvent() in-process, building the event the
way API Gateway would. Read either driver for a worked example —
Kinetis\Tests\Runtime\Conformance\SuperglobalsDriver in the framework
package, Kinetis\BrefAdapter\Tests\Conformance\LambdaDriver in the
adapter’s.
Only behavior every environment can exhibit belongs in the shared suite.
An input one environment alone can produce — a base64-flagged event
body, an absent client address — is that adapter’s own test to write,
alongside the conformance run; the suite’s public assertion helpers
(assertMalformedBodyResponse()) hold that input to the same contract
the shared cases use, so the outcome stays unified even where the
trigger can’t be. The byte cap on a request body is not the adapter’s
to test — it is MaxBodySizeMiddleware’s, in the Kernel, identical
under every adapter and tested there. Kinetis\Testing\FreePort::reserve()
hands a fixture server a port nothing is listening on, so two suites
spawning servers in one checkout don’t collide on a hard-coded number.
What each run proves, precisely. The committed framework suite spawns
php -S and, through RuntimeDetector, runs FpmAdapter under the CLI
server’s superglobal population; the bref-adapter suite runs the Lambda
conversion in-process. The real SAPIs — a FrankenPHP worker loop behind
Caddy, and PHP-FPM behind nginx — run the identical suite in CI
(integration.yml’s runtime-conformance job), the same driver pointed
at a container instead of a spawned process. That is where each SAPI’s
own population of headers, client address and body, and its own
streaming path, are exercised; the streaming case times the body as it
arrives, so a proxy holding a stream back until the end fails it — as
nginx does with its default fastcgi_buffering, which the FPM fixture
turns off.
See also¶
Runtime Adapters — the adapters this suite holds to one contract, and how to write your own.
Routing & Validation — the routes and DTOs these requests target.
Container —
AppScopeand the binding rules a booted application follows.Persistence — driver selection, and why a test run gets the PDO drivers.