This recipe is the smallest end-to-end Playwright test that exercises the QA Shop public REST API end to end.
It uses Playwright's request fixture (no page) to list products, fetch one by slug, and assert that the response envelope and the list-vs-detail contract both hold. Run it on your laptop in about seven seconds.
It is the canonical first test to write against any HTTP surface in a Playwright suite, and it is the foundation that hybrid API-then-browser specs (set up state over HTTP, then drive the UI) build on top of.
§ 01 · PREREQUISITES
Prerequisites
You need a project with @playwright/test installed, a Playwright config that points at QA Shop, and an API key minted from your account.
Bootstrap with npm init playwright@latest if you don't already have a project — it scaffolds the runner, the config, and an example spec.
Set to http://localhost:3002 for a local checkout, or https://automationtestingplatform.com for the hosted demo.
Mint at /account/api-keys, copy the secret once (it is shown a single time), and store it in .env.local. In CI the same value lives in a GitHub repo secret. The seeded dev value is qashop_sk_recipe00_dev_seed_change_in_ci and ships with supabase/seed.sql.
The recipe assumes the server is already up. The test does not start it for you.
§ 02 · THE SPEC
The full spec
Save this file as tests/recipes/playwright/api-testing.spec.ts and run it with npx playwright test --grep api-testing.
// tests/recipes/playwright/api-testing.spec.ts
import { test, expect, request as pwRequest } from "@playwright/test";
const PRODUCT_SLUG = "wooden-train-set-100-piece";
test("public REST API contract holds for products", async () => {
const apiKey = process.env.RECIPE_API_KEY;
if (!apiKey) {
throw new Error(
"RECIPE_API_KEY must be set. " +
"See .env.example. In CI it comes from a GitHub repo secret; " +
"locally it comes from your .env.local.",
);
}
// A request-only context inherits baseURL from playwright.config.ts and
// attaches the bearer header to every call below.
const ctx = await pwRequest.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${apiKey}` },
});
// 1. List endpoint — envelope is { success, data: { products, ... }, error }.
const listRes = await ctx.get("/api/v1/products");
expect(listRes.status()).toBe(200);
const listBody = await listRes.json();
expect(listBody.success).toBe(true);
expect(listBody.error).toBeNull();
expect(Array.isArray(listBody.data.products)).toBe(true);
expect(listBody.data.products.length).toBeGreaterThan(0);
for (const p of listBody.data.products) {
expect(typeof p.public_id).toBe("string");
expect(typeof p.slug).toBe("string");
expect(typeof p.name).toBe("string");
expect(typeof p.price).toBe("number");
}
// 2. Detail endpoint by slug — `ref` accepts slug OR public_id.
const detailRes = await ctx.get(`/api/v1/products/${PRODUCT_SLUG}`);
expect(detailRes.status()).toBe(200);
const detailBody = await detailRes.json();
expect(detailBody.success).toBe(true);
expect(detailBody.error).toBeNull();
expect(detailBody.data.product.slug).toBe(PRODUCT_SLUG);
// 3. Cross-check: the detail slug appears in the list response.
const slugs = listBody.data.products.map((p: { slug: string }) => p.slug);
expect(slugs).toContain(PRODUCT_SLUG);
await ctx.dispose();
});§ 03 · WALKTHROUGH
Walkthrough
The first thing the test does is read process.env.RECIPE_API_KEY and throw a descriptive error if it is missing. This is the same fail-fast pattern as tests/recipes/playwright/setup/auth.setup.ts, and the reason matters: a silent fallback to an empty string would still hit the API, get back a 401, and produce a confusing "expected 200, received 401" assertion failure several lines later. Throwing at the top points the reader at the env-var contract directly1.
request.newContext({ extraHTTPHeaders }) is the load-bearing call. It returns an APIRequestContext that owns its own cookie jar, inherits baseURL from playwright.config.ts, and attaches the bearer header to every subsequent ctx.get, ctx.post, and so on. Reusing one context across the three calls (vs. passing the header three times) keeps the spec readable and matches the pattern Playwright recommends for hybrid suites that later log in once and share storageState with a browser test.
The first assertion block walks the full envelope: success === true, error === null, then data.products is an array, then each product has public_id, slug, name, and price of the right shape. The envelope check is the part most beginners skip and the part regression tests catch the most. A handler that accidentally returns a bare […] instead of the wrapper would silently break every consumer; pinning success and error in every test stops that drift at PR time.
The detail call uses the slug as the ref path parameter — the route accepts either slug or public_id, and a slug is the human-readable choice in a recipe. Note the response shape difference: the list returns data.products (plural), the detail returns data.product (singular). Mixing them up is the single most common API-test bug in the repo's history; the cross-check below catches it.
The final cross-check maps data.products to a slug array and asserts the detail slug appears in it. This is what turns three isolated assertions into a contract test: if the list and the detail ever drift to different sources of truth (cache stale, a copy-on-read bug, a missed migration), the cross-check fails before either single-endpoint assertion would.
await ctx.dispose() at the end is small but matters in a long suite — every undisposed APIRequestContext keeps its keep-alive sockets open until the worker exits, and Playwright will warn about it on --workers > 1.
§ 04 · EXTEND IT
Things to extend
Three obvious variants build on this skeleton.
The list endpoint honors ?search=, ?category=, ?minPrice=, ?maxPrice=, ?inStock=, ?featured=, ?sort=, ?page=, ?perPage=. Add a second test that fetches /api/v1/products?inStock=true&perPage=5, asserts data.perPage === 5, and walks the products to confirm stock > 0 on each — a small spec, big contract surface.
Request /api/v1/products/this-slug-does-not-exist. Assert the status is 404, body.success === false, and body.error.code === "PRODUCT_NOT_FOUND". Negative-path tests like this catch a class of bug where a handler returns 200 with data: null instead of the documented error envelope.
The reusable shape is: open one APIRequestContext, do the data setup over HTTP (mint a cart, place an order, etc.), then hand the resulting cookies or order_number to a browser test that drives the UI from a known state. This pattern is several times faster than UI-only setup and is the headline reason a Playwright suite picks the request fixture in the first place.
A fourth variant worth scripting eventually is the rate-limit assertion — fire 31 requests in a tight loop, catch the 429 on the 31st, and assert the Retry-After, X-RateLimit-Remaining: 0, and X-RateLimit-Reset headers all surface. That spec doubles as a regression net for the abuse-protection layer, but mind the budget — running it costs your hourly quota for that key.
Related
For the broader API contract and the public-IDs architecture, see the QA Shop testing guide. For the browser-driven counterpart this recipe complements, see Add to cart and reach checkout and Log in to QA Shop.
The Selenium, Cypress, and WebdriverIO API-testing counterparts are coming soon — placeholder slugs are /recipes/selenium/api-testing, /recipes/cypress/api-testing, and /recipes/webdriverio/api-testing. The /recipes index lists them as they ship.
Footnotes
-
Playwright API testing — https://playwright.dev/docs/api-testing (verified 2026-05-03, Playwright 1.58) ↩
Frequently asked questions
Last verified: