15 Days of Playwright - Day 10: Beyond the Browser - Mastering API & UI Hybrid Tests

Why choose between speed and reliability? Break down the barriers between API and UI testing. Learn how to write hybrid tests that are both blazingly fast and incredibly robust.

15 Days of Playwright - Day 10: Beyond the Browser - Mastering API & UI Hybrid Tests
Jhonatas Matos

Jhonatas Matos

Why choose between speed and reliability? Break down the barriers between API and UI testing. Learn how to write hybrid tests that are both blazingly fast and incredibly robust.

Modern web applications are a mix of frontend UI and backend APIs, and our tests should reflect that reality.

Hybrid testing—mixing API and UI actions in the same test—is the secret weapon of senior automation engineers. It lets you create tests that are:

  • Faster: API calls are orders of magnitude quicker than UI interactions
  • More reliable: Less flaky since you're not depending on UI for setup steps
  • More powerful: You can test scenarios that are difficult or impossible through UI alone

The Power of the request Fixture

Playwright provides a dedicated request fixture for making HTTP requests. This isn't just any HTTP client—it's baked into Playwright's ecosystem and shares authentication state with your browser contexts.

Basic API Request Pattern

Every API test in Playwright follows a fundamental pattern. The example below demonstrates the most basic structure: making a request, verifying its response, and validating the returned data. This is the essential building block for all the tests you'll see next.

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

test('fetch user data via API', async ({ request }) => {
  // Make a GET request
  const response = await request.get('https://api.example.com/users/1');
  
  // Verify response status
  expect(response.status()).toBe(200);
  
  // Parse and verify response body
  const userData = await response.json();
  expect(userData.name).toBe('John Doe');
  expect(userData.email).toBe('john.doe@example.com');
});

Why Use Playwright's request Instead of Axios?

  • Built-in: No additional dependencies required
  • Authentication sharing: Can reuse cookies and tokens from UI sessions
  • Trace integration: API calls appear in Playwright traces for debugging
  • Consistent API: Uses the same async patterns as other Playwright methods

Real-World Hybrid Test Patterns

Pattern 1: API Setup + UI Validation (Most Common)

This is the killer use case for hybrid testing. Use APIs for fast, reliable setup, then use the UI for the actual user journey you want to test.

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

test('create data via API and validate via UI', async ({ page, request }) => {
  // 🚀 FAST API SETUP: Create a new post via JSONPlaceholder API
  const newPost = {
    title: 'My Playwright Test Post',
    body: 'This post was created by an API request from a Playwright test.',
    userId: 1
  };

  const createResponse = await request.post('https://jsonplaceholder.typicode.com/posts', {
    data: newPost
  });
  
  expect(createResponse.status()).toBe(201);
  const createdPost = await createResponse.json();
  const postId = createdPost.id; // Store the ID for later use
  
  // 🎯 UI TEST: Navigate to a UI that displays the data we just created
  // For this example, we'll use a public "test playground" that lists posts
  await page.goto('https://jsonplaceholder.typicode.com/');
  
  // Simulate user navigation to the posts section
  await page.click('text=Guide');
  await page.click('text=/#posts');
  
  // ✅ UI VALIDATION: Verify the new post is displayed (or that the UI behaves correctly)
  // Since JSONPlaceholder is an API, we simulate the UI validation by checking for general content
  await expect(page.locator('body')).toContainText('Posts');
  await expect(page.locator('body')).toContainText('sunt aut facere'); // Text from a sample post
  
  // 🔍 API VALIDATION: Double-check via the API that our data persists
  const verifyResponse = await request.get(`https://jsonplaceholder.typicode.com/posts/${postId}`);
  expect(verifyResponse.status()).toBe(200);
  
  const verifiedPost = await verifyResponse.json();
  expect(verifiedPost.title).toBe(newPost.title);
  expect(verifiedPost.body).toBe(newPost.body);
});

Why this pattern rocks:

  • 5-10x faster than creating accounts through UI
  • More reliable - no UI flakiness during setup
  • Tests exactly what users experience in the UI
  • Validates both frontend and backend consistency

Pattern 2: UI Action + API Verification

Sometimes you want to test a UI action but verify the backend result directly.

test('user registration creates correct API record', async ({ page, request }) => {
  // 🎯 UI ACTION: Complete registration through UI
  await page.goto('https://bugbank.netlify.app/register');
  
  const testEmail = `user${Date.now()}@example.com`;
  
  await page.fill('input[name="name"]', 'Test User');
  await page.fill('input[name="email"]', testEmail);
  await page.fill('input[name="password"]', 'securepassword123');
  await page.fill('input[name="passwordConfirmation"]', 'securepassword123');
  await page.click('button:has-text("Cadastrar")');
  
  // Wait for UI to indicate success
  await expect(page.locator('.welcome-message')).toBeVisible();
  
  // 🔍 API VERIFICATION: Check what actually got created
  const usersResponse = await request.get(`https://bugbank-api.example.com/users?email=${testEmail}`);
  expect(usersResponse.status()).toBe(200);
  
  const users = await usersResponse.json();
  expect(users).toHaveLength(1);
  expect(users[0].name).toBe('Test User');
  expect(users[0].email).toBe(testEmail);
  expect(users[0].status).toBe('active');
});

Pattern 3: API Cleanup

Always clean up after your tests, especially when creating data via APIs.

test.afterEach(async ({ request }) => {
  // Clean up test data via API
  await request.delete('https://bugbank-api.example.com/test-data/cleanup', {
    data: { cleanupBefore: new Date().toISOString() }
  });
});

Advanced API Testing Techniques

Handling Authentication

Most real-world applications require authentication. Here's how to handle a common token-based authentication flow using a real, public API.

test('API call with authentication', async ({ request }) => {
  // 1. First, authenticate and get a token from a real API
  const authResponse = await request.post('https://reqres.in/api/login', {
    data: {
      email: 'eve.holt@reqres.in', // Test credentials provided by the API
      password: 'cityslicka'
    }
  });
  
  expect(authResponse.status()).toBe(200);
  const { token } = await authResponse.json();
  expect(token).toBeDefined(); // Ensure we actually got a token
  
  // 2. Use the token to make an authenticated request
  const userResponse = await request.get('https://reqres.in/api/users/2', {
    headers: {
      'Authorization': `Bearer ${token}` // Attach the token in the Authorization header
    }
  });
  
  expect(userResponse.status()).toBe(200);
  
  // 3. Verify the protected data was returned
  const userData = await userResponse.json();
  expect(userData.data.email).toBe('janet.weaver@reqres.in');
  expect(userData.data.first_name).toBe('Janet');
});

Key points demonstrated:

  • Real API: Uses a live, working authentication endpoint
  • Token-based auth: Shows the complete flow from login to using the token
  • Header management: Demonstrates how to set the Authorization header
  • End-to-end validation: Verifies both the auth response and the subsequent protected request

Testing Error Cases

A robust test suite verifies not only success scenarios but also how the API handles errors gracefully. Testing for proper error responses is crucial.

test('API returns proper error for invalid registration', async ({ request }) => {
  // Attempt to register without providing a password (known to cause error in this API)
  const response = await request.post('https://reqres.in/api/register', {
    data: {
      email: 'sydney@fife' // Missing required 'password' field
    }
  });
  
  // Verify error response status code
  expect(response.status()).toBe(400);
  
  // Parse and verify the structure of the error response
  const errorData = await response.json();
  expect(errorData.error).toBe('Missing password'); // Specific error message from the API
});

test('API returns 404 for non-existent resource', async ({ request }) => {
  // Attempt to access a user that doesn't exist
  const response = await request.get('https://jsonplaceholder.typicode.com/users/9999');
  
  // Verify 404 Not Found status code
  expect(response.status()).toBe(404);
  
  // For this API, the response body is empty, so we might just verify the status
  // Alternatively, some APIs return a JSON error object on 404
});

test('API returns proper error for invalid login', async ({ request }) => {
  // Attempt login with incorrect credentials
  const response = await request.post('https://reqres.in/api/login', {
    data: {
      email: 'peter@klaven', // This user is known to not exist in the API
      password: 'anypassword'
    }
  });
  
  // Verify error response
  expect(response.status()).toBe(400);
  
  const errorData = await response.json();
  expect(errorData.error).toBe('user not found'); // Specific error message
});

Key points demonstrated:

  • Real API endpoints that actually return error codes
  • Testing various HTTP status codes: 400 (Bad Request) and 404 (Not Found)
  • Validating error response structure: Ensuring the API returns meaningful error messages
  • Testing known failure conditions: Missing fields, invalid resources, wrong credentials

Why testing errors matters:

  • Ensures your application handles API failures gracefully
  • Validates that error responses follow consistent patterns
  • Protects against unexpected behavior when things go wrong

Working with Different Content Types

APIs can return data in various formats. Playwright's request fixture provides methods to handle these different content types appropriately.

test('handle different API response formats', async ({ request }) => {
  // 1. JSON (Application/JSON) - The most common format
  const jsonResponse = await request.get('https://api.github.com/users/octocat');
  expect(jsonResponse.headers()['content-type']).toContain('application/json');
  const jsonData = await jsonResponse.json(); // Parse as JSON object
  expect(jsonData.login).toBe('octocat');
  expect(jsonData.public_repos).toBeDefined();

  // 2. Plain Text (text/plain) - Often used for simple responses
  const textResponse = await request.get('https://httpbin.org/encoding/utf8');
  expect(textResponse.headers()['content-type']).toContain('text/plain');
  const textData = await textResponse.text(); // Get response as text
  expect(textData.length).toBeGreaterThan(0);
  expect(textData).toContain('Unicode演示');

  // 3. XML (Application/XML) - Common in legacy systems and APIs
  const xmlResponse = await request.get('https://httpbin.org/xml');
  expect(xmlResponse.headers()['content-type']).toContain('application/xml');
  const xmlData = await xmlResponse.text(); // Get response as text for XML
  expect(xmlData).toContain('<?xml version="1.0" encoding="UTF-8"?>');
  expect(xmlData).toContain('<slideshow');

  // 4. HTML (text/html) - When APIs return HTML content
  const htmlResponse = await request.get('https://httpbin.org/html');
  expect(jsonResponse.ok()).toBeTruthy();
  const htmlData = await htmlResponse.text();
  expect(htmlData).toContain('<h1>'); // Simple check for HTML structure
});

Key points demonstrated:

  • Real APIs: Uses working endpoints from GitHub and HTTPBin
  • Content-type verification: Checks the content-type header to confirm format
  • Appropriate parsing methods: Uses .json() for JSON and .text() for text/XML/HTML
  • Practical assertions: Shows how to validate each response type

Pro Tip: You can also handle binary data like images or files:

test('handle binary data', async ({ request }) => {
  const imageResponse = await request.get('https://httpbin.org/image/png');
  expect(imageResponse.headers()['content-type']).toBe('image/png');
  
  // For binary data, you might save it to a file or check its properties
  const imageBuffer = await imageResponse.body();
  expect(imageBuffer.length).toBeGreaterThan(0);
});

Best Practices for Hybrid Testing

Scenario Use API For Use UI For Why
Test Setup ✅ Creating test data ❌ Slow setup flows APIs are faster and more reliable for setup
Test Validation ✅ Backend state verification ✅ User experience verification Verify both what users see and what the system stores
Cleanup ✅ Deleting test data ❌ Manual cleanup APIs ensure proper cleanup without UI dependencies
Error Testing ✅ Testing edge cases ✅ User error handling APIs can easily simulate errors; UI tests how users experience them

When to Avoid Hybrid Testing

While hybrid testing is powerful, it's not a silver bullet. Here are scenarios where a pure API or pure UI approach might be better:

  • Testing End-to-End Login Flows: The entire UI journey--from entering credentials to redirecting to a dashboard--is what you need to test. Using an API token bypasses the actual user experience you're trying to validate. Example: Testing multi-factor authentication (MFA) or single sign-on (SSO) flows.
  • Testing UI-specific functionality: If the user experience itself is the subject of the test, APIs can't help. Example: Animations, page transitions, form validation messages, drag-and-drop interactions, or visual regression testing.
  • When API contracts are unstable: If the backend endpoints and data structures are changing frequently, maintaining the API portions of your hybrid tests will create significant overhead and negate the efficiency gains. Example: Early in a product's development or during a major backend refactor.
  • Testing Security-Sensitive Flows: For certain actions, the UI path is a critical part of the security model. Example: A password change should require entering the old password in the UI; it shouldn't be possible to bypass this via an API call in a test.
  • Creating Simple, Atomic Tests: Sometimes, for debugging or CI speed, you want a test that only does one thing. A pure UI test can be simpler to reason about when isolating a frontend issue.

Practical Examples

Let's implement hybrid tests using some of the most popular free APIs used by the testing community worldwide. These APIs are perfect for practice and learning.

Example 1: JSONPlaceholder (REST API Testing)

JSONPlaceholder is the most famous fake REST API used for testing and prototyping.

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

test.describe('JSONPlaceholder API Tests', () => {
  test('create post via API and verify via UI simulation', async ({ page, request }) => {
    // 1. API: Create a new post
    const newPost = {
      title: 'Playwright Hybrid Test',
      body: 'This post was created by Playwright API test',
      userId: 1
    };

    const createResponse = await request.post('https://jsonplaceholder.typicode.com/posts', {
      data: newPost
    });

    expect(createResponse.status()).toBe(201);
    const createdPost = await createResponse.json();
    
    // Verify API response
    expect(createdPost.title).toBe(newPost.title);
    expect(createdPost.body).toBe(newPost.body);
    expect(createdPost.id).toBeDefined();

    // 2. UI Simulation: "Verify" the post exists by fetching it
    // (Since JSONPlaceholder is read-only for GET, we simulate UI verification)
    await page.goto('https://jsonplaceholder.typicode.com/');
    
    // Navigate to posts (simulating UI behavior)
    await page.click('text=Guide');
    await page.click('text=JSONPlaceholder API');
    
    // Verify the post ID is mentioned in the documentation (simulated verification)
    const postId = createdPost.id;
    await expect(page.locator('body')).toContainText('posts');
    
    // 3. API Verification: Get the post to confirm it exists
    const getResponse = await request.get(`https://jsonplaceholder.typicode.com/posts/${postId}`);
    expect(getResponse.status()).toBe(200);
    
    const fetchedPost = await getResponse.json();
    expect(fetchedPost.title).toBe(newPost.title);
  });

  test('test user CRUD operations', async ({ request }) => {
    // Create user
    const userData = {
      name: 'John Playwright',
      email: `john.playwright${Date.now()}@test.com`,
      username: 'jplaywright'
    };

    const createResponse = await request.post('https://jsonplaceholder.typicode.com/users', {
      data: userData
    });

    expect(createResponse.status()).toBe(201);
    const createdUser = await createResponse.json();

    // Read user
    const readResponse = await request.get(`https://jsonplaceholder.typicode.com/users/${createdUser.id}`);
    expect(readResponse.status()).toBe(200);
    const readUser = await readResponse.json();
    expect(readUser.name).toBe(userData.name);

    // Update user
    const updateData = { name: 'John Updated' };
    const updateResponse = await request.put(`https://jsonplaceholder.typicode.com/users/${createdUser.id}`, {
      data: updateData
    });

    expect(updateResponse.status()).toBe(200);
    const updatedUser = await updateResponse.json();
    expect(updatedUser.name).toBe(updateData.name);

    // Delete user (simulated - JSONPlaceholder doesn't actually delete)
    const deleteResponse = await request.delete(`https://jsonplaceholder.typicode.com/users/${createdUser.id}`);
    expect(deleteResponse.status()).toBe(200);
  });
});

Example 2: ReqRes (Realistic User API)

ReqRes provides a more realistic API with actual CRUD operations.

test.describe('ReqRes API Tests', () => {
  test('register user and verify login', async ({ request }) => {
    // Register new user
    const userData = {
      email: `eve.holt${Date.now()}@reqres.in`,
      password: 'playwright123'
    };

    const registerResponse = await request.post('https://reqres.in/api/register', {
      data: userData
    });

    expect(registerResponse.status()).toBe(200);
    const registerResult = await registerResponse.json();
    expect(registerResult.token).toBeDefined();
    expect(registerResult.id).toBeDefined();

    // Login with same credentials
    const loginResponse = await request.post('https://reqres.in/api/login', {
      data: userData
    });

    expect(loginResponse.status()).toBe(200);
    const loginResult = await loginResponse.json();
    expect(loginResult.token).toBeDefined();

    // Use the token to get user profile
    const userResponse = await request.get(`https://reqres.in/api/users/${registerResult.id}`, {
      headers: {
        'Authorization': `Bearer ${loginResult.token}`
      }
    });

    expect(userResponse.status()).toBe(200);
    const userProfile = await userResponse.json();
    expect(userProfile.data.email).toBe(userData.email);
  });

  test('test pagination and data listing', async ({ request }) => {
    // Test pagination parameters
    const page = 2;
    const perPage = 3;
    
    const response = await request.get(`https://reqres.in/api/users?page=${page}&per_page=${perPage}`);
    expect(response.status()).toBe(200);
    
    const data = await response.json();
    expect(data.page).toBe(page);
    expect(data.per_page).toBe(perPage);
    expect(data.data).toHaveLength(perPage);
    expect(data.total_pages).toBeGreaterThan(0);

    // Verify user data structure
    const firstUser = data.data[0];
    expect(firstUser.id).toBeDefined();
    expect(firstUser.email).toBeDefined();
    expect(firstUser.first_name).toBeDefined();
    expect(firstUser.last_name).toBeDefined();
    expect(firstUser.avatar).toBeDefined();
  });
});

Community Favorite APIs for Testing

Here are some of the most popular free APIs used by testers worldwide:

Pro Tips for API Testing

// 1. Always validate response schemas
test('validate API response schema', async ({ request }) => {
  const response = await request.get('https://jsonplaceholder.typicode.com/users/1');
  const user = await response.json();
  
  // Basic schema validation
  expect(user).toEqual(expect.objectContaining({
    id: expect.any(Number),
    name: expect.any(String),
    email: expect.any(String),
    address: expect.objectContaining({
      street: expect.any(String),
      city: expect.any(String)
    })
  }));
});

// 2. Test error responses
test('test 404 error response', async ({ request }) => {
  const response = await request.get('https://jsonplaceholder.typicode.com/users/9999');
  expect(response.status()).toBe(404);
});

// 3. Test with different HTTP methods
test('test all HTTP methods', async ({ request }) => {
  // GET
  const getResponse = await request.get('https://jsonplaceholder.typicode.com/posts/1');
  expect(getResponse.status()).toBe(200);
  
  // POST
  const postResponse = await request.post('https://jsonplaceholder.typicode.com/posts', {
    data: { title: 'Test', body: 'Content', userId: 1 }
  });
  expect(postResponse.status()).toBe(201);
  
  // PUT
  const putResponse = await request.put('https://jsonplaceholder.typicode.com/posts/1', {
    data: { title: 'Updated', body: 'Content', userId: 1 }
  });
  expect(putResponse.status()).toBe(200);
  
  // DELETE
  const deleteResponse = await request.delete('https://jsonplaceholder.typicode.com/posts/1');
  expect(deleteResponse.status()).toBe(200);
});

// 4. Test headers and content types
test('verify response headers', async ({ request }) => {
  const response = await request.get('https://jsonplaceholder.typicode.com/posts');
  const headers = response.headers();
  
  expect(headers['content-type']).toContain('application/json');
  expect(headers['cache-control']).toBeDefined();
});
API URL Best For Features
JSONPlaceholder jsonplaceholder.typicode.com REST API testing, CRUD operations Posts, users, comments, albums, photos
ReqRes reqres.in User authentication, realistic responses Login, registration, pagination, error responses
Cat API thecatapi.com File download, media testing Image downloads, content-type verification
Dog API dog.ceo/api JSON structure, random data Random dog images, breed lists
OpenWeatherMap api.openweathermap.org API keys, query parameters Weather data, geolocation, free tier available

Want to see all these hybrid testing patterns in action? Check out the complete working example:

Link to GitHub projectGitHub Octocat

Conclusion

Hybrid testing isn't about choosing between API and UI testing—it's about using each tool where it shines brightest. By combining them strategically, you get:

Speed Meets Reliability

  • API setup eliminates slow, flaky UI preparation
  • UI testing ensures real user experiences work correctly
  • API verification confirms backend consistency

Comprehensive Coverage

  • Frontend behavior: What users actually see and experience
  • Backend logic: What the system actually stores and processes
  • Integration points: How frontend and backend work together

Maintainable Test Suite

  • Clear separation: Setup vs action vs verification
  • Faster execution: Parallel API + UI testing
  • Easier debugging: Know exactly where failures occur

Remember: The goal isn't to eliminate UI testing, but to make it more focused and effective. Use APIs for the boring, repetitive setup work, and save the UI testing for what really matters—simulating actual user journeys.

Now you're not just testing the frontend or backend—you're testing the complete system like a senior automation engineer.


Thank you for reading and see you in the next lesson! ☕💜