
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.
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.
// 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.
// 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.
// 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.
// 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 separaterequestcontexts 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 onerequestcall and then including it in subsequentrequestcalls' 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
responseobject'sstatus(),statusText(), andjson()ortext()content to debug effectively. Playwright's trace viewer is still invaluable here, showing network requests made by both the browser and therequestcontext.
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.

When Your Database Needs to Think: Adding AI Search with pgvector
Semantic search using embeddings is a game-changer, but integrating it often feels like a separate service problem. What if your existing Postgres database could handle it natively?

When Your Data Visualization Needs to Be More Than Just a Static Chart: Diving into Plotly.js
Tired of static charts that only tell half the story? Plotly.js offers a powerful way to bring your data to life with rich interactivity, letting users explore and understand complex datasets directly in their browser. It's not just about pretty graphs; it's about making data explorable.

When Your Markdown Notes Need to Act Like a Database: Diving into SilverBullet
I've been using Markdown for notes and documentation for years, but it always felt like I was leaving so much on the table. How do you query across files? How do you create dynamic dashboards? SilverBullet finally tackles this, turning simple Markdown into a surprisingly powerful, queryable knowledg


















