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:

ApproachProsCons
Pure UI TestsMost realistic user simulationSlow, flaky, hard to set up test data
Pure API TestsFast, reliable, great for coverageMisses visual/UX issues
Hybrid TestsFast setup + realistic verificationRequires 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:

bash
npm init playwright@latest

Basic project structure:

text
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

typescript
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

typescript
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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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:

typescript
// 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

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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:

typescript
// 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

typescript
// 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:

yaml
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

typescript
test.beforeEach(async ({ request }) => {
  // Clean up test data before each test
  await request.post('/api/test/reset', {
    data: { environment: 'test' }
  });
});

2. Use Environment Variables

typescript
// .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

typescript
// 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

typescript
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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
// ❌ 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

ScenarioAPI OnlyUI OnlyHybrid
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.

Recommended Next Reads

Popular posts from this blog

Mastering Selenium Practice: Automating Web Tables with Demo Examples

18 Demo Websites for Selenium Automation Practice in 2026

25+ Selenium WebDriver Commands: The Complete Cheat Sheet with Examples

Selenium Automation for E-commerce Websites: End-to-End Testing Scenarios

Top 7 Web Development Trends in the Market (2026)

14+ Best Selenium Practice Exercises to Master Automation Testing (with Code & Challenges)

Top Selenium Interview Questions & Answers of 2026

Playwright CI/CD Integration with GitHub Actions: The Complete Guide

Your First Playwright Test: Step-by-Step Tutorial (Beginner Friendly)

Top 10 Highly Paid Indian-Origin CEOs in the USA