Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 017 / 100

API testing with Playwright: what developers should know

Test your backend with the same tool.

Expona Admin7 min read

API testing with Playwright: what developers should know

Most teams treat API testing and UI testing as separate disciplines, forcing engineers to maintain different test suites with different frameworks. We've found that API testing with Playwright eliminates that friction—you can validate your backend contracts and seed data in the same tool you use for browser automation. Whether you're testing a REST API, GraphQL endpoint, or preparing your backend state before a critical user flow, Playwright's request fixture gives you the speed and simplicity to do it all.

The request fixture for HTTP calls

Playwright exposes a request context fixture that lets you make raw HTTP calls without spinning up a browser. This is powerful: you get all of Playwright's test infrastructure—retries, timeouts, reporting—but you're talking directly to your API.

Here's how you'd make a simple POST request:

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

test('create user via API', async ({ request }) => {
  const response = await request.post('https://api.example.com/users', {
    data: {
      name: 'Alice Chen',
      email: 'alice@example.com',
      role: 'engineer',
    },
  });
  
  expect(response.status()).toBe(201);
});

The request fixture is a built-in Playwright object. It handles cookies, headers, and authentication context automatically if you've configured them at the test project level. You're not writing HTTP client boilerplate—Playwright handles the plumbing.

For GET requests, it's equally straightforward:

typescript
test('fetch user details', async ({ request }) => {
  const response = await request.get('https://api.example.com/users/42');
  
  expect(response.status()).toBe(200);
  const user = await response.json();
  expect(user.id).toBe(42);
  expect(user.name).toBe('Alice Chen');
});

You can also attach custom headers, query parameters, or auth tokens if you need test-specific overrides. Most teams, though, configure these once in the Playwright config and reuse them across all tests.

Assert status and JSON

API responses have two surfaces you need to verify: the HTTP status code and the response body. Playwright makes both assertions straightforward.

For status codes, use response.status(). It's a simple integer:

typescript
const response = await request.post('/api/orders', {
  data: { items: [1, 2, 3], total: 99.99 },
});

expect(response.status()).toBe(201);

For JSON body assertions, call response.json() and treat the result like any JavaScript object. This is where most of the test logic lives:

typescript
test('order response includes tracking', async ({ request }) => {
  const response = await request.post('/api/orders', {
    data: {
      customerId: 'cust-456',
      items: [{ sku: 'WIDGET-01', qty: 2 }],
    },
  });
  
  expect(response.status()).toBe(201);
  
  const order = await response.json();
  expect(order).toHaveProperty('id');
  expect(order).toHaveProperty('trackingUrl');
  expect(order.status).toBe('pending');
  expect(order.total).toBeGreaterThan(0);
});

You can also chain assertions using Playwright's built-in matchers or standard Jest matchers if you prefer. The key is that response.json() blocks until the full response body is buffered, so you never get partial or streaming data in the test.

For APIs that return error responses with details, you can assert on the error structure too:

typescript
test('validation error returns field details', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { email: 'invalid-email' },
  });
  
  expect(response.status()).toBe(400);
  
  const error = await response.json();
  expect(error.errors).toEqual(
    expect.arrayContaining([
      expect.objectContaining({
        field: 'email',
        message: expect.stringContaining('valid email'),
      }),
    ])
  );
});

Seed data before UI tests

One of the best uses for API testing in Playwright is seeding your test database before running browser tests. Instead of clicking through a UI to create 10 products, 5 orders, and 3 users, you make three API calls—and your UI tests start with a known, consistent state.

You typically do this in a test setup or using Playwright's beforeEach hook:

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

test.describe('Shopping cart', () => {
  let productId: string;
  let customerId: string;

  test.beforeEach(async ({ request, page }) => {
    // Seed a product
    const productRes = await request.post('/api/products', {
      data: {
        name: 'Premium Headphones',
        price: 199.99,
        stock: 50,
      },
    });
    productId = (await productRes.json()).id;

    // Seed a customer
    const customerRes = await request.post('/api/customers', {
      data: {
        email: 'test-' + Date.now() + '@example.com',
        name: 'Test Customer',
      },
    });
    customerId = (await customerRes.json()).id;

    // Navigate to the shop
    await page.goto('/shop');
  });

  test('add product to cart', async ({ page }) => {
    // The product and customer already exist via API
    // Now test the UI behavior
    await page.click(`[data-product-id="${productId}"]`);
    await page.click('button:has-text("Add to Cart")');
    
    await expect(page.locator('[data-cart-count]')).toContainText('1');
  });
});

This pattern scales well. If your test needs 50 items in inventory, don't add them one by one in the UI—bulk-insert them via the API. Your tests will run faster and be less flaky because you're not clicking through a form 50 times.

A practical tip: generate unique identifiers (like email addresses or SKUs) using timestamps or UUIDs so you don't get collisions if tests run in parallel.

Share auth with storage state

When your API and UI tests both need authentication, Playwright's storage state feature lets you authenticate once and reuse the session across both API calls and browser navigation.

First, define an authenticated request context at the project level in playwright.config.ts:

typescript
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: 'https://api.example.com',
    storageState: 'auth.json',
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Then create a setup project that logs in once and saves the auth state to a file:

typescript
import { test as setup } from '@playwright/test';

setup('authenticate', async ({ request }) => {
  const response = await request.post('/auth/login', {
    data: {
      email: 'test@example.com',
      password: 'secure-test-password',
    },
  });

  expect(response.ok()).toBeTruthy();
  
  // Playwright automatically saves cookies to auth.json
  // because we configured storageState in the config
});

Now, every test—whether it uses the request fixture or the page (browser) fixture—will include the saved auth cookies:

typescript
test('API call respects auth state', async ({ request }) => {
  // This request automatically includes the cookie from auth.json
  const response = await request.get('/api/profile');
  expect(response.status()).toBe(200);
});

test('browser nav respects auth state', async ({ page }) => {
  // This page load automatically includes the cookie from auth.json
  await page.goto('/dashboard');
  await expect(page.locator('h1')).toContainText('Welcome');
});

This approach is more reliable than logging in via UI every test. You log in once, verify the token works, and then every subsequent test uses that same session. If your API uses JWT or session cookies, Playwright handles the mechanics—your tests just work.

Common mistakes

  • Forgetting to await response.json(). If you don't await the JSON parsing, you'll get a promise instead of the actual data. Always await when you need the body.
  • Not handling network timeouts. Playwright has default timeouts, but API calls can hang if your backend is slow. Set reasonable timeouts at the test or config level and handle flakes gracefully.
  • Mixing test data across test runs. If you create a user via API but don't clean it up, the next test run might fail because the email already exists. Use unique identifiers or a teardown step to clean up after each test.
  • Ignoring response headers. Some APIs return important metadata in headers (rate limits, cache control, location redirects). Use response.headers() if you need to assert on them.
  • Testing at the wrong layer. API tests in Playwright are great for integration and end-to-end scenarios, but unit tests on your backend code should still live in your backend test suite. Don't try to replace backend unit tests with Playwright.

Key takeaways

  • Use the request fixture to make HTTP calls alongside your browser tests—same tool, no context switching.
  • Assert both HTTP status codes and JSON response bodies to validate your API contract.
  • Seed backend state via API before UI tests to avoid slow, flaky UI setup steps.
  • Share authentication across API and browser tests using Playwright's storage state feature.
  • Keep API tests focused on integration scenarios; let your backend test suite handle unit and business logic validation.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles