API and End-to-End Testing — HTTP Assertions, Auth Flows, and Browser Workflows

Write API tests that send real HTTP requests to a PHP application and assert on JSON response bodies and status codes, cover authentication and authorization paths deliberately (not just the happy path), and understand how browser-driven E2E tools like Panther and Playwright verify a complete user workflow end to end.

API and end-to-end tests sit at the top of the testing pyramid — the fewest in number, but each one proves something no lower layer can: that a real client making a real HTTP request against your real, running application gets back exactly the response it should, including the parts of the contract (status codes, JSON shape, authentication behavior) that a unit or integration test, working below the HTTP layer, never touches at all. This lesson covers both halves of this top layer: API testing, where the test itself acts as an HTTP client hitting your application's endpoints and asserting on status codes and JSON, with particular attention to authentication and authorization paths since those are exactly the behaviors most likely to go untested and most costly to get wrong; and, more briefly, end-to-end browser testing with tools like Playwright or PHP's own Panther, which drive a real browser through a complete user workflow.

API tests: real HTTP requests, real responses

An API test sends an actual HTTP request — GET, POST, PUT, DELETE — to your application (either a fully running instance, or, in frameworks like Laravel and Symfony, an in-process request/response cycle that behaves identically without needing a real network socket) and asserts on what comes back: the HTTP status code, the response headers, and the JSON body's exact shape and values. This is meaningfully different from a feature test that calls a service class method directly, because it also exercises the layers a service-level test skips entirely — routing (does this URL correctly map to this controller?), request parsing (are query parameters and JSON bodies correctly decoded?), middleware (does authentication middleware correctly run before the controller?), and serialization (does the controller's return value actually turn into the JSON shape clients expect?).

A thorough API test doesn't stop at 'the happy path returns 200 with the right data' — it deliberately covers the status codes and shapes for every distinct outcome a client of that endpoint might encounter: 201 Created with a Location header on a successful POST, 400 Bad Request with a validation-error body for malformed input, 404 Not Found for a missing resource, 422 Unprocessable Entity for semantically invalid input that's syntactically well-formed. Each of these is a real, distinct contract the API makes with its consumers, and each deserves its own explicit test rather than being an afterthought to the success case.

• API tests assert on status code + JSON body/shape, exercising routing, middleware, and serialization — layers a service-level test skips. • Cover every distinct outcome deliberately: success (200/201), validation failure (400/422), not found (404), not just the happy path. • Many PHP frameworks offer an in-process test client (Laravel's $this->postJson(), Symfony's KernelTestCase client) that behaves like a real HTTP round trip without a real network socket. • Assert on the JSON body's actual structure and values, not just the status code — a 200 with the wrong data is still a broken contract.

Testing authentication and authorization deliberately

Authentication (who are you?) and authorization (are you allowed to do this?) failures are among the most consequential bugs an API can have, and they're exactly the kind of thing that's easy to leave untested if a suite only covers the 'a logged-in user with the right permissions calls the endpoint successfully' path. A thorough API test suite explicitly tests the negative cases: a request with no authentication token returns 401 Unauthorized, not a 200 with empty or partial data; a request with a valid token but insufficient permissions (a regular user hitting an admin-only endpoint) returns 403 Forbidden, not a silent success; and — critically for anything involving per-resource ownership — a valid, authenticated user cannot read or modify another user's resource just because they guessed or enumerated its ID, which should return 403 or 404 rather than leaking the other user's data.

This last case (resource ownership) is worth calling out specifically because it's a common, real vulnerability class (insecure direct object reference / IDOR) and because it's invisible to a test suite that only checks 'is the user authenticated' without also checking 'is this specific resource actually theirs.' A good pattern is to create two distinct authenticated test users in the test setup, have one create a resource, and assert the other explicitly cannot read, modify, or delete it — proving authorization is enforced per-resource, not just per-endpoint.

• No token / invalid token -> assert 401, not a partial or empty success response. • Valid token, insufficient role/permission -> assert 403, not a silent success. • Valid token, wrong resource owner -> assert 403/404, never the other user's data (guards against IDOR-style bugs). • Use two distinct test users/tokens in setup specifically to prove authorization is enforced per-resource, not just per-endpoint.

JSON assertions: structure, not just presence

Asserting on a JSON API response well means checking more than 'the response decoded without error' — it means pinning down the actual keys, types, and values that matter, using assertions (or a framework's fluent JSON-assertion helpers like assertJsonPath() / assertJsonStructure()) that fail clearly when the shape drifts. A response body assertion should check the specific fields the test cares about (assertJsonPath('data.email', 'alice@example.com')) rather than a brittle full-body string comparison that breaks the moment an unrelated field like a timestamp or a newly added field changes, and should check that fields expected to be absent (an internal id, a password hash) are genuinely absent, not just that the fields you expect are present.

For a public API with external consumers, this kind of precise, per-field assertion also acts as a lightweight contract test: it catches an accidental breaking change (a renamed field, a type that silently changed from a number to a string) before it ships and breaks every client depending on the old shape — exactly the failure mode dedicated contract testing exists to prevent, achieved here just with careful, specific assertions in ordinary API tests.

End-to-end and browser workflow testing

Where API tests stop at the HTTP boundary, E2E tests go one layer further and drive a real browser through the same steps a real user would take, verifying the full stack including any client-side JavaScript, real page rendering, and real navigation between pages. Playwright (a Node-based browser automation tool commonly driven from a PHP project via a thin CLI/process wrapper, or through community PHP bindings) and Panther (a PHP-native library built on the WebDriver protocol / a headless Chrome instance) both let a test open a real browser, navigate to a URL, fill in and submit a real form, click a real button, and assert on what actually renders afterward.

Because this lesson's earlier lesson on the testing pyramid already covers why E2E tests are reserved for a small number of critical journeys, the practical guidance here is narrow and concrete: write E2E tests for complete workflows, not individual page elements (a full 'register, verify email link, log in' flow rather than a test that just checks a button exists), keep assertions on user-visible outcomes (page text, a redirect, a visible success message) rather than internal implementation details, and expect these tests to need explicit waits for asynchronous page behavior (a page that loads data via JavaScript after the initial page load) since a test that doesn't wait correctly for the DOM to settle is the single most common source of E2E flakiness.

• Playwright / Panther drive a REAL browser — real JavaScript execution, real rendering, real navigation, the full stack. • Write E2E tests around complete user workflows (register -> verify -> log in), not isolated checks of individual elements. • Assert on user-visible outcomes (text on the page, a redirect, a success banner) rather than internal implementation details. • Explicit waits for asynchronous page behavior are essential — the most common source of E2E flakiness is not waiting correctly for the DOM to settle.

API test assertions and a Panther E2E snippet

assertJsonMissing() is as important as asserting what IS present — it catches an internal field accidentally leaking into a public API response. Testing 401 (no token) and 403 (wrong permissions/owner) as their own explicit test cases, not as an afterthought to the happy path. $client->waitFor(...) in Panther waits for a selector to appear before asserting — skipping this is the top cause of flaky E2E tests.

// --- API test (Laravel-style fluent JSON assertions) ---
$response = $this->postJson('/api/orders', ['items' => [...]], [
    'Authorization' => 'Bearer ' . $token,
]);

$response->assertStatus(201);
$response->assertJsonPath('data.total_cents', 1000);
$response->assertJsonMissing(['internal_id']); // internal field must NOT leak to clients

// --- Auth/authorization negative cases ---
$this->getJson('/api/orders/42')->assertStatus(401);              // no token at all
$this->getJson('/api/orders/42', $authHeaders)->assertStatus(403); // token belongs to a different user

// --- E2E with Panther (PHP-native, real headless browser) ---
use Symfony\Component\Panther\PantherTestCase;

final class RegistrationE2ETest extends PantherTestCase {
    public function testUserCanRegisterAndSeeWelcomeMessage(): void {
        $client = static::createPantherClient();
        $client->request('GET', '/register');
        $client->submitForm('Sign up', [
            'email' => 'newuser@example.com',
            'password' => 'a-strong-password',
        ]);
        $client->waitFor('.welcome-banner'); // explicit wait for async render
        $this->assertSelectorTextContains('.welcome-banner'
…

Code Examples

Four tests, four distinct contracts: success returns the right JSON with a Location header, invalid input returns a structured 422 error rather than a generic failure, a missing token returns 401 rather than accidentally succeeding, and — the case most suites forget — a second, legitimately authenticated user is explicitly proven unable to read the first user's order, guarding against an authorization bug that only per-resource testing can catch. $this->userA and $this->userB are two distinct authenticated fixtures created in the test class's setUp(), specifically so ownership-based authorization — not just 'is any token present' — gets verified.

$response->assertStatus(403); // authenticated, but not the owner

This single test proves the entire user-visible journey works with a real browser, real page rendering, and real navigation between pages — something no combination of unit, integration, or even API tests can fully guarantee, since none of them execute real client-side JavaScript or render real HTML the way an actual user's browser does. The explicit waitFor() calls before each assertion on asynchronously-updated content are what keep this test from becoming flaky — asserting immediately after an action, before the page has finished updating, is the most common cause of intermittent E2E failures.

declare(strict_types=1);

final class OrdersApiTest extends \Tests\ApiTestCase
{
    public function testCreatingAnOrderReturns201WithLocationAndBody(): void
    {
        $response = $this->postJson('/api/orders', [
            'items' => [['sku' => 'widget', 'qty' => 2]],
        ], $this->authHeadersFor($this->userA));

        $response->assertStatus(201);
        $response->assertJsonPath('data.total_cents', 1000);
        $response->assertHeader('Location');
    }

    public function testCreatingAnOrderWithNoItemsReturns422(): void
    {
        $response = $this->postJson('/api/orders', ['items' => []], $this->authHeadersFor($this->userA));

        $response->assertStatus(422);
        $response->assertJsonPath('errors.items.0', 'At least one item is required.');
    }

    public function testRequestWithNoTokenReturns401(): void
    {
        $this->getJson('/api/orders/1')->assertStatus(401);
    }

    public function testUserCannotReadAnotherUsersOrder(): void
    {
        $orderId = $this->createOrderFor($this->userA);

        $response = $this->getJson("/api/orders/{$orderId}", $this->authHeadersFor($this->userB));

        $response->assertStatus(403); // authenti
…
declare(strict_types=1);

use Symfony\Component\Panther\PantherTestCase;

final class CheckoutE2ETest extends PantherTestCase
{
    public function testGuestCanAddItemAndCompleteCheckout(): void
    {
        $client = static::createPantherClient();

        $client->request('GET', '/products/widget');
        $client->clickLink('Add to cart');

        $client->waitFor('.cart-count'); // wait for the async cart badge to update
        $this->assertSelectorTextContains('.cart-count', '1');

        $client->request('GET', '/checkout');
        $client->submitForm('Place order', [
            'shipping_address' => '123 Main St',
            'card_number' => '4242424242424242', // test-mode payment provider card
        ]);

        $client->waitFor('.order-confirmation');
        $this->assertSelectorTextContains('.order-confirmation', 'Thank you for your order');
    }
}

What each layer at the top of the pyramid actually proves

Each layer catches a class of bug the one below it structurally cannot see.

Feature/service test      API test                         E2E/browser test
  ---------------------     ----------------------------     --------------------------------
  Calls a service method     Sends a real HTTP request         Drives a REAL browser
  directly, no HTTP layer    (or in-process equivalent)        Real JS execution, real render
  Skips: routing, auth       Exercises: routing, middleware,   Exercises: EVERYTHING above, plus
  middleware, serialization   auth, request/response shape      client-side behavior end to end

  Fast <----------------------------------------------------------------------> Slow, most realistic

Important Rules

Assert on both status code AND JSON body/shape for every API test — a 200 with wrong data is still a broken contract.

Explicitly test every distinct outcome an endpoint can produce, not just the happy path: success, validation failure, not found.

Always test the negative authentication case (no/invalid token -> 401) and negative authorization cases (wrong role or wrong resource owner -> 403).

Use two distinct authenticated test users to prove per-resource authorization, not just per-endpoint authentication.

Reserve browser-driven E2E tests for complete, critical user workflows, and always wait explicitly for asynchronous content before asserting on it.

How this looks in a real PHP application's suite

A REST API's test suite includes a dedicated Api/ directory with tests for every endpoint covering success, validation-error, 401, and 403 cases as a matter of course — often generated from a checklist so a new endpoint's PR isn't considered complete until all four are present. Two long-lived test user fixtures (one regular, one admin, sometimes a third for cross-tenant testing in a multi-tenant app) are shared across the API test suite specifically to make authorization testing consistent and easy to write. A short list of Panther or Playwright E2E tests — registration, login, checkout, and password reset — runs in CI on every merge to the main branch (though not necessarily on every single commit, given their cost), while the much larger API and unit test layers run on every push.

public function testGetOrder(): void { $response = $this->getJson('/api/orders/1', $this->authHeadersFor($this->userA)); $response->assertStatus(200); } // no test exists for what happens when userB requests userA's order public function testUserCannotReadAnotherUsersOrder(): void { $orderId = $this->createOrderFor($this->userA); $response = $this->getJson("/api/orders/{$orderId}", $this->authHeadersFor($this->userB)); $response->assertStatus(403); }

Only testing that a resource's owner can read it leaves the far more dangerous case — a different, authenticated user reading someone else's data — completely unverified, which is exactly the kind of authorization gap (insecure direct object reference) that shows up as a real, damaging vulnerability if it's never explicitly tested.

$client->clickLink('Add to cart'); $this->assertSelectorTextContains('.cart-count', '1'); // asserts immediately, before the async update lands

$client->clickLink('Add to cart'); $client->waitFor('.cart-count'); $this->assertSelectorTextContains('.cart-count', '1');

Asserting immediately after an action that triggers an asynchronous page update (an AJAX call updating a cart badge) is a race condition — the test sometimes passes and sometimes fails depending on timing, producing exactly the intermittent flakiness that erodes trust in an E2E suite; an explicit wait for the expected content removes the race.

What does an API test verify that a service-layer (feature) test calling a class method directly does not? A. Nothing — they verify exactly the same things. B. Routing, middleware (including authentication), request parsing, and response serialization — the HTTP-layer concerns that sit between a raw method call and a real client. C. Only the database schema. D. Only whether Composer dependencies are installed correctly.

B. Routing, middleware (including authentication), request parsing, and response serialization — the HTTP-layer concerns that sit between a raw method call and a real client. — A service-layer test skips the HTTP boundary entirely, so it can't catch a routing misconfiguration, a middleware bug, or a serialization mismatch — an API test, by sending a real (or in-process equivalent) HTTP request, exercises all of those layers.

Why should an API test suite include a case where one authenticated user tries to access another user's resource? A. It's not necessary if the endpoint already checks that a valid token is present. B. Because authentication (who you are) and authorization (what you're allowed to do to a specific resource) are different checks, and only testing the first leaves ownership-based access-control bugs (like IDOR) completely unverified. C. Because PHPUnit requires at least two test users per test class. D. To test database performance under concurrent access.

B. Because authentication (who you are) and authorization (what you're allowed to do to a specific resource) are different checks, and only testing the first leaves ownership-based access-control bugs (like IDOR) completely unverified. — A valid token proves who the requester is, but not that they're allowed to touch a specific resource — testing with a second, distinct authenticated user is the only way to catch a bug where authorization is checked per-endpoint but not per-resource.

What is the most common cause of flakiness in browser-driven E2E tests? A. Using PHPUnit instead of a JavaScript test framework. B. Asserting on page content before an asynchronous update (like an AJAX-driven UI change) has actually finished, instead of explicitly waiting for it. C. Running tests in alphabetical order. D. Using too many data providers.

B. Asserting on page content before an asynchronous update (like an AJAX-driven UI change) has actually finished, instead of explicitly waiting for it. — Asserting immediately after an action that triggers an async update creates a race condition — the test's outcome depends on unpredictable timing — which explicit waits (for a selector or specific content to appear) are specifically designed to eliminate.

What should a thorough API test check beyond just the HTTP status code? A. Nothing else is necessary if the status code is correct. B. The actual JSON response body's structure and values (and that unwanted fields are absent), since a correct status code with an incorrect or leaking body is still a broken contract. C. Only the response's Content-Length header. D. The exact byte size of the HTTP response.

B. The actual JSON response body's structure and values (and that unwanted fields are absent), since a correct status code with an incorrect or leaking body is still a broken contract. — A 200 status code with the wrong data, a missing field a client depends on, or an internal field that accidentally leaked into the response are all real, broken contracts that a status-code-only assertion would completely miss — thorough API tests check the JSON body's actual shape and values too.

Write an API test suite (at least 4 test methods) for a DELETE /api/notes/{id} endpoint that should: return 204 with no body when the authenticated owner deletes their own note; return 404 when the note ID doesn't exist; return 401 when no auth token is provided; and return 403 when a different authenticated user tries to delete someone else's note. Then write one line describing what a corresponding E2E test for 'delete a note' would additionally verify that the API test cannot.

public function testOwnerCanDeleteTheirNote(): void { $id = $this->createNoteFor($this->owner); $this->deleteJson("/api/notes/{$id}", [], $this->authHeadersFor($this->owner))->assertStatus(204); $this->getJson("/api/notes/{$id}", $this->authHeadersFor($this->owner))->assertStatus(404); } public function testDeletingMissingNoteReturns404(): void { $this->deleteJson('/api/notes/999999', [], $this->authHeadersFor($this->owner))->assertStatus(404); } public function testNoTokenReturns401(): void { $id = $this->createNoteFor($this->owner); $this->deleteJson("/api/notes/{$id}")->assertStatus(401); } public function testOtherUserCannotDeleteTheNote(): void { $id = $this->createNoteFor($this->owner); $this->deleteJson("/api/notes/{$id}", [], $this->authHeadersFor($this->other))->assertStatus(403); } // An E2E test would additionally verify that clicking a real 'Delete' button in the UI, confirming // a real confirmation dialog, and seeing the note actually disappear from the rendered page all work // together through real client-side JavaScript — none of which the API test alone exercises.

Use two distinct authenticated test users (owner and other) created in setUp(), and assert both the status code and, for the success case, that a follow-up GET on the same ID now returns 404.