API Testing with Playwright: Building UI + API Hybrid Tests
In modern web applications, the line between frontend and backend is increasingly blurred. Your UI makes API calls, your authentication depends on API responses, and your data is fetched asynchronously. So why should your tests treat them separately?
Playwright isn't just a UI testing tool. With its built-in request context and network interception capabilities, it's a powerful API testing framework that enables a hybrid approach: combining API and UI validation in a single, coherent test flow.
This guide explores how to build robust hybrid tests that leverage the best of both worlds—using APIs for setup and data validation while using the UI for visual and behavioral verification.
Why Hybrid Tests?
Before diving into code, let's understand the philosophy behind hybrid testing:
| Approach | Pros | Cons |
|---|---|---|
| Pure UI Tests | Most realistic user simulation | Slow, flaky, hard to set up test data |
| Pure API Tests | Fast, reliable, great for coverage | Misses visual/UX issues |
| Hybrid Tests | Fast setup + realistic verification | Requires more initial design |
Hybrid tests give you the speed of API tests with the confidence of UI tests.
The Hybrid Testing Pyramid
/\
/ \
/ UI \
/Assert\
/ Visual \
/ Behavior \
/──────────────\
/ API Calls \
/ Data Setup \
/────────────────────\
Performance? Security? Integration?In this pyramid:
Base Layer: API calls for test setup (fast, reliable)
Middle Layer: UI interactions (user-centric)
Top Layer: API assertions for verification (data integrity)
Prerequisites
Ensure your Playwright project is set up:
npm init playwright@latestBasic project structure:
hybrid-tests/ ├── tests/ │ ├── api/ │ ├── ui/ │ └── hybrid/ ├── utils/ │ ├── api-helpers.ts │ └── test-data.ts └── playwright.config.ts
Core Concepts: Playwright's API Testing Capabilities
Playwright provides two powerful API testing tools:
1. request Fixture - For Independent API Calls
import { test, expect } from '@playwright/test'; test('make standalone API call', async ({ request }) => { const response = await request.get('https://api.example.com/users'); expect(response.ok()).toBeTruthy(); const users = await response.json(); expect(users.length).toBeGreaterThan(0); });
2. page.request - Context-Aware API Calls
test('API call within browser context', async ({ page }) => { // This request shares cookies/auth with the page const response = await page.request.get('/api/user-profile'); const profile = await response.json(); await page.goto('/profile'); await expect(page.locator('.user-name')).toHaveText(profile.name); });
Strategy 1: API Setup, UI Verify
The most common hybrid pattern: use APIs to set up test data, then verify it in the UI.
Example: Testing a Todo Application
// tests/hybrid/todo-workflow.spec.ts import { test, expect } from '@playwright/test'; test.describe('Todo App Hybrid Tests', () => { test('should display todos created via API', async ({ page, request }) => { // STEP 1: API Setup - Create test data const testTodos = [ { title: 'Learn Playwright', completed: false }, { title: 'Write hybrid tests', completed: true }, { title: 'Deploy to CI', completed: false } ]; // Clean up any existing todos via API await request.post('/api/todos/reset'); // Create todos via API for (const todo of testTodos) { const createResponse = await request.post('/api/todos', { data: todo }); expect(createResponse.ok()).toBeTruthy(); } // STEP 2: UI Interaction - Load and verify await page.goto('/todos'); // Wait for todos to load (they might be fetched via API) await page.waitForResponse(response => response.url().includes('/api/todos') && response.status() === 200 ); // STEP 3: UI Assertions - Visual verification const todoItems = page.locator('.todo-item'); await expect(todoItems).toHaveCount(3); // Verify first todo await expect(todoItems.first()).toContainText('Learn Playwright'); // Verify completed todo has checkmark const completedTodo = page.locator('.todo-item.completed'); await expect(completedTodo).toContainText('Write hybrid tests'); // STEP 4: API Assertion - Double-check data integrity const getResponse = await request.get('/api/todos'); const apiTodos = await getResponse.json(); expect(apiTodos).toHaveLength(3); expect(apiTodos[1].completed).toBe(true); }); });
Why this works:
Setup is fast (no UI navigation for data creation)
Tests are reliable (API calls don't flake)
UI verification ensures user sees correct data
Strategy 2: UI Action, API Verify
Sometimes you want to perform an action in the UI and verify the result via API to ensure backend integrity.
Example: Testing User Registration
// tests/hybrid/registration.spec.ts import { test, expect } from '@playwright/test'; test('user registration with backend verification', async ({ page, request }) => { const testUser = { email: `test-${Date.now()}@example.com`, password: 'SecurePass123!', name: 'Test User' }; // STEP 1: UI Action - Register through the form await page.goto('/register'); await page.fill('#email', testUser.email); await page.fill('#password', testUser.password); await page.fill('#confirm-password', testUser.password); await page.fill('#name', testUser.name); // Listen for the registration API call const registrationPromise = page.waitForResponse(response => response.url().includes('/api/users') && response.request().method() === 'POST' ); await page.click('button[type="submit"]'); // STEP 2: Capture and verify API response const registrationResponse = await registrationPromise; expect(registrationResponse.status()).toBe(201); const responseBody = await registrationResponse.json(); expect(responseBody.user.email).toBe(testUser.email); expect(responseBody.user.id).toBeDefined(); // STEP 3: Verify via direct API call const getUserResponse = await request.get(`/api/users/${responseBody.user.id}`); expect(getUserResponse.ok()).toBeTruthy(); const userData = await getUserResponse.json(); expect(userData.email).toBe(testUser.email); expect(userData.name).toBe(testUser.name); // STEP 4: UI verification - Should see success message await expect(page.locator('.success-message')).toHaveText( 'Registration successful!' ); await expect(page).toHaveURL(/.*\/welcome/); });
Key technique: page.waitForResponse() captures the exact network call triggered by a UI action, allowing you to verify both the UI result and the API contract simultaneously.
Strategy 3: Maintaining Authentication State
One of the biggest challenges in hybrid testing is managing authentication. Playwright makes this seamless with storage state.
Global Setup with API Authentication
// global-setup.ts import { FullConfig, request } from '@playwright/test'; async function globalSetup(config: FullConfig) { // Use API to authenticate and save state const requestContext = await request.newContext(); const loginResponse = await requestContext.post('https://api.example.com/login', { data: { email: 'test@example.com', password: 'password123' } }); const { token } = await loginResponse.json(); // Save authentication state await requestContext.storageState({ path: 'auth.json' }); // Also save token for API calls process.env.API_TOKEN = token; } export default globalSetup;
Using Auth in Hybrid Tests
// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ globalSetup: './global-setup', use: { storageState: 'auth.json', // UI tests use this extraHTTPHeaders: { 'Authorization': `Bearer ${process.env.API_TOKEN}` // API calls use this } } });
Now your hybrid tests are automatically authenticated:
// tests/hybrid/authenticated.spec.ts import { test, expect } from '@playwright/test'; test('authenticated hybrid flow', async ({ page, request }) => { // UI is already logged in via storageState await page.goto('/dashboard'); await expect(page.locator('.welcome')).toContainText('Welcome back'); // API calls automatically include auth token const profileResponse = await request.get('/api/user/profile'); expect(profileResponse.ok()).toBeTruthy(); const profile = await profileResponse.json(); await expect(page.locator('.user-email')).toHaveText(profile.email); });
Strategy 4: Data-Driven Hybrid Tests
Combine API data fetching with UI testing for powerful parameterized tests.
Fetching Test Data from APIs
// tests/hybrid/data-driven.spec.ts import { test, expect } from '@playwright/test'; test.describe('Product Catalog Tests', () => { // Fetch products once for all tests let products: any[]; test.beforeAll(async ({ request }) => { const response = await request.get('/api/products'); expect(response.ok()).toBeTruthy(); products = await response.json(); }); test('should display all products correctly', async ({ page }) => { await page.goto('/products'); // Verify count matches API const productCards = page.locator('.product-card'); await expect(productCards).toHaveCount(products.length); // Spot-check first few products for (let i = 0; i < Math.min(3, products.length); i++) { const product = products[i]; const card = productCards.nth(i); await expect(card.locator('.product-name')).toHaveText(product.name); await expect(card.locator('.product-price')).toHaveText( `$${product.price}` ); } }); // Parameterized test using API data for (const product of products.slice(0, 5)) { test(`product detail page for ${product.name}`, async ({ page }) => { await page.goto(`/products/${product.id}`); // Verify UI matches API data await expect(page.locator('h1')).toHaveText(product.name); await expect(page.locator('.price')).toHaveText(`$${product.price}`); await expect(page.locator('.description')).toHaveText(product.description); // Verify related products API is called const relatedResponse = await page.waitForResponse(response => response.url().includes(`/api/products/${product.id}/related`) ); expect(relatedResponse.status()).toBe(200); }); } });
Strategy 5: Visual Regression with API Baseline
Combine visual testing with API data for dynamic golden files.
// tests/hybrid/visual-with-api.spec.ts import { test, expect } from '@playwright/test'; test('user profile card visual test', async ({ page, request }) => { // Get user data from API const response = await request.get('/api/users/current'); const user = await response.json(); await page.goto('/profile'); // Wait for dynamic content to load await page.waitForSelector('.profile-card'); // Take screenshot with API data in the name for traceability await expect(page.locator('.profile-card')).toHaveScreenshot( `profile-${user.id}-${user.theme}.png` ); // Also verify specific data points await expect(page.locator('.user-name')).toHaveText(user.name); await expect(page.locator('.user-role')).toHaveText(user.role); });
Strategy 6: Mocking APIs for Edge Cases
Use API mocking to test error states that are hard to reproduce.
// tests/hybrid/api-mocking.spec.ts import { test, expect } from '@playwright/test'; test('handles API failure gracefully', async ({ page }) => { // Mock the products API to return an error await page.route('**/api/products**', async route => { await route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) }); }); await page.goto('/products'); // UI should show error message await expect(page.locator('.error-message')).toHaveText( 'Failed to load products. Please try again.' ); // Retry button should work await page.click('button:has-text("Retry")'); // Verify retry API call const retryRequest = await page.waitForRequest('**/api/products**'); expect(retryRequest.method()).toBe('GET'); }); test('test with network delay simulation', async ({ page }) => { // Simulate slow network await page.route('**/api/search**', async route => { await new Promise(resolve => setTimeout(resolve, 3000)); await route.continue(); }); await page.goto('/search'); await page.fill('#search-input', 'test'); // Verify loading state appears await expect(page.locator('.loading-spinner')).toBeVisible(); // Verify results eventually load await expect(page.locator('.results')).toBeVisible({ timeout: 5000 }); });
Strategy 7: Performance Testing Hybrid Style
Combine UI performance metrics with API timing.
// tests/hybrid/performance.spec.ts import { test, expect } from '@playwright/test'; test('measure full page load with API timing', async ({ page, request }) => { // Start API timing const apiStart = Date.now(); // Make API call that page will use const apiResponse = await request.get('/api/dashboard-data'); const apiDuration = Date.now() - apiStart; expect(apiDuration).toBeLessThan(500); // API should be fast // Now load the page and measure const pageStart = Date.now(); await page.goto('/dashboard'); // Wait for network idle await page.waitForLoadState('networkidle'); const pageLoadDuration = Date.now() - pageStart; expect(pageLoadDuration).toBeLessThan(2000); // Get performance metrics from browser const performanceTiming = await page.evaluate(() => { const { navigationStart, domContentLoadedEventEnd, loadEventEnd } = performance.timing; return { domReady: domContentLoadedEventEnd - navigationStart, fullyLoaded: loadEventEnd - navigationStart }; }); console.log('Performance metrics:', performanceTiming); // Assert reasonable performance expect(performanceTiming.fullyLoaded).toBeLessThan(3000); });
Advanced Pattern: The Hybrid Test Factory
Create reusable test factories for common patterns:
// utils/hybrid-factory.ts import { Page, APIRequestContext, expect } from '@playwright/test'; interface EntityConfig { createApi: (data: any) => Promise<any>; getApi: (id: string) => Promise<any>; listApi: () => Promise<any[]>; uiPath: (id?: string) => string; selectors: { list: string; item: string; name: string; createButton: string; }; } export function createHybridTests(config: EntityConfig) { return { async testCreateViaUI(page: Page, request: APIRequestContext, testData: any) { // Setup: ensure clean state const existing = await config.listApi(); for (const item of existing) { await request.delete(`${config.createApi(item.id)}`); } // UI: Create await page.goto(config.uiPath()); await page.click(config.selectors.createButton); // Fill form (customize per entity) for (const [key, value] of Object.entries(testData)) { await page.fill(`#${key}`, String(value)); } const createResponse = page.waitForResponse(config.createApi('')); await page.click('button[type="submit"]'); // API: Verify const response = await createResponse; const created = await response.json(); const fetched = await config.getApi(created.id); expect(fetched).toMatchObject(testData); // UI: Verify await expect(page.locator(config.selectors.list)) .toContainText(created.name); }, async testReadViaAPI(page: Page, request: APIRequestContext, id: string) { // API: Get data const entity = await config.getApi(id); // UI: Navigate and verify await page.goto(config.uiPath(id)); for (const [key, value] of Object.entries(entity)) { if (typeof value === 'string') { await expect(page.locator(`[data-testid="${key}"]`)) .toHaveText(value); } } } }; }
Using the Factory
// tests/hybrid/products.spec.ts import { test } from '@playwright/test'; import { createHybridTests } from '../../utils/hybrid-factory'; const productTests = createHybridTests({ createApi: (data) => request.post('/api/products', { data }), getApi: (id) => request.get(`/api/products/${id}`).then(r => r.json()), listApi: () => request.get('/api/products').then(r => r.json()), uiPath: (id) => id ? `/products/${id}` : '/products/new', selectors: { list: '.product-list', item: '.product-card', name: '.product-name', createButton: '#new-product-btn' } }); test('create product via UI', async ({ page, request }) => { await productTests.testCreateViaUI(page, request, { name: 'Test Product', price: 29.99, description: 'A test product' }); });
CI/CD Integration for Hybrid Tests
Optimize your GitHub Actions workflow for hybrid tests:
name: Hybrid Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies run: npm ci - name: Start test server run: | npm run start:test & npx wait-on http://localhost:3000 - name: Run hybrid tests run: npx playwright test tests/hybrid/ env: API_BASE_URL: http://localhost:3000/api - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: hybrid-test-results path: | test-results/ playwright-report/
Best Practices for Hybrid Tests
1. Isolate Test Data
test.beforeEach(async ({ request }) => { // Clean up test data before each test await request.post('/api/test/reset', { data: { environment: 'test' } }); });
2. Use Environment Variables
// .env.test API_URL=http://localhost:3000/api TEST_USER_EMAIL=test@example.com TEST_USER_PASSWORD=testpass123 // In tests const apiUrl = process.env.API_URL; const testUser = { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD };
3. Create Test Data Helpers
// utils/test-data.ts export async function createTestUser(request: APIRequestContext) { const user = { email: `user-${Date.now()}@test.com`, password: 'Password123!' }; const response = await request.post('/api/users', { data: user }); return response.json(); } export async function createTestTodo(request: APIRequestContext, userId: string) { const todo = { title: `Todo ${Date.now()}`, userId, completed: false }; const response = await request.post('/api/todos', { data: todo }); return response.json(); }
4. Handle Async Operations
test('async hybrid flow', async ({ page, request }) => { // Start async operation const operationPromise = request.post('/api/long-operation', { data: { task: 'heavy-computation' } }); // Navigate to status page await page.goto('/operations/status'); // Wait for completion via UI await expect(page.locator('.status')).toHaveText('completed', { timeout: 10000 }); // Verify via API const operation = await operationPromise; const result = await operation.json(); expect(result.status).toBe('completed'); });
Common Pitfalls and Solutions
Pitfall 1: State Leakage Between Tests
// ❌ Bad: Tests share state test('test 1', async ({ request }) => { await request.post('/api/data', { data: { key: 'value1' } }); }); test('test 2', async ({ request }) => { // Might see data from test 1 const data = await request.get('/api/data'); }); // ✅ Good: Isolated state test.beforeEach(async ({ request }) => { await request.post('/api/test/reset'); });
Pitfall 2: Timing Issues with Async Operations
// ❌ Bad: Race condition test('async test', async ({ page }) => { await page.click('#submit'); const response = await page.request.get('/api/result'); // Response might not be ready yet }); // ✅ Good: Wait for specific condition test('async test', async ({ page }) => { await page.click('#submit'); const response = await page.waitForResponse('/api/result'); expect(response.ok()).toBeTruthy(); });
Pitfall 3: Hardcoding URLs
// ❌ Bad: Hardcoded URLs await request.get('http://localhost:3000/api/users'); // ✅ Good: Use baseURL from config await request.get('/api/users');
Conclusion: The Power of Hybrid Testing
Hybrid testing with Playwright represents a fundamental shift in how we think about end-to-end testing. By combining API and UI testing, you get:
Faster tests through efficient API setup
More reliable tests by reducing UI dependencies for data creation
Comprehensive coverage that verifies both backend and frontend
Better debugging with multiple verification layers
Realistic scenarios that mirror how users actually interact with your app
When to Use Each Approach
| Scenario | API Only | UI Only | Hybrid |
|---|---|---|---|
| Authentication flows | ❌ | ❌ | ✅ |
| Data validation | ✅ | ❌ | ✅ |
| Visual regression | ❌ | ✅ | ❌ |
| Complex user journeys | ❌ | ✅ | ✅ |
| Performance testing | ✅ | ✅ | ✅ |
| Error handling | ✅ | ❌ | ✅ |
The key insight: Your tests should mirror how your application actually works. Modern apps are built on API-UI interactions. Your tests should be too.
Start small—convert your slowest UI setup steps to API calls first. Then, gradually build out more sophisticated hybrid patterns. Your test suite will thank you with faster runs and fewer flakes.