Skip to content
advanced Phase 16 · Capstone: Production PHP Application

Covering the API with PHPUnit

Test status codes, payloads, and authorization paths.

1h
0 problems
Topic Progress 0%

Wiring PHPUnit to a Real Request Against a Seeded Test Database

A green suite that exercises nothing is worse than no suite: it converts "we never tested this" into "we tested this and it passed." The fix is a harness that sends real requests through the same kernel the web server uses, against a database that is isolated and reproducible. Three pieces: the dev dependency, the configuration, and the test client.

composer require --dev phpunit/phpunit:^11
vendor/bin/phpunit --version    # PHPUnit 11.x — confirm before copying config between projects

phpunit.xml keeps environment out of the tests themselves, so the same file works locally and in CI:

<phpunit bootstrap="tests/bootstrap.php" colors="true">
  <testsuites><testsuite name="api"><directory>tests</directory></testsuite></testsuites>
  <php>
    <env name="APP_ENV" value="testing"/>
    <env name="DATABASE_URL" value="mysql:host=127.0.0.1;dbname=inventory_test"/>
    <env name="DB_USER" value="root"/>
  </php>
</phpunit>

The test database is its own object, not an afterthought: CREATE DATABASE IF NOT EXISTS inventory_test, migrated with the same runner that production uses (php bin/migrate.php up pointed at the test URL by the env var), and never the development or staging database. That last rule is one DATABASE_URL typo away from a horror story, so the bootstrap should refuse to run when APP_ENV=testing and the DSN does not contain test.

The client skips only the socket:

final class ApiTestCase extends TestCase
{
    protected static PDO $pdo;
    private TestClient $client;

    public static function setUpBeforeClass(): void
    {
        self::$pdo = Db::connect();                       // from DATABASE_URL
        self::$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
        foreach (['stock_adjustments','stock_levels','products','users'] as $t) {
            self::$pdo->exec("TRUNCATE TABLE $t");        // deterministic state per run
        }
        self::$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
        (new Seeder(self::$pdo))->loadMinimalFixtures();  // admin + worker + 121 products
    }

    protected function setUp(): void { $this->client = new TestClient(new AppKernel()); }

    protected function token(string $role = 'admin'): string
    {
        return $this->client->postJson('/auth/token', [
            'email'    => "$role@example.test",
            'password' => 'correct horse battery staple',
        ])->json('data.token');
    }
}

// TestClient.php — the front controller's path, minus globals and exit()
final class TestClient
{
    public function request(string $method, string $uri, array $body = [], ?string $token = null): TestResponse
    {
        $req = new Request($method, $uri, $body, $token ? ['Authorization' => "Bearer $token"] : []);
        return new TestResponse((new AppKernel())->handle($req));   // SAME code path as public/index.php
    }

    public function postJson(string $uri, array $body, ?string $token = null): TestResponse
    {
        return $this->request('POST', $uri, $body, $token);
    }
}

TRUNCATE-plus-fixtures instead of per-test transactions is a deliberate call: tests that assert 204 deletes and foreign-key 409s are asserting commit behavior, and a rolled-back transaction cannot prove what a committed delete does. When a suite only reads and writes rows, the faster transaction-rollback pattern in tearDown() is the better trade — the rule is that no test may observe another test's rows, one way or the other.

Run it the way CI will:

vendor/bin/phpunit --testdox

--testdox rewrites the output into sentences derived from method names and data providers, so the terminal reads like the acceptance list — a reviewer scanning CI output can see coverage claims without opening the code. The point of sending requests through the kernel is that routing, the auth middleware, and the envelope handling built in Implementing the REST Endpoints all execute exactly as they do for a real client; only php://input and the socket are replaced. And CI runs the identical command after migrate up against the test database; if the laptop command and the CI command differ, you are not running the same suite.

One number is worth fixing now: target a suite that finishes under 30 seconds. That budget forces good decisions — in-process requests instead of sockets, one fixture load per class, no real sleeps — and it is what makes running the suite before every commit culturally possible rather than aspirational.

The harness works; what goes in the test methods — status codes, envelopes, the authorization matrix — is the next chapter.

Asserting Codes, Envelopes, and the 401-403-404 Split

With the harness in place, the test methods are mostly one repeated shape: assert the status code first, the envelope shape second, and the field that proves the effect third. That order is not cosmetic. A body-only test passes against a handler returning the right payload with the wrong status code — which breaks every real client while looking green on your laptop.

A thin TestResponse helper keeps the intent readable:

final class TestResponse
{
    public function status(): int            { return $this->status; }
    public function header(string $n): ?string { return $this->headers[$n] ?? null; }
    public function json(string $path = null) { /* dot-path accessor over decoded body */ }
}

Now one write endpoint gets its full matrix — the assertions the acceptance list already promised:

public function test_delete_product_matrix(): void
{
    $this->assertSame(401, $this->client->request('DELETE', '/products/A1')->status());

    $forbidden = $this->client->request('DELETE', '/products/A1', [], $this->token('worker'));
    $this->assertSame(403, $forbidden->status());
    $this->assertSame('forbidden', $forbidden->json('error.code'));

    $ok = $this->client->request('DELETE', '/products/A1', [], $this->token('admin'));
    $this->assertSame(204, $ok->status());
    $this->assertSame('', $ok->body());              // 204 means NO body, not an empty object

    $gone = $this->client->request('DELETE', '/products/A1', [], $this->token('admin'));
    $this->assertSame(404, $gone->status());
}

Note the sequence inside the success case: the second delete returning 404 is not a separate concern — it is what proves the first delete actually committed. Tests that assert only the first response would also pass if the handler merely printed 204 without touching the database.

The same matrix shape, expressed with a data provider, covers the validation surface without copy-paste:

#[DataProvider('invalidPayloads')]
public function test_create_rejects_invalid_body(array $payload, string $badField): void
{
    $res = $this->client->postJson('/products', $payload, $this->token('admin'));
    $this->assertSame(422, $res->status());
    $this->assertSame('validation_failed', $res->json('error.code'));
    $this->assertArrayHasKey($badField, $res->json('error.fields'));
}

public static function invalidPayloads(): iterable
{
    'missing sku'          => [['name' => 'Widget', 'unit_price' => '19.99'], 'sku'];
    'price not numeric'    => [['sku' => 'X1', 'name' => 'W', 'unit_price' => 'free'], 'unit_price'];
    'price negative'       => [['sku' => 'X2', 'name' => 'W', 'unit_price' => '-1'], 'unit_price'];
    'three decimals'       => [['sku' => 'X3', 'name' => 'W', 'unit_price' => '1.999'], 'unit_price'];
    'name over 255 chars'  => [['sku' => 'X4', 'name' => str_repeat('a', 256), 'unit_price' => '1'], 'name'];
}

--testdox turns each provider row into its own line of output, so the terminal literally lists which invalid inputs are guarded. Adding a validation rule later means adding one row here — the test table and the validator table evolve as one artifact.

Two assertion disciplines prevent the two classic mistakes. First, pin types, not just values: assertSame(201, ...) fails on the string '201', and a response whose body decodes to null must not satisfy an json('data') check — loose equality hides both. Second, avoid whole-body equality on anything with a timestamp or id; assert the fields you depend on (assertJsonPath('data.sku', 'A1')) so a new column in the response does not break a hundred tests while a changed existing field still does.

Headers belong in the matrix as much as bodies, because clients branch on them: assertHeaderSame('Location', '/products/A1') proves the create response points at the resource the follow-up GET will succeed against, Content-Type: application/json on the 422 proves error responses kept the envelope contract, and WWW-Authenticate: Bearer on the 401 is what tells a client interceptor to refresh its token rather than give up. A test that reads only the body will happily pass while all three rot.

List behavior gets the same treatment, because pagination was designed to be asserted: meta.limit caps a request for 500, meta.total reports 121, page=99 returns 200 with an empty data array, and the Location header on create points at the resource you can then GET successfully — a two-request round trip that proves routing, storage, and serialization agree.

All of this protects code that already worked. The next chapter handles the other direction: what to do the moment a bug slips past all of it.

Turning Every Escaped Bug Into a Failing Test First

Every suite eventually meets the bug that escaped it — a support ticket, a failing staging check, a "works for me" from a colleague. How you respond to that moment determines whether the suite is a living safety net or a screenshot in the README. The rule: before you touch the fix, write the test that fails.

A real one from this API. A customer created product A1, then a second request to create A1 was accepted — two products, one visible identifier. The cause was a collation mismatch: products.sku was VARCHAR(64) COLLATE utf8mb4_unicode_ci, so MySQL's case-insensitive comparison let a1 through the app's check-then-insert, while GET /products/{sku} happily returned the original row for a1. The database was comparing case-insensitively; the application was comparing case-sensitively; nobody owned the disagreement.

Step one is the failing test, written against the correct behavior:

public function test_sku_uniqueness_is_case_insensitive_end_to_end(): void
{
    $this->client->postJson('/products',
        ['sku' => 'A1', 'name' => 'Widget', 'unit_price' => '19.99'], $this->token('admin'));

    $dup = $this->client->postJson('/products',
        ['sku' => 'a1', 'name' => 'Widget 2', 'unit_price' => '9.99'], $this->token('admin'));

    $this->assertSame(422, $dup->status());                     // the correct contract
    $this->assertSame('sku_taken', $dup->json('error.code'));
}

Run it and watch it fail: Failed asserting that 201 matches expected 422. That failure is doing three jobs at once. It proves the test has teeth — a test you have never seen fail detects nothing. It documents the regression in the file forever, so the next refactor that reopens the hole breaks CI instead of billing. And it narrows the search: the failure tells you which layer disagrees, which here points at the database, not the PHP check.

Step two is the smallest fix that closes the root cause — aligning the database with the case-sensitive contract the API documents:

-- 0004_sku_bin_collation.sql
ALTER TABLE products
    MODIFY sku VARCHAR(64) NOT NULL
    COLLATE utf8mb4_bin;                          -- 'a1' no longer equals 'A1'

Step three: watch the test go green, then run the whole suite — because the fix has blast radius. Case-insensitive matching had been silently helping the seed script ('A1' vs 'a1') and one filter query; if those were load-bearing, they were load-bearing on a bug. One behavior change, several dependent tests, all visible in a single run.

The collation story has one more lesson worth extracting: the absence of a test was itself the finding. When a bug escapes, spend five minutes asking which acceptance behavior it falls under and why nothing covered it. Three answers recur. The behavior was never written down (add the acceptance line and the test). It was written down but the test asserted the wrong half (a body without a status code). Or it lived in a path nobody classified — a configuration-dependent behavior like collation, where the test must pin the assumption explicitly by seeding A1 and querying a1 rather than trusting the default. Each answer improves the suite at the design level, not just at the bug level.

Two escalation paths complete the discipline:

  • Check the sibling branches. A case-handling bug in sku earns a look at every other WHERE against a *_ci column — email on users is the usual second victim, which is why the schema pins UNIQUE KEY uq_users_email and compares consistently.
  • Triage flakiness the same way. A test that fails "sometimes" is a failing test reporting a real nondeterminism — shared rows, an unseeded clock, an unbounded COUNT(*) racing a write. Fix the test or fix the code; a markTestSkipped is a silent hole with extra steps.

What you have at this point is a suite that fails when behavior changes and passes when it does not — the definition of a safety net rather than a checkbox. The capstone's final turn is getting that suite, the migrations, and the configuration onto a server where a mistake is visible to customers, which is Hardening and Deploying the Release.

Quiz

1. What is the defining property of an integration test for this API, and how is the database handled between tests?

Question 1 options

2. Which assertion set correctly covers the authorization path for DELETE /products/{sku}?

Question 2 options

3. Production logs show page=500 returning 500 rows because a colleague removed the limit cap. What is the correct response?

Question 3 options

4. Why must assertions compare status codes with assertSame(201, ...) rather than checking only the response body?

Question 4 options

Flashcards

Question

How does a test client run a request without starting a web server?

Answer

Build a Request from arrays (method, URI, headers, body) instead of globals and call the same front-controller/kernel function index.php calls, then read status, headers, and json_decode'd body off the returned Response. Identical code path, no socket — fast, deterministic, and still covers routing, middleware, validation, and SQL.

Question

Two accepted ways to isolate the database per test, and the rule for choosing

Answer

Wrap each test in a transaction and roll back in tearDown (fast, ideal for pure-CRUD assertions) or truncate-and-reseed per test (required when a test asserts commits, DDL, or autocommit behavior). The rule: never let one test's rows be visible to another — order-dependent suites are worse than no suite.

Question

What does a data provider buy you for the validation matrix?

Answer

One @dataProvider yields the invalid payloads (missing sku, 255+ char name, negative price, price with 3 decimals) each as its own named test case with its expected field error — so a new rule means adding a row, not a new method, and the testdox output reads like the acceptance list.

Question

The red-green regression workflow after a bug escapes

Answer

1) Write a test that reproduces the reported behavior and assert the *correct* expectation. 2) Watch it fail — proof the test has teeth. 3) Fix the code. 4) Watch it pass, then run the whole suite for collateral damage. The failing test stays in the suite forever as documentation of the regression.

Question

The minimum assertion matrix for one write endpoint

Answer

No token → 401; worker token → 403; valid payload + admin → 201 with Location and body echo; invalid field → 422 with error.fields naming the field; duplicate/unknown target → 422 or 404 as planned; valid delete → 204 with empty body. Assert status FIRST, then envelope shape, then the field that proves the write landed.

Revision Notes

Key Takeaways

  • 1. Integration tests dispatch a real Request through the production kernel and assert the Response — only the socket is skipped, so routing, middleware, validation, and SQL are all covered
  • 2. Isolation is non-negotiable: transaction-rollback or truncate-reseed per test; shared rows make the suite order-dependent and CI flaky
  • 3. Every write endpoint gets the full matrix — 401 no token, 403 wrong role, 201/204 happy path, 422 invalid body, 404 unknown row — driven by data providers
  • 4. Assert status codes first with assertSame, then the envelope shape, then the field proving the effect landed; body-only assertions hide wrong-code regressions
  • 5. Every escaped bug becomes a failing test before the fix — red proves the test detects it, green proves the fix, and the test remains as permanent documentation

Interview Tips

  • • "How do you test an API endpoint?" — name the layers: in-process request, seeded isolated DB, the auth matrix, then status-then-body assertions; mentioning data providers for the invalid-payload table shows you avoid copy-paste tests
  • • "Unit or integration tests for an API?" — integration for endpoints (the value is the wiring), unit for pure logic like validation rules and pagination math; explain that mocking PDO tests your mocks, not your SQL
  • • "A bug reached production — what now?" — red-green: failing test first, watch it fail, fix, watch it pass, keep the test; add that you check whether the missing case points at a whole uncovered branch
  • • "Your suite is flaky — causes?" — shared database state between tests, unseeded fixtures, time/randomness, and real network calls; fixes are per-test transactions, deterministic seeds, injected clocks, and an in-process client

Cheat Sheet

Covering the API with PHPUnit

Setup: composer require --dev phpunit/phpunit:^11; phpunit.xml carries DATABASE_URL (test DB only — bootstrap refuses DSNs without test when APP_ENV=testing); CI runs the exact laptop command: vendor/bin/phpunit --testdox. Budget: suite under 30s.

Client: build a Request from arrays and call the same AppKernel::handle() as public/index.php — same routing/middleware/validation/SQL path, no socket. Never point integration tests at a live server.

Isolation (pick one, never share rows):

Pattern Use when
TRUNCATE + fixtures in setUpBeforeClass() tests assert commits, deletes, FK 409s
per-test transaction, ROLLBACK in tearDown() read/write-only tests; much faster

Assertion order: status (assertSame(201, ...) — pins type) → envelope shape (error.code, data present) → the field proving the effect landed (assertJsonPath, Location header, then GET round-trip). Avoid whole-body equality where timestamps/ids vary. 204 asserts an empty body.

Write-endpoint matrix: no token → 401; worker token → 403; admin + valid → 201/204; invalid field → 422 + error.fields; unknown/duplicate → 404/422 as planned; second DELETE → 404 proves the first committed. Invalid payloads ride a #[DataProvider] so one row per bad input.

Regression discipline (red-green):

  1. Write the test asserting the correct behavior.
  2. Run it — watch it fail (proves the test detects the bug).
  3. Smallest fix at the root cause (e.g. ALTER TABLE products MODIFY sku VARCHAR(64) COLLATE utf8mb4_bin for the case-insensitive-*_ci duplicate bug).
  4. Green, then full suite for collateral damage; the test stays forever.

Flaky test = real nondeterminism (shared rows, clock, races). Fix it; markTestSkipped is a hole with extra steps. Always check the sibling branches of the same class of bug (email collation after sku).

Continue Learning