TechByteByByte

Part 17: API Testing

Use Playwright for API requests, response validation and combined API and UI workflows.

An API is a structured conversation between software. API tests check that conversation without clicking the screen.

request → server work → response → check

Series: Playwright Zero to Expert | Demo/practice API used in this part: reqres.in (a free public REST API built for exactly this kind of practice)

Recall Part 0’s testing pyramid: API tests sit in a genuinely important middle layer — faster and more stable than UI tests, but closer to real business logic than a unit test. This part builds the HTTP knowledge you need, then shows Playwright acting as a client directly, with no browser involved at all.


API Fundamentals, Properly

Recall Part 0’s restaurant analogy: customer → waiter → kitchen. An API (Application Programming Interface) is the waiter — a defined way for one piece of software (the client) to ask another piece of software (the server) to do something or return data, without the client needing to know anything about how the server actually does it internally.

HTTP (HyperText Transfer Protocol)

is the actual language this conversation happens in — the specific rules governing how a request is formatted and how a response comes back. Every request has a method, signaling what kind of operation is being requested:

  • GET — retrieve data, without changing anything. Fetching the list of products on SauceDemo’s inventory page is conceptually a GET.
  • POST — create something new. Submitting a login form, or placing a new order, is conceptually a POST.
  • PUT — replace an existing resource entirely with new data.
  • PATCH — update part of an existing resource, without replacing the whole thing.
  • DELETE — remove a resource.

Every response carries a status code — a standardized, three-digit number communicating the outcome:

  • 2xx (like 200 OK, 201 Created) — success.
  • 3xx (like 301, 302) — redirection; the resource has moved elsewhere.
  • 4xx (like 400 Bad Request, 401 Unauthorized, 404 Not Found) — the client made a mistake — sent bad data, wasn’t authenticated, or asked for something that doesn’t exist.
  • 5xx (like 500 Internal Server Error) — the server itself failed while trying to handle an otherwise valid request.

This 4xx-vs-5xx distinction genuinely matters for QA work, not just trivia: it tells you, immediately, roughly where to start looking for the actual bug. A 404 on a request that should genuinely exist points toward a routing or URL problem, likely on the frontend or in how the request was built. A 500 points toward something breaking inside the server’s own logic — an unhandled error, a database problem — regardless of how correctly the client’s request was formed.

A request can also carry:

  • Headers — metadata about the request itself, like Content-Type: application/json (telling the server the request body is JSON) or Authorization: Bearer <token> (proving who’s making the request — a direct preview of Part 18).
  • Query parameters — extra data appended to the URL, like ?page=2 (recall Part 1.2’s URL anatomy breakdown).
  • Path parameters — part of the URL itself representing a specific resource, like the 5 in /users/5.
  • Request body — data sent along with the request, typically formatted as JSON (recall Part 2’s JSON.stringify/JSON.parse) for a POST, PUT, or PATCH.
  • Response body — the actual data sent back, also typically JSON.

Playwright’s request Context

Here’s the genuinely important architectural point for this whole part: Playwright’s API testing capability does not launch a browser at all. It makes real HTTP requests directly, the same way any HTTP client would, which is exactly why API tests run dramatically faster than UI tests — there’s no browser to launch, no page to render, no DOM to build.

Analogy: Direct Email vs. Driving to the Store

  • UI Testing (Driving to the Store): To check if a store has a shirt in stock, you get in your car, drive through traffic (launch browser), walk through the doors, navigate the aisles, search the racks, find the tag, and look at the price. This is slow and contains many failure points (heavy traffic, broken escalators).
  • API Testing (Direct Email/Call): You write a brief email directly to the store manager: “Do you have product ID 123 in stock?” The manager responds instantly: “Yes, price is $29.99.” You bypass the entire journey, parking, and building layout entirely.

📊 Visual Flowchart: UI vs. API Test Execution

Here is how Playwright bypasses the browser shell entirely when running API tests:

graph TD
    subgraph UITestPath ["UI Test Path (Heavy)"]
        UI_Start["Test: page.goto('/products')"] --> Browser["Launch Browser Window"]
        Browser --> DOM["Build DOM Tree & Render CSS Layout"]
        DOM --> Interaction["Simulate mouse click / keypress"]
        Interaction --> UI_Done["Assertion: expect().toBeVisible()"]
    end

subgraph APITestPath ["API Test Path (Light)"]
        API_Start["Test: request.get('/api/products')"] --> Send["Send HTTP Request Payload"]
        Send --> Parse["Parse JSON Response Body"]
        Parse --> API_Done["Assertion: expect().toBe(200)"]
    end
import { test, expect } from "@playwright/test";

test("GET request returns a valid user", async ({ request }) => {
  const response = await request.get("https://reqres.in/api/users/2");

  // Status code check
  expect(response.status()).toBe(200);
  expect(response.ok()).toBeTruthy(); // true for any 2xx status

  // Parse and check the actual response body
  const body = await response.json();
  console.log(body);
  /* Output (structure, actual values vary):
  {
    data: { id: 2, email: 'janet.weaver@reqres.in', first_name: 'Janet', last_name: 'Weaver' },
    support: { url: '...', text: '...' }
  }
  */

  expect(body.data.id).toBe(2);
  expect(body.data.email).toContain("@reqres.in");
});

request here is another built-in fixture, exactly the same concept as page from Part 15 — Playwright’s test runner provides it automatically, ready to use, without any manual setup.

test("POST request creates a new user", async ({ request }) => {
  const response = await request.post("https://reqres.in/api/users", {
    data: {
      name: "Amar",
      job: "QA Engineer",
    },
  });

  expect(response.status()).toBe(201); // 201 = Created

  const body = await response.json();
  expect(body.name).toBe("Amar");
  expect(body.job).toBe("QA Engineer");
  expect(body.id).toBeTruthy(); // the server should have generated a new id
});

Notice the data option in the POST request — this becomes the request body, automatically serialized to JSON. response.status() gives you the raw status code; response.ok() is a convenient shortcut that’s true for any 2xx code and false otherwise — genuinely useful when you don’t need to distinguish between 200 and 201 specifically, just “did this broadly succeed.”


Combining API and UI Testing

Here’s where the middle-layer position of API testing, from Part 0’s pyramid, becomes genuinely practical rather than just theoretical. Two powerful, common patterns:

1. Using an API call to set up state for a UI test, instead of clicking through the UI to get there.

Imagine a test that needs a user to already have three items in their cart before it can test the checkout flow. Clicking “Add to cart” three times through the UI works, but it’s slow, and it’s not actually what this particular test is meant to be verifying — it’s just tedious setup standing in the way of the real test. A test focused on checkout shouldn’t need to re-prove that “add to cart” itself works; that’s a different test’s job entirely.

test("checkout calculates total correctly with pre-existing cart items", async ({
  page,
  request,
}) => {
  // Set up cart state via API — fast, and not what this test is actually about
  await request.post("/api/cart/add", { data: { productId: 1, quantity: 3 } });

  // NOW use the UI for what this test genuinely cares about: checkout
  await page.goto("/checkout");
  await expect(page.getByText("Total: $89.97")).toBeVisible();
});

2. Verifying the UI and the underlying API data agree with each other — directly answering the exact investigation Part 0.3 posed as a hypothetical.

test("product prices shown in UI match the API response", async ({
  page,
  request,
}) => {
  const apiResponse = await request.get("/api/products");
  const apiProducts = (await apiResponse.json()).products;

  await page.goto("/inventory.html");

  for (const product of apiProducts) {
    await expect(
      page.getByText(product.name).locator("..").getByText(`$${product.price}`),
    ).toBeVisible();
  }
});

This second pattern is genuinely powerful, and worth pausing on why: a UI test alone can only tell you “the page shows 29.99."Itcannot,onitsown,tellyouwhether29.99." It cannot, on its own, tell you whether 29.99 is actually correct. Combined with an API check confirming the backend’s own source of truth also says $29.99, you’ve now verified something meaningfully stronger — not just that the frontend displays something, but that it displays the correct thing, end to end across the exact three layers Part 0.1 first introduced.


Schema Validation, Briefly

Beyond checking individual field values, sometimes you want to verify a response’s overall shape is correct — every expected field present, with the right type — without manually writing an assertion for every single property. Tools like Zod or AJV let you define an expected schema once and validate an entire response against it in one step:

import { z } from "zod";

const UserSchema = z.object({
  data: z.object({
    id: z.number(),
    email: z.string(),
    first_name: z.string(),
    last_name: z.string(),
  }),
});

test("user response matches expected schema", async ({ request }) => {
  const response = await request.get("https://reqres.in/api/users/2");
  const body = await response.json();

  expect(() => UserSchema.parse(body)).not.toThrow(); // throws if the shape doesn't match
});

This is worth knowing exists even at a beginner level, without needing to master it immediately — it becomes genuinely valuable once you’re testing APIs with larger, more complex response shapes, where manually asserting on every individual field would be tedious and easy to leave incomplete.


How It Works in a Real Test Run

APIRequestContext sends HTTP requests without driving the UI. A fast setup pattern is API creates data → UI opens that data → UI assertion verifies the user’s experience → API or fixture cleanup removes the record.

Keep layers honest: an API assertion proves the service response, while a UI assertion proves rendering and interaction. A successful API setup does not prove the browser workflow works.

Interview Questions

Q: What is the fundamental architectural difference between a UI test and an API test in Playwright?

Ans: A UI test launches and drives a real browser, rendering an actual page and interacting with its DOM. An API test using Playwright’s request context makes real HTTP requests directly, without launching a browser at all — no page rendering, no DOM. This is exactly why API tests run significantly faster than UI tests for equivalent coverage of backend behavior.

Q: What’s the practical difference between a 4xx and a 5xx status code, and why does that distinction matter when investigating a bug?

Ans: A 4xx status code indicates the client made a mistake in its request — bad data, missing authentication, requesting something that doesn’t exist. A 5xx status code indicates the server itself failed while handling an otherwise valid request. This distinction matters because it points toward where to start investigating — a 4xx suggests looking at how the request was built or authenticated, while a 5xx suggests looking at the server’s own internal logic, regardless of how correctly the request was formed.

Q: Why might a test use an API call to set up cart state, rather than clicking “Add to cart” through the UI, before testing checkout?

Ans: Clicking through the UI to establish that setup state is slower and isn’t actually what a checkout-focused test is meant to verify — it’s incidental setup, not the test’s real purpose, and a separate test should already be responsible for confirming “add to cart” works correctly on its own. Using an API call to establish the needed state directly is faster and keeps the checkout test focused specifically on checkout behavior, rather than re-proving unrelated functionality every time.

Q: Why is combining a UI assertion with a corresponding API assertion meaningfully stronger than a UI assertion alone, when verifying something like a displayed price?

Ans: A UI-only assertion can only confirm that the page displays some value — it can’t, on its own, confirm that value is actually correct. Checking the API’s response for the same data provides an independent source of truth to compare against, so the combined check verifies not just that something is displayed, but that what’s displayed genuinely matches the backend’s actual data — catching bugs where the frontend might display stale, cached, or incorrectly formatted data even though the backend itself is correct.

Q: What does the request fixture in Playwright let you do, and how is it similar to the page fixture conceptually?

Ans: The request fixture provides a way to make direct HTTP requests — GET, POST, PUT, PATCH, DELETE — without a browser. Conceptually, it’s provided the exact same way page is: a built-in fixture Playwright’s test runner automatically creates and hands to any test that declares it as a parameter, ready to use without manual setup.

Q: A test asserts expect(response.status()).toBe(200) but doesn’t check the response body at all. What real risk does this leave uncovered?

Ans: A 200 status only confirms the request succeeded broadly — it says nothing about whether the actual data returned is correct. A response could return status 200 while containing missing fields, wrong values, or an unexpected shape entirely, and a status-only assertion would never catch any of that. Checking the response body’s actual content, or validating it against an expected schema, is necessary to verify the data itself is genuinely correct, not just that the request didn’t outright fail.


Exercises — Part 17

Understand: Without looking back, explain in your own words why an API test runs faster than an equivalent UI test, tying your answer to what actually has to happen for each.

Simple Practice: Using Playwright’s request context, write a test that sends a GET request to https://reqres.in/api/users?page=2, asserts the status code is 200, and asserts that the response body contains a data array with more than zero users in it.

Real-World Scenario: Write a test that uses an API call to a public test API of your choosing to create a resource (a POST request), then asserts the response status is 201 and that the returned data reflects what you sent. Explain, in a sentence, why checking the returned data (not just the status code) matters here.

Challenge: Pick any public site with both a browser-visible list of items (products, articles, users) and a discoverable underlying API (inspect the Network tab from Part 1 to find it). Write a combined test that fetches the same data via the API and via the UI, and asserts that at least one specific piece of information (a name, a price, a count) matches between the two.


Next: Part 18 — Authentication

— sessions, cookies, tokens, storage state, and reusing a logged-in session across an entire test suite without logging in from scratch every single time.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed