06 Integration Tests
Goal
Write a PHPUnit test that drives the API over real HTTP — starting an actual php -S server as a subprocess, not calling handler functions directly the way Intermediate — Tests did.
Prerequisites
Why This Is a Different Kind of Test
The intermediate tier's tests called handleCreate($pdo, $body) directly — fast, but it never exercises the router's dispatch logic, the rate limiter sitting in front of it, or real HTTP semantics (status codes, headers) end to end. An integration test starts the real server and talks to it over a real socket, the same way a client actually would.
Starting a Real Server from PHPUnit
PHP has no test-server helper built in — proc_open starts php -S as a genuine subprocess, and curl (the extension, not the CLI) makes real HTTP requests against it:
Create tests/IntegrationTest.php:
<?php
use PHPUnit\Framework\TestCase;
class IntegrationTest extends TestCase
{
private static $process;
private static string $dbPath;
private static int $port = 8765;
public static function setUpBeforeClass(): void
{
self::$dbPath = sys_get_temp_dir() . '/integration-' . uniqid() . '.db';
$cmd = sprintf(
'ITEMS_DB=%s JWT_SECRET=test-secret-long-enough-for-hs256-32b php -S localhost:%d %s/../router.php',
escapeshellarg(self::$dbPath),
self::$port,
__DIR__
);
self::$process = proc_open($cmd, [], $pipes);
usleep(500000);
}
public static function tearDownAfterClass(): void
{
proc_terminate(self::$process);
proc_close(self::$process);
@unlink(self::$dbPath);
}
private function baseUrl(): string
{
return 'http://localhost:' . self::$port;
}
public function testFullFlow(): void
{
$loginResponse = $this->request('POST', '/login', ['username' => 'alice']);
$this->assertSame(200, $loginResponse['status']);
$token = $loginResponse['body']['token'];
$unauth = $this->request('POST', '/items', ['name' => 'pen']);
$this->assertSame(401, $unauth['status']);
$created = $this->request('POST', '/items', ['name' => 'pen'], $token);
$this->assertSame(201, $created['status']);
$list = $this->request('GET', '/items');
$this->assertSame(200, $list['status']);
$this->assertCount(1, $list['body']);
}
private function request(string $method, string $path, ?array $body = null, ?string $token = null): array
{
$ch = curl_init($this->baseUrl() . $path);
$headers = ['Content-Type: application/json'];
if ($token !== null) {
$headers[] = "Authorization: Bearer {$token}";
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
return ['status' => $status, 'body' => json_decode($raw, true)];
}
}A few details worth explaining:
ITEMS_DBpoints at a real temp file, not:memory:. Every requestrouter.phpserves callsopenDb($path)fresh — underphp -S, each request is still a separate script execution (see the shared-nothing note in 01 Why Production Hardening). An in-memory SQLite database would be created and discarded on every single request, losing all data between the login call and the assertion that reads it back. A temp file persists across the whole test's requests the wayitems.dbwould in real use, andtearDownAfterClassdeletes it afterward.JWT_SECRETin the test command is 37 characters — comfortably past the 32-byte minimum from 02 JWT Auth. A shorter placeholder here would 500 on the login request and fail the test with a confusing HTTP-level error instead of a clear assertion message.- No
curl_close($ch)call. As of PHP 8.0,curl_close()is a no-op —CurlHandleis a regular object PHP garbage-collects automatically — and PHP 8.5 formally deprecates calling it at all. Omit it; there's nothing to close. usleep(500000)gives the server half a second to finish starting before the first request — a fixed delay, simple and reliable enough for a local dev server that starts near-instantly; a flakier CI environment might need a small retry loop instead.
PHPUnit Config
Same phpunit.xml from the intermediate tier — no changes needed; PHPUnit picks up any file under tests/.
Checkpoint
vendor/bin/phpunitExpected:
PHPUnit ...
. 1 / 1 (100%)
OK (1 test, 5 assertions)Next
Continue to 07 Config and Secrets.