๐Ÿ”ฅ 0
โญ 0
Lesson 5 of 9 30 min +250 XP

API Testing & Network Interception

Every test you've written so far drives the browser. But a huge amount of an e-commerce app's correctness lives below the UI โ€” in the HTTP requests the page makes. Testing there is faster, more stable, and lets you check things the UI hides. This lesson adds two skills: testing the API directly, and intercepting the network to control what the UI sees.

> Practice ground: the ShopKart Test Range ships a real mock API. Endpoint docs and a live interception widget are at the [API Lab](https://resources.criodo.com/shopkart/api-lab). Every example below is verified against it.

Why test below the UI

Remember the pyramid from Lesson 1. A UI test that checks "the cart total is right" has to log in, render pages, and click through checkout โ€” 20 seconds, and fragile. The same rule โ€” does the cart compute the right total? โ€” can be checked with one HTTP request in 200 milliseconds, with nothing to render and nothing to flake.

API tests are:

  • Fast โ€” no browser, no rendering. Milliseconds, not seconds.
  • Stable โ€” no locators to break when the UI is restyled.
  • Precise โ€” they test the contract (status code + JSON shape) directly, so a failure points straight at the backend.

The UI test still matters โ€” it's the only thing that proves the user's journey works. But push every check you can down to the API layer, and keep the browser for what only the browser can prove.

The mock API

ShopKart exposes a small, real HTTP API. The endpoints you'll test:

MethodPathReturns
GET/shopkart/api/products{ count, products } โ€” supports ?category= and ?q=
GET/shopkart/api/products/:idthe product, or 404
POST/shopkart/api/login200 { token } ยท 401 wrong creds ยท 423 locked
POST/shopkart/api/checkout201 { orderId, total } ยท 400 { errors }

Testing the API directly

Playwright gives every test a request fixture โ€” an HTTP client that needs no browser. Point it at an endpoint and assert on the response. All of these pass against the live API:

import { test, expect } from '@playwright/test';

test('products endpoint returns the catalog', async ({ request }) => {
  const res = await request.get('/shopkart/api/products');
  expect(res.status()).toBe(200);
  const body = await res.json();
  expect(body.count).toBe(12);
  expect(body.products[0]).toHaveProperty('price');
});

test('a missing product returns 404', async ({ request }) => {
  const res = await request.get('/shopkart/api/products/NOPE');
  expect(res.status()).toBe(404);
});

test('search narrows the catalog', async ({ request }) => {
  const res = await request.get('/shopkart/api/products?q=cable');
  const body = await res.json();
  expect(body.count).toBe(1);
  expect(body.products[0].name).toBe('Mi USB-C Cable');
});

Testing a POST is the same shape โ€” pass a data body and assert the created resource:

test('checkout creates an order', async ({ request }) => {
  const res = await request.post('/shopkart/api/checkout', {
    data: { firstName: 'Ada', lastName: 'Lovelace', postalCode: '560001', items: ['P01'] },
  });
  expect(res.status()).toBe(201);
  const body = await res.json();
  expect(body.total).toBe(199.80);   // โ‚น185.00 + 8% tax
  expect(body.orderId).toBe('ORD-1001');
});

Data-driven status-code tests

The negative paths are where APIs earn their tests โ€” and they're a perfect fit for the data-driven pattern from Lesson 2. One test, a table of cases:

const loginCases = [
  { name: 'valid',   body: { username: 'student', password: 'crio123' }, status: 200 },
  { name: 'wrong',   body: { username: 'student', password: 'nope' },    status: 401 },
  { name: 'locked',  body: { username: 'locked_user' },                  status: 423 },
];

for (const c of loginCases) {
  test(`login (${c.name}) -> ${c.status}`, async ({ request }) => {
    const res = await request.post('/shopkart/api/login', { data: c.body });
    expect(res.status()).toBe(c.status);
  });
}

And validation โ€” a checkout missing required fields returns 400 with a list of what's wrong:

test('checkout rejects missing fields', async ({ request }) => {
  const res = await request.post('/shopkart/api/checkout', { data: { items: ['P01'] } });
  expect(res.status()).toBe(400);
  const body = await res.json();
  expect(body.errors).toContain('firstName is required');
});

Network interception: control what the UI receives

The second skill flips the direction. Instead of calling the API yourself, you intercept the requests the page makes and decide what comes back. page.route() catches a URL pattern; route.fulfill() answers with your own response.

Why this is powerful:

  • Determinism โ€” pin the data the UI renders, so your assertion never depends on live data.
  • Speed โ€” stub a slow call so the UI test doesn't wait for the real backend.
  • Reach the unreachable โ€” simulate an empty catalog, a 500 error, or a specific edge case on demand, without needing the backend in that state.

The API Lab page fetches GET /shopkart/api/products and renders the result. Here we mock that call so the page shows data that never came from the server. This passes against the live app:

test('UI renders mocked products', async ({ page }) => {
  await page.route('**/shopkart/api/products*', (route) =>
    route.fulfill({
      json: { count: 1, products: [{ id: 'X', name: 'Mocked Item', category: 'Test' }] },
    }));

  await page.goto('/shopkart/api-lab');
  await page.locator('[data-test="api-load-btn"]').click();

  await expect(page.locator('[data-test="api-product"]')).toHaveCount(1);
  await expect(page.locator('[data-test="api-product"]').first()).toContainText('Mocked Item');
});

Testing loading and error states

Real users hit slow networks and server errors. Those states have UI โ€” a spinner, an error banner โ€” and that UI needs testing. Two ways to trigger it:

1. Force a failure with interception โ€” abort or fulfill with an error status:
test('shows an error when the API fails', async ({ page }) => {
  await page.route('**/shopkart/api/products*', (route) =>
    route.fulfill({ status: 500, json: { error: 'boom' } }));

  await page.goto('/shopkart/api-lab');
  await page.locator('[data-test="api-load-btn"]').click();
  await expect(page.locator('[data-test="api-error"]')).toBeVisible();
});
2. Use the API's built-in fault injection โ€” the ShopKart API accepts ?latency=2000 and ?fail=500 on any endpoint (the API twin of the storefront's bug panel), so you can drive real slow/failed responses without mocking:
const res = await request.get('/shopkart/api/products?fail=503');
expect(res.status()).toBe(503);

Either way, the point is the same: the sad paths โ€” slow, empty, broken โ€” are requirements too, and now you can test them on demand.

When to use which

  • Test the API directly for backend contracts: status codes, validation, computed values (totals, tax), auth. Cheap and stable.
  • Intercept & mock in a UI test when you want to pin the data, speed things up, or force a state the backend won't easily produce.
  • Drive the real UI end to end (no mocks) for the handful of critical-path journeys that must prove the whole stack works together.

Don't mock everything โ€” a UI test that mocks every call proves the frontend renders your fixtures, not that the app works. Mock to control a variable, not to avoid the backend entirely.

> ๐ŸŽฏ Practice: open the [API Lab](https://resources.criodo.com/shopkart/api-lab). Test the four endpoints directly with the request fixture, then use the live widget's latency/failure controls (or page.route) to test the loading and error states. The docs panel has copy-paste snippets.

Key takeaways

  • Push checks below the UI where you can โ€” API tests are faster, stabler, and test the contract directly.
  • The request fixture tests endpoints with no browser: assert status() and the JSON body, including 4xx error paths (data-drive them).
  • page.route() + route.fulfill() intercept the page's network calls so you control the data โ€” for determinism, speed, and reaching states the backend won't easily produce.
  • Loading and error states are requirements โ€” trigger them with interception or the API's ?latency=/?fail= fault injection.
  • Mock to control a variable, not to avoid the backend; keep real end-to-end tests for the critical path.

Next lesson

You can now test the app at the UI and the network layer. But some UI widgets fight back no matter how good your locators are โ€” modals, tables, autocomplete, toasts, dynamic IDs. Next we tackle handling tricky components.

Killing Flakiness