🔥 0
0
Lesson 6 of 10 30 min +250 XP

API Testing & Hybrid Workflows

Here's a secret: the fastest UI test is one that doesn't use the UI when it doesn't have to. Playwright's API testing capabilities let you bypass slow UI operations for setup and teardown.

The Speed Problem

A typical checkout test through the UI:

1. Go to homepage               → 2s
2. Search for product           → 1s
3. Click product                → 1s
4. Add to cart                  → 2s
5. Go to cart                   → 1s
6. Fill checkout form           → 3s
7. Submit order                 → 2s
8. Verify confirmation          → 1s
───────────────────────────────────
Total:                           13s

What if you only care about testing step 7-8? The first 6 steps are just setup.

With API + UI hybrid:
1. Create cart via API          → 0.1s
2. Add items via API            → 0.1s
3. Navigate to checkout         → 1s
4. Submit order (UI)            → 2s
5. Verify confirmation          → 1s
───────────────────────────────────
Total:                           4.2s (3x faster)

Playwright Request Context

Basic API Requests

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

test('fetch products via API', async ({ request }) => {
  const response = await request.get('https://api.shop.example/products');

  expect(response.ok()).toBeTruthy();
  expect(response.status()).toBe(200);

  const products = await response.json();
  expect(products.length).toBeGreaterThan(0);
  expect(products[0]).toHaveProperty('name');
  expect(products[0]).toHaveProperty('price');
});

POST Requests

test('create order via API', async ({ request }) => {
  const response = await request.post('https://api.shop.example/orders', {
    data: {
      items: [
        { productId: 'SKU-001', quantity: 2 },
        { productId: 'SKU-002', quantity: 1 },
      ],
      shippingAddress: {
        street: '123 Main St',
        city: 'San Francisco',
        state: 'CA',
        zip: '94102',
      },
    },
  });

  expect(response.ok()).toBeTruthy();

  const order = await response.json();
  expect(order.orderId).toBeDefined();
  expect(order.status).toBe('pending');
});

Request Options

test('authenticated API request', async ({ request }) => {
  const response = await request.get('https://api.shop.example/user/orders', {
    headers: {
      'Authorization': 'Bearer your-token-here',
      'Content-Type': 'application/json',
    },
    params: {
      limit: 10,
      status: 'completed',
    },
    timeout: 30000,
  });

  expect(response.ok()).toBeTruthy();
});

All HTTP Methods

test.describe('Product API', () => {
  test('GET - list products', async ({ request }) => {
    const response = await request.get('/api/products');
    expect(response.ok()).toBeTruthy();
  });

  test('POST - create product', async ({ request }) => {
    const response = await request.post('/api/products', {
      data: { name: 'New Product', price: 29.99 },
    });
    expect(response.status()).toBe(201);
  });

  test('PUT - update product', async ({ request }) => {
    const response = await request.put('/api/products/123', {
      data: { name: 'Updated Name', price: 39.99 },
    });
    expect(response.ok()).toBeTruthy();
  });

  test('PATCH - partial update', async ({ request }) => {
    const response = await request.patch('/api/products/123', {
      data: { price: 24.99 },
    });
    expect(response.ok()).toBeTruthy();
  });

  test('DELETE - remove product', async ({ request }) => {
    const response = await request.delete('/api/products/123');
    expect(response.status()).toBe(204);
  });
});

API-Based Authentication

The Login Performance Problem

// SLOW: Login via UI for every test
test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Login' }).click();
  await expect(page.getByText('Welcome')).toBeVisible();
  // This takes 3-5 seconds per test
});

Solution 1: API Login + Session Storage

// auth.setup.ts - Run once before all tests
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ request }) => {
  // Login via API
  const response = await request.post('https://api.shop.example/auth/login', {
    data: {
      email: 'user@example.com',
      password: 'password123',
    },
  });

  expect(response.ok()).toBeTruthy();

  // Save session state (cookies, localStorage)
  await request.storageState({ path: authFile });
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    // Setup project runs first
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    // Tests use the authenticated state
    {
      name: 'chromium',
      dependencies: ['setup'],
      use: {
        storageState: 'playwright/.auth/user.json',
      },
    },
  ],
});

Solution 2: Fixture-Based Auth

// fixtures/auth.fixture.ts
import { test as base, expect } from '@playwright/test';
import { APIRequestContext } from '@playwright/test';

type AuthFixture = {
  authenticatedRequest: APIRequestContext;
  authToken: string;
};

export const test = base.extend<AuthFixture>({
  authToken: async ({ request }, use) => {
    const response = await request.post('/api/auth/login', {
      data: {
        email: process.env.TEST_USER_EMAIL,
        password: process.env.TEST_USER_PASSWORD,
      },
    });

    const { token } = await response.json();
    await use(token);
  },

  authenticatedRequest: async ({ playwright, authToken }, use) => {
    const context = await playwright.request.newContext({
      extraHTTPHeaders: {
        'Authorization': `Bearer ${authToken}`,
      },
    });
    await use(context);
    await context.dispose();
  },
});

export { expect };
// tests/orders.spec.ts
import { test, expect } from '../fixtures/auth.fixture';

test('can view order history', async ({ authenticatedRequest }) => {
  const response = await authenticatedRequest.get('/api/orders');
  expect(response.ok()).toBeTruthy();

  const orders = await response.json();
  expect(orders.length).toBeGreaterThan(0);
});

Hybrid API + UI Testing

The most powerful pattern: combine fast API setup with UI verification.

Pattern 1: API Setup, UI Verification

test('verify order confirmation page', async ({ page, request }) => {
  // FAST: Create order via API
  const orderResponse = await request.post('/api/orders', {
    data: {
      items: [{ productId: 'SKU-001', quantity: 1 }],
      shippingAddress: {
        street: '123 Main St',
        city: 'San Francisco',
        state: 'CA',
        zip: '94102',
      },
      payment: {
        method: 'test_card',
        token: 'tok_visa',
      },
    },
  });

  const { orderId } = await orderResponse.json();

  // UI: Verify the confirmation page renders correctly
  await page.goto(`/orders/${orderId}/confirmation`);

  await expect(page.getByRole('heading', { name: 'Order Confirmed' })).toBeVisible();
  await expect(page.getByText(orderId)).toBeVisible();
  await expect(page.getByText('123 Main St')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Track Order' })).toBeVisible();
});

Pattern 2: API Cart Population

test('checkout with pre-populated cart', async ({ page, request }) => {
  // API: Add items to cart (skips product browsing UI)
  await request.post('/api/cart/items', {
    data: { productId: 'SKU-001', quantity: 2 },
  });

  await request.post('/api/cart/items', {
    data: { productId: 'SKU-002', quantity: 1 },
  });

  // UI: Start checkout with items already in cart
  await page.goto('/checkout');

  await expect(page.getByTestId('cart-item')).toHaveCount(2);
  await expect(page.getByTestId('cart-total')).not.toHaveText('$0.00');

  // Continue with UI checkout test...
  await page.getByLabel('First Name').fill('John');
  // ...
});

Pattern 3: API Cleanup After UI Test

test('place order and verify', async ({ page, request }) => {
  // UI: Complete checkout (the flow we're actually testing)
  await page.goto('/products/laptop');
  await page.getByRole('button', { name: 'Add to Cart' }).click();
  await page.goto('/checkout');
  // ... fill form, submit

  // Get order ID from confirmation page
  const orderIdElement = page.getByTestId('order-id');
  await expect(orderIdElement).toBeVisible();
  const orderId = await orderIdElement.textContent();

  // API: Clean up (cancel order, refund, etc.)
  await request.post(`/api/orders/${orderId}/cancel`, {
    data: { reason: 'Test cleanup' },
  });
});

Pattern 4: API Test Data Factory

// helpers/testDataFactory.ts
import { APIRequestContext } from '@playwright/test';

export class TestDataFactory {
  constructor(private request: APIRequestContext) {}

  async createTestUser(): Promise<{ email: string; password: string; id: string }> {
    const email = `test-${Date.now()}@example.com`;
    const password = 'TestPass123!';

    const response = await this.request.post('/api/users', {
      data: { email, password, name: 'Test User' },
    });

    const { id } = await response.json();
    return { email, password, id };
  }

  async createProduct(data?: Partial<ProductData>): Promise<string> {
    const response = await this.request.post('/api/products', {
      data: {
        name: `Test Product ${Date.now()}`,
        price: 29.99,
        stock: 100,
        ...data,
      },
    });

    const { productId } = await response.json();
    return productId;
  }

  async createOrder(items: OrderItem[]): Promise<string> {
    const response = await this.request.post('/api/orders', {
      data: {
        items,
        shippingAddress: this.defaultAddress,
        payment: { method: 'test', token: 'tok_test' },
      },
    });

    const { orderId } = await response.json();
    return orderId;
  }

  async cleanup(resource: string, id: string): Promise<void> {
    await this.request.delete(`/api/${resource}/${id}`);
  }

  private defaultAddress = {
    street: '123 Test St',
    city: 'Test City',
    state: 'CA',
    zip: '12345',
  };
}
// tests/order-flow.spec.ts
import { test, expect } from '@playwright/test';
import { TestDataFactory } from '../helpers/testDataFactory';

test('order flow with test data factory', async ({ page, request }) => {
  const factory = new TestDataFactory(request);

  // Create test data via API
  const productId = await factory.createProduct({ name: 'Test Widget', price: 19.99 });

  // Use in UI test
  await page.goto(`/products/${productId}`);
  await expect(page.getByText('Test Widget')).toBeVisible();
  await expect(page.getByText('$19.99')).toBeVisible();

  // Cleanup
  await factory.cleanup('products', productId);
});

API Response Validation

Schema Validation

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

test('product API returns valid schema', async ({ request }) => {
  const response = await request.get('/api/products/SKU-001');
  expect(response.ok()).toBeTruthy();

  const product = await response.json();

  // Validate required fields
  expect(product).toHaveProperty('id');
  expect(product).toHaveProperty('name');
  expect(product).toHaveProperty('price');
  expect(product).toHaveProperty('stock');

  // Validate types
  expect(typeof product.id).toBe('string');
  expect(typeof product.name).toBe('string');
  expect(typeof product.price).toBe('number');
  expect(typeof product.stock).toBe('number');

  // Validate values
  expect(product.price).toBeGreaterThan(0);
  expect(product.stock).toBeGreaterThanOrEqual(0);
});

Contract Testing

test.describe('Cart API Contract', () => {
  test('GET /api/cart returns cart structure', async ({ request }) => {
    const response = await request.get('/api/cart');
    const cart = await response.json();

    expect(cart).toMatchObject({
      items: expect.any(Array),
      subtotal: expect.any(Number),
      tax: expect.any(Number),
      total: expect.any(Number),
    });
  });

  test('POST /api/cart/items adds item correctly', async ({ request }) => {
    const response = await request.post('/api/cart/items', {
      data: { productId: 'SKU-001', quantity: 2 },
    });

    expect(response.status()).toBe(201);

    const result = await response.json();
    expect(result).toMatchObject({
      cartId: expect.any(String),
      itemCount: expect.any(Number),
    });
  });

  test('DELETE /api/cart/items/:id removes item', async ({ request }) => {
    // Add item first
    const addResponse = await request.post('/api/cart/items', {
      data: { productId: 'SKU-001', quantity: 1 },
    });
    const { itemId } = await addResponse.json();

    // Remove it
    const deleteResponse = await request.delete(`/api/cart/items/${itemId}`);
    expect(deleteResponse.status()).toBe(204);
  });
});

Performance Comparison

Approach100 TestsWith Parallel (4 workers)
Pure UI22 min6 min
Hybrid API+UI8 min2.5 min
Pure API45 sec15 sec

For e-commerce, hybrid is the sweet spot: fast setup via API, critical path verification via UI.

Key Takeaways

  • Use request fixture for API calls in the same test context
  • Authenticate via API and save session state (massive time savings)
  • Hybrid tests combine fast API setup with UI verification
  • Test data factories create clean, isolated test data
  • API cleanup keeps test environment clean
  • Contract testing validates API structure without UI

Next Lesson

API requests are great for real backends. But what about testing edge cases like payment failures or network errors? In the next lesson, we'll learn network mocking to simulate any scenario.