TestForge
Log in
Low difficulty~65 min read

Playwright (API)

Use Playwright’s request context to test REST APIs — no browser required.

API testing with Playwright

Playwright is not just for browsers. Its APIRequestContext (the request fixture) sends real HTTP calls with cookie handling, making it ideal for testing REST endpoints directly — faster and more stable than going through the UI. You can also mix API calls into UI tests to set up state quickly.

  • Same test runner, reporter, and CI you already use for UI tests.
  • Great for testing endpoints, seeding data, and asserting side effects the UI hides.
  • Built-in retry via expect(), and full request/response access (status, headers, body).

Target the TestForge API gateway

All routes live under http://localhost:3000/api — e.g. POST /api/auth/login, GET /api/catalog/products, POST /api/cart/items.

Setup

If you already did the Playwright UI setup you are ready. Otherwise scaffold a project, then set an API baseURL.

bash
npm init playwright@latest
playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://localhost:3000/api',
    extraHTTPHeaders: { 'Content-Type': 'application/json' },
  },
});

Your first API test

The request fixture is injected into each test. Assert on status and body.

tests/catalog.api.spec.ts
import { test, expect } from '@playwright/test';

test('GET /catalog/products returns a page of products', async ({ request }) => {
  const res = await request.get('/catalog/products?limit=5');
  expect(res.status()).toBe(200);

  const body = await res.json();
  expect(Array.isArray(body.data)).toBe(true);
  expect(body.data.length).toBeLessThanOrEqual(5);
  expect(body.meta).toHaveProperty('totalPages');
});

Authentication: logging in and sending the token

TestForge issues a JWT access token from POST /auth/login. Send it as a Bearer header on protected routes like /cart and /orders.

tests/cart.api.spec.ts
import { test, expect } from '@playwright/test';

test('a logged-in user can add an item to the cart', async ({ request }) => {
  // 1) Authenticate
  const login = await request.post('/auth/login', {
    data: { email: 'customer@testforge.dev', password: 'Password123!' },
  });
  expect(login.ok()).toBeTruthy();
  const { accessToken } = await login.json();

  // 2) Find a product
  const products = await (await request.get('/catalog/products?limit=1')).json();
  const productId = products.data[0].id;

  // 3) Add to cart with the Bearer token
  const add = await request.post('/cart/items', {
    headers: { Authorization: 'Bearer ' + accessToken },
    data: { productId, quantity: 1 },
  });
  expect(add.status()).toBe(201);

  const cart = await add.json();
  expect(cart.items.length).toBeGreaterThan(0);
});

Reuse auth across tests

Create an authenticated request context once with request.newContext({ extraHTTPHeaders: { Authorization: "Bearer " + token } }) in a fixture, so every test starts authorized.

A custom authenticated fixture

tests/fixtures.ts
import { test as base, request, APIRequestContext, expect } from '@playwright/test';

type Fixtures = { api: APIRequestContext };

export const test = base.extend<Fixtures>({
  api: async ({}, use) => {
    const anon = await request.newContext({ baseURL: 'http://localhost:3000/api' });
    const res = await anon.post('/auth/login', {
      data: { email: 'customer@testforge.dev', password: 'Password123!' },
    });
    const { accessToken } = await res.json();
    const authed = await request.newContext({
      baseURL: 'http://localhost:3000/api',
      extraHTTPHeaders: { Authorization: 'Bearer ' + accessToken },
    });
    await use(authed);
    await authed.dispose();
    await anon.dispose();
  },
});

export { expect };

Asserting responses thoroughly

  • Status code: expect(res.status()).toBe(201) — assert the exact code, not just ok().
  • Body shape: check required fields and types; use expect(body).toMatchObject({ ... }) for partial matches.
  • Headers: expect(res.headers()["content-type"]).toContain("application/json").
  • Error envelope: TestForge returns { error: { code, message } } — assert code on negative tests.
typescript
test('rejects an invalid login', async ({ request }) => {
  const res = await request.post('/auth/login', {
    data: { email: 'nobody@testforge.dev', password: 'wrong' },
  });
  expect(res.status()).toBe(401);
  const body = await res.json();
  expect(body.error.code).toBeTruthy();
});

Schema validation

For contract confidence, validate response bodies against a schema. Zod (TypeScript-native) is a lightweight option.

typescript
import { z } from 'zod';

const Product = z.object({
  id: z.string(),
  name: z.string(),
  priceCents: z.number().int(),
  currency: z.string(),
  stockQty: z.number().int(),
});

test('product matches schema', async ({ request }) => {
  const body = await (await request.get('/catalog/products?limit=1')).json();
  Product.parse(body.data[0]); // throws if the shape drifts
});

Running in CI

API tests need no browsers, so CI is even simpler — you can skip npx playwright install if you run only API specs.

bash
npx playwright test tests/*.api.spec.ts --reporter=list

Best practices

  • Assert exact status codes and the error code on negative paths, not just happy paths.
  • Isolate tests: create the data you need, and clean up (or use unique data) so runs are repeatable.
  • Prefer API setup for UI tests — it is faster and less flaky than clicking through forms.
  • Validate response schemas to catch contract drift early.
  • Keep secrets/tokens out of source; read credentials from environment variables.

The request context and authentication

Every API test receives a request fixture: an APIRequestContext bound to your config baseURL. It is a lightweight HTTP client with cookie handling built in — no browser, no page. When you need a stateful, pre-authenticated client that outlives a single test, build your own context with request.newContext() and bake the credentials into extraHTTPHeaders.

The TestForge flow is always the same: POST /auth/login, pull accessToken out of the JSON, then send it as an Authorization: Bearer header on every protected route. Setting it once at context creation time beats repeating the header on each call.

tests/authed-context.api.spec.ts
import { test, expect, request, APIRequestContext } from '@playwright/test';

async function authedContext(): Promise<APIRequestContext> {
  // A throwaway anonymous context just to obtain a token
  const anon = await request.newContext({ baseURL: 'http://localhost:3000/api' });
  const login = await anon.post('/auth/login', {
    data: { email: 'customer@testforge.dev', password: 'Password123!' },
  });
  expect(login.ok()).toBeTruthy();
  const { accessToken } = await login.json();
  await anon.dispose();

  // A long-lived context that carries the Bearer token on every request
  return request.newContext({
    baseURL: 'http://localhost:3000/api',
    extraHTTPHeaders: { Authorization: 'Bearer ' + accessToken },
  });
}

test('the stored token authorizes a protected route', async () => {
  const api = await authedContext();
  const res = await api.get('/orders'); // no per-call headers needed
  expect(res.status()).toBe(200);
  await api.dispose();
});

request vs request.newContext()

The request fixture is stateless and reset between tests — perfect for one-off calls. request.newContext() gives you a context you control (own headers, own cookie jar); create it in a fixture or beforeAll and dispose() it when done.

Seeding state via API for UI tests

The slowest, flakiest part of a UI test is usually the setup: register, log in, browse, add to cart — all by clicking. Every one of those clicks is a chance to fail on a slow render or a moved selector. Do the setup over the API instead, where a whole precondition is a couple of HTTP calls, then hand the browser a session that is already in the right state.

  • Faster: a POST is milliseconds; driving the same flow through forms is seconds.
  • More stable: no selectors, no waiting for animations, no re-login churn.
  • Focused: the UI test asserts the one thing it is actually about, not the plumbing to get there.
tests/cart-badge.spec.ts
import { test, expect, request } from '@playwright/test';

test('the cart badge reflects an item added via API', async ({ page }) => {
  const api = await request.newContext({ baseURL: 'http://localhost:3000/api' });

  // 1) Register a fresh, isolated user over the API
  const email = 'seed+' + Date.now() + '@testforge.dev';
  await api.post('/auth/register', {
    data: { email, password: 'Password123!', name: 'Seed User' },
  });
  const login = await api.post('/auth/login', {
    data: { email, password: 'Password123!' },
  });
  const { accessToken } = await login.json();

  // 2) Seed the cart over the API — no clicking
  const products = await (await api.get('/catalog/products?limit=1')).json();
  await api.post('/cart/items', {
    headers: { Authorization: 'Bearer ' + accessToken },
    data: { productId: products.data[0].id, quantity: 2 },
  });

  // 3) Hand the token to the browser, then assert only the UI concern
  await page.addInitScript((token) => {
    window.localStorage.setItem('accessToken', token);
  }, accessToken);
  await page.goto('/');
  await expect(page.getByTestId('cart-badge')).toHaveText('2');

  await api.dispose();
});

Rule of thumb

Set up through the fastest layer, assert through the layer under test. If the test is about the cart badge, seed the cart via API and only click for the badge.

Response and schema validation

A green status code is not a passing test. Validate the full response: the status, the headers that callers depend on, and the body shape. Splitting these apart tells you exactly what drifted when something breaks — a 500 is a very different failure from a 200 with a renamed field.

  • Status: expect(res.status()).toBe(201) — the exact code, not just ok().
  • Headers: expect(res.headers()["content-type"]).toContain("application/json").
  • Body values: expect(body).toMatchObject({ ... }) for the fields you care about.
  • Body shape: parse against a schema so a renamed or missing field fails loudly.
tests/checkout-contract.api.spec.ts
import { test, expect } from '@playwright/test';
import { z } from 'zod';

// A lightweight contract for the checkout response
const Order = z.object({
  id: z.string().uuid(),
  status: z.enum(['pending', 'paid', 'failed']),
  totalCents: z.number().int().nonnegative(),
  currency: z.string().length(3),
  items: z.array(z.object({ productId: z.string(), quantity: z.number().int() })).min(1),
});

test('POST /orders/checkout satisfies the order contract', async ({ request }) => {
  const login = await request.post('/auth/login', {
    data: { email: 'customer@testforge.dev', password: 'Password123!' },
  });
  const { accessToken } = await login.json();
  const auth = { Authorization: 'Bearer ' + accessToken };

  const res = await request.post('/orders/checkout', { headers: auth });

  // Status
  expect(res.status()).toBe(201);
  // Headers
  expect(res.headers()['content-type']).toContain('application/json');
  // Body values + shape
  const body = await res.json();
  expect(body).toMatchObject({ status: 'pending' });
  Order.parse(body); // throws on any contract drift
});

Assert the negative contract too

Error responses have a contract as well. TestForge returns { error: { code, message } } — assert the exact status and error.code on failure paths, or a broken error envelope will slip through unnoticed.

Interview questions and answers

What is the request fixture, and how does it relate to APIRequestContext?

The request fixture is an instance of APIRequestContext that Playwright injects into each test. It is a browserless HTTP client — it sends real requests, follows redirects, and keeps a cookie jar, all bound to the baseURL from your config. When you need a stateful client that outlives one test or carries fixed auth headers, you build your own APIRequestContext with request.newContext() instead of using the per-test fixture.

Why do API testing at all when you already have UI tests?

API tests sit lower on the test pyramid: they are faster, more stable, and pinpoint failures better because they skip rendering, selectors, and timing. They can also assert things the UI hides — side effects, error envelopes, and edge cases that are hard to trigger through a form. You want many cheap API tests covering business logic and contracts, and a small number of UI tests for the things only a real browser can verify.

Walk me through the difference between asserting status, body, and schema.

Status confirms the endpoint took the right action — a 201 on create, a 401 on bad auth — and you assert the exact code, not just ok(). Body assertions check specific values you care about, like status: "pending" or a computed total. Schema validation checks the overall shape — every expected field is present with the right type — so a renamed or dropped field fails even when the values you happened to assert still look fine. Together they distinguish a wrong action from a wrong value from a drifted contract.

How do you carry an auth token between requests in Playwright?

You log in once via POST /auth/login, read the accessToken out of the JSON, and send it as an Authorization: Bearer header. For a single call you pass headers per request; for many calls you create an APIRequestContext with request.newContext({ extraHTTPHeaders: { Authorization: "Bearer " + token } }) so every request from that context is authorized automatically. Wrapping this in a fixture means each test starts already authenticated.

Explain the "set up state via API" pattern and why it speeds up UI tests.

Instead of clicking through registration, login, and add-to-cart to reach a precondition, you make those calls over the API and then hand the browser a session that is already in the desired state. It is dramatically faster because a POST is milliseconds versus seconds of form-driving, and far less flaky because there are no selectors or animations to wait on. The UI test is then free to assert only the behavior it actually cares about.

Can you combine API and UI work in a single test? How?

Yes — request and page fixtures coexist in the same test. A common shape is: use the request context to create data and obtain a token, inject that token into the browser (via addInitScript or storageState), navigate, and assert in the UI. You can also flip it: perform an action in the UI, then hit the API to verify the side effect landed. The guideline is to set up through the fastest layer and assert through the layer under test.

When should you mock an API versus hit the real one?

Hit the real API when the point of the test is the integration — the actual contract, auth, and persistence — which is exactly what Playwright API tests are for. Mock (via page.route or a stub server) when you are testing the frontend in isolation and want to drive rare responses like a 500 or a slow network deterministically without a live backend. Mocking trades real-integration confidence for speed and control, so use it for edge-case UI behavior, not to replace genuine end-to-end coverage.

What is contract testing, at a basic level?

Contract testing verifies that the shape of the data exchanged between a consumer and a provider matches an agreed contract — field names, types, and required-ness — rather than just the specific values. In practice here that means parsing a response against a schema (for example with Zod) so any renamed, removed, or retyped field fails the test immediately. It catches breaking API changes early, before they surface as confusing failures in downstream consumers.

What parallel or serial concerns come up in API testing?

Playwright runs tests in parallel by default, so tests that share mutable server state can interfere — one test emptying a cart while another asserts on it. The fixes are to isolate data (create a unique user or unique records per test, as with an email keyed on Date.now()) or, when tests genuinely must run in order, to mark them test.describe.serial. Prefer isolation; reach for serial only when a real ordering dependency exists, since it costs you concurrency.

How is API testing different from UI testing in what it can and cannot verify?

API testing verifies server behavior directly — status codes, response contracts, business rules, and side effects — quickly and without a browser, but it cannot tell you whether anything renders correctly, whether a button is reachable, or whether the client wires the response into the view. UI testing verifies the rendered experience and real user flows but is slower and more brittle. They are complementary: API tests give you broad, cheap coverage of logic and contracts, and UI tests confirm the handful of things that only exist in the browser.

Where to go next

  • Docs: playwright.dev/docs/api-testing
  • Combine API + UI: seed a cart via API, then assert the badge in the UI.
  • Practice: cover the full order lifecycle — checkout, simulate a payment failure, then POST /orders/:id/pay to retry.