Back to Blog
When Your E2E Tests Need to Be More Than Just Clicking Buttons: Diving into Playwright's API Capabilities
8 min readSep 16, 20260 views

When Your E2E Tests Need to Be More Than Just Clicking Buttons: Diving into Playwright's API Capabilities

Most developers use Playwright for UI automation, clicking through pages and asserting on elements. But its underlying API client is a hidden gem, allowing you to combine UI interactions with direct API calls for faster, more robust end-to-end tests.

TestingWeb DevelopmentToolingFull-Stack
Share

by Sunil Band

Beyond the Browser: Playwright's API Context

We've all been there: a test suite that's slow, flaky, and a pain to maintain. Often, the culprit is over-reliance on UI interactions. Clicking through five pages just to set up test data or verify a backend state is a massive waste of time and introduces unnecessary brittleness. Every visual change, every slight refactor of a component's rendering logic, can break these tests.

Playwright shines as a browser automation tool, and that's usually what people focus on. But its request context, often overlooked, is a powerful feature that lets you make direct HTTP requests within your tests. This means you can interact with your application's API, external services, or even mock servers, all from within your Playwright test suite. It's not just for setup; it's a first-class citizen for asserting on API responses directly, bypassing the UI entirely when appropriate.

Why Mix UI and API Calls in E2E Tests?

Think about a common scenario: you're testing an e-commerce checkout flow. To test placing an order, you first need a user logged in, items in a cart, and perhaps a specific shipping address. Doing all of this through the UI is possible, but incredibly slow. What if you could log in via an API call, add items to the cart via another API call, and then only use the UI for the final steps, like confirming the order details and clicking 'Place Order'?

This hybrid approach offers significant benefits. Your tests become faster because you're skipping redundant UI interactions. They become more stable because you're less susceptible to minor UI changes. And they become more focused, allowing you to isolate the specific UI interactions you need to test, while leveraging the API for everything else. It's about testing the right layer at the right time.

Setting Up Your request Context

The request context in Playwright is designed to mimic a browser's network requests, including handling cookies and authentication automatically if initiated within a page context or configured globally. This is crucial because it means your API calls will respect the same session state as your browser interactions.

Let's look at a simple example. Imagine we have a user management API, and we want to ensure a user can log in and then fetch their profile. We'll start by defining a request fixture, which is good practice for reusability across tests.

typescript
// playwright.config.ts
import { defineConfig, devices, request } from '@playwright/test';

export default defineConfig({
  // ... other config options
  use: {
    // Base URL for API requests. Playwright will prefix this to all requests
    // made with `request.get()`, `request.post()`, etc.
    baseURL: 'http://localhost:3000/api', // Your API's base URL

    // Configure the request context to be available globally
    // This ensures cookies/auth state are shared between browser and API contexts.
    extraHTTPHeaders: {
      'Accept': 'application/json',
      // 'Authorization': `Bearer ${process.env.API_TOKEN}` // Example for token auth
    }
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Now, with this configuration, any request context within your tests will automatically use http://localhost:3000/api as its base URL and send the Accept header. This is a subtle but powerful feature: it makes your API calls feel native to the test environment.

A Hybrid Test Example: Login and Profile Fetch

Consider a scenario where you want to test a user's ability to log in through the UI and then immediately verify their profile data via an API call, without navigating to a profile page. This is where the hybrid approach shines.

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

test('should allow a user to log in via UI and fetch profile via API', async ({ page, request }) => {
  // 1. Navigate to the login page via the UI
  await page.goto('http://localhost:3000/login');
  expect(await page.title()).toBe('Login - My App');

  // 2. Perform login actions via the UI
  await page.fill('input[name="email"]', 'test@example.com');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  // Wait for navigation or a success indicator. This is crucial for session cookies to be set.
  await page.waitForURL('http://localhost:3000/dashboard');
  expect(await page.locator('h1').textContent()).toContain('Welcome');

  // At this point, the browser session (and thus the `request` context) should have auth cookies.

  // 3. Now, fetch the user profile directly via API using the same session
  const profileResponse = await request.get('/profile'); // No need for full URL due to baseURL config
  expect(profileResponse.ok()).toBeTruthy();

  const profileData = await profileResponse.json();
  expect(profileData).toEqual({
    id: expect.any(String),
    email: 'test@example.com',
    firstName: 'Test',
    lastName: 'User',
    // ... more profile data
  });

  // You could then continue with UI interactions if needed,
  // e.g., navigate to a different page and check if the profile data is displayed correctly.
  // await page.goto('http://localhost:3000/settings');
  // expect(await page.locator('#email-display').textContent()).toBe('test@example.com');
});

In this test, the login happens visually, which is important for testing the UI component itself. But the profile verification bypasses the UI, directly querying the API. This is faster and less prone to breaking if, for instance, the profile page's layout changes but the underlying API remains the same. The request object automatically shares the session and cookies established by the page object, making this seamless.

Bypassing UI for Test Setup

Another powerful use case is test data setup. Instead of painstakingly filling out forms in the browser to create a new user or a new product, you can do it with a single API call.

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

test.describe('Product Management', () => {
  // Setup hook to create a product via API before tests run
  test.beforeEach(async ({ request }) => {
    const createProductResponse = await request.post('/products', {
      data: {
        name: 'My Test Product',
        price: 99.99,
        description: 'A product for testing purposes',
        stock: 10
      }
    });
    expect(createProductResponse.ok()).toBeTruthy();
    const product = await createProductResponse.json();
    // Store product ID in context for later tests if needed
    // test.info().annotations.push({ type: 'product-id', description: product.id });
  });

  test('should display created product on the product listing page', async ({ page }) => {
    await page.goto('http://localhost:3000/products');
    await expect(page.locator('text=My Test Product')).toBeVisible();
    await expect(page.locator('text=$99.99')).toBeVisible();
  });

  // ... other tests that might modify or delete this product
});

Here, test.beforeEach uses the request context to create test data. This is significantly faster and more reliable than driving the UI to create the product every time. The actual test then only needs to verify the product appears correctly in the UI, focusing solely on the rendering logic and user experience.

The APIRequestContext Object

The request fixture provided by Playwright is an instance of APIRequestContext. It has methods for all standard HTTP verbs (get, post, put, delete, patch) and a generic fetch method. You can pass various options to these methods, including headers, data (for POST/PUT bodies), params (for query strings), and even form data for application/x-www-form-urlencoded payloads.

typescript
// Example of different request types
const getResponse = await request.get('/users', { params: { status: 'active' } });
const postResponse = await request.post('/items', { data: { name: 'New Item' } });
const putResponse = await request.put('/users/123', { headers: { 'X-Custom-Header': 'value' }, data: { email: 'updated@example.com' } });

// Assertions on responses
expect(getResponse.status()).toBe(200);
expect(await postResponse.json()).toHaveProperty('id');

This API is intuitive and powerful, allowing you to simulate almost any backend interaction you need directly within your test environment. You're not just testing the UI; you're testing the entire application stack, from the browser all the way to your backend API, but with surgical precision.

Trade-offs and Gotchas

While combining UI and API testing is powerful, it's not a silver bullet. You need to be mindful of a few things:

  • Test Clarity: Don't overdo it. If a core user flow must happen through the UI, test it that way. API calls are best for setup, teardown, and verifying data that isn't the primary focus of a UI interaction.
  • Environment Dependencies: Your API tests will hit your actual backend. Ensure your test environment is stable and isolated. Running against a shared dev environment can lead to flaky tests due to concurrent changes from other developers or CI runs.
  • Authentication Flow: While Playwright handles session cookies when you log in via the UI and then use request, more complex authentication (like OAuth redirects involving multiple domains) might require careful setup or separate request contexts for different stages of the flow. For purely API-driven tests, you might need to manage tokens explicitly, e.g., by fetching an access token with one request call and then including it in subsequent request calls' headers.
  • Debugging: When an API call fails, the error message might not be as immediately obvious as a UI test failing to find an element. You'll need to inspect the response object's status(), statusText(), and json() or text() content to debug effectively. Playwright's trace viewer is still invaluable here, showing network requests made by both the browser and the request context.

Wrapping up

Playwright's request context transforms your end-to-end testing strategy from a purely UI-driven affair into a flexible, multi-layered approach. By leveraging direct API calls for test setup, data verification, and non-critical path interactions, you can drastically improve the speed and stability of your test suite. It's about being pragmatic: use the UI when you need to test user experience, and use the API when you only need to manipulate or assert on data.

My recommendation? Take an existing, slow E2E test in your current project. Identify steps that are purely about setting up data or verifying backend state, and refactor them to use Playwright's request object. Observe the speedup and the newfound stability. You'll be surprised how much fat you can trim, making your tests more robust and a pleasure to work with.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕