Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 094 / 100

Testing with a real backend vs mocks: a practical guide

Choose the right level of realism.

Expona Admin9 min read

Testing with a real backend vs mocks: a practical guide

Every browser automation engineer eventually faces the same fork in the road: should your end-to-end tests hit a real backend, or should you mock the API responses? The honest answer is that there's no one-size-fits-all solution, and the choice depends on your team's risk tolerance, infrastructure maturity, and how much flakiness you're willing to tolerate. In this guide, we'll explore the trade-offs between real backends, mocked APIs, hybrid approaches, and contract testing—so you can make an informed decision for your test suite.

Real backend—highest confidence, slower and more setup

Testing against a real backend gives you the highest confidence that your application actually works end-to-end. When your Playwright test exercises the full stack—from browser to API to database—you're catching integration bugs that no amount of mocking can reveal.

The setup typically involves spinning up a dedicated test environment with a real database, real services, and real infrastructure. Some teams use Docker Compose to orchestrate databases and service containers; others rely on cloud-based staging environments.

Here's a practical example of a real backend test:

typescript

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

test('user can complete checkout with real payment service', async ({ page }) => {
  // Point to real staging environment
  const API_BASE = process.env.STAGING_API_URL || 'https://api-staging.example.com';
  
  // Real user flow: login → add to cart → checkout
  await page.goto(`${API_BASE}/login`);
  await page.fill('[data-testid="email"]', 'testuser@example.com');
  await page.fill('[data-testid="password"]', process.env.TEST_PASSWORD);
  await page.click('[data-testid="login-btn"]');
  
  // Wait for real authentication
  await page.waitForURL(/\/dashboard/);
  
  // Add product
  await page.goto(`${API_BASE}/products/sku-12345`);
  await page.click('[data-testid="add-to-cart"]');
  
  // Checkout flow hits real Stripe test keys
  await page.goto(`${API_BASE}/checkout`);
  const orderTotal = await page.locator('[data-testid="order-total"]').textContent();
  expect(orderTotal).toContain('$99.99');
  
  // Verify order created in real database
  const response = await page.request.get(`${API_BASE}/api/orders/latest`, {
    headers: { 'Authorization': `Bearer ${process.env.TEST_TOKEN}` }
  });
  expect(response.status()).toBe(200);
});

The advantages are clear: you're testing what users actually experience. Payment flows work. Database constraints catch bugs. Third-party integrations behave as expected.

But real backend testing comes with real costs. Test execution is slower because every HTTP round-trip is genuine. Database state becomes a source of flakiness—if a previous test didn't clean up properly, the next test fails unpredictably. Network latency adds variability. And if your staging environment goes down, your entire test suite is blocked.

Most teams run real backend tests as a subset: smoke tests on critical user journeys, run less frequently (nightly or pre-release) rather than on every commit.

Mocked APIs—fast and deterministic, can drift from reality

Mocking API responses lets your tests run in milliseconds and gives you complete control over the responses your frontend receives. You decide what the API returns, when it returns it, and what errors it throws.

Playwright's route interception is perfect for this:

typescript

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

test('user sees error when profile fetch fails', async ({ page }) => {
  // Intercept all API calls to /api/profile
  await page.route('**/api/profile', route => {
    route.abort('failed');
  });
  
  await page.goto('http://localhost:3000/dashboard');
  
  // UI should display error gracefully
  const errorMsg = await page.locator('[data-testid="error-message"]').textContent();
  expect(errorMsg).toContain('Could not load profile');
});

test('user sees profile data when API succeeds', async ({ page }) => {
  // Mock successful response
  await page.route('**/api/profile', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 'user-123',
        name: 'Alice',
        email: 'alice@example.com',
        tier: 'premium'
      })
    });
  });
  
  await page.goto('http://localhost:3000/dashboard');
  
  const userName = await page.locator('[data-testid="user-name"]').textContent();
  expect(userName).toBe('Alice');
});

test('slow API response is handled gracefully', async ({ page }) => {
  // Simulate 5-second delay
  await page.route('**/api/profile', async route => {
    await new Promise(resolve => setTimeout(resolve, 5000));
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ id: 'user-123', name: 'Alice' })
    });
  });
  
  await page.goto('http://localhost:3000/dashboard');
  
  // Check that loading spinner shows while waiting
  const spinner = page.locator('[data-testid="loading"]');
  await expect(spinner).toBeVisible();
  
  // Eventually data appears
  await expect(page.locator('[data-testid="user-name"]')).toContainText('Alice');
});

Mocked APIs make your tests fast, reproducible, and focused on UI logic rather than backend correctness. They're ideal for component-level and integration tests that run on every commit.

The danger is mock drift: your mock doesn't match what the real API actually returns. The backend team adds a required field to the response, but your mock doesn't include it. Your test passes happily, but production fails. Or the API now returns an error in a situation your mock never tested.

Hybrid approach—real backend for smoke, mocks for edge cases

The hybrid strategy combines the best of both worlds: run a small set of critical user journeys against the real backend (smoke tests), and run the bulk of your tests with mocks (fast feedback).

Here's how it might look in practice:

typescript

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

// Use environment variable to decide: real or mock
const USE_REAL_BACKEND = process.env.E2E_REAL_BACKEND === 'true';
const API_BASE = process.env.API_URL || 'http://localhost:3000';

test.describe('checkout flow', () => {
  test('smoke: user completes real order (real backend only)', async ({ page }) => {
    test.skip(!USE_REAL_BACKEND, 'Real backend test');
    
    await page.goto(`${API_BASE}/login`);
    // ... full checkout flow against real staging environment
  });
  
  test('happy path: user sees success after checkout (mocked)', async ({ page }) => {
    // Mock payment processing
    await page.route('**/api/payments', route => {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ transactionId: 'txn-abc123', status: 'succeeded' })
      });
    });
    
    // Mock order creation
    await page.route('**/api/orders', route => {
      route.fulfill({
        status: 201,
        contentType: 'application/json',
        body: JSON.stringify({ orderId: 'ord-456', status: 'confirmed' })
      });
    });
    
    await page.goto(`${API_BASE}/checkout`);
    // ... test UI behavior with mocked responses
  });
  
  test('error handling: show error when payment is declined (mocked)', async ({ page }) => {
    await page.route('**/api/payments', route => {
      route.fulfill({
        status: 400,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Card was declined' })
      });
    });
    
    await page.goto(`${API_BASE}/checkout`);
    // ... verify error is displayed to user
  });
});

Run your smoke tests on schedule (nightly or before major releases) against real staging. Run the full test suite with mocks on every commit for fast feedback. This gives you high confidence without sacrificing development velocity.

Contract tests—keep mocks honest with API contracts

Contract testing is a third approach that deserves attention: define an explicit contract between frontend and backend (often a JSON schema or OpenAPI spec), then verify that both sides honor it.

The frontend tests against mocks that conform to the contract. The backend tests that it produces responses matching the contract. If the backend changes the contract, the backend tests fail before the frontend even knows about it.

A simple example using Playwright and a centralized schema:

typescript

// schema.ts - shared contract
export const USER_PROFILE_SCHEMA = {
  type: 'object',
  required: ['id', 'name', 'email'],
  properties: {
    id: { type: 'string' },
    name: { type: 'string' },
    email: { type: 'string', format: 'email' },
    tier: { type: 'string', enum: ['free', 'premium'] }
  }
};

// test.spec.ts - frontend respects the contract
import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
import { USER_PROFILE_SCHEMA } from './schema';

const ajv = new Ajv();
const validateProfile = ajv.compile(USER_PROFILE_SCHEMA);

test('profile data matches contract', async ({ page }) => {
  const mockProfile = {
    id: 'user-123',
    name: 'Alice',
    email: 'alice@example.com',
    tier: 'premium'
  };
  
  // Verify mock conforms to contract
  expect(validateProfile(mockProfile)).toBe(true);
  
  await page.route('**/api/profile', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(mockProfile)
    });
  });
  
  await page.goto('http://localhost:3000/dashboard');
  const userName = await page.locator('[data-testid="user-name"]').textContent();
  expect(userName).toBe('Alice');
});

Contract testing prevents mock drift by enforcing that your mocks always conform to the agreed-upon schema. If the backend team adds a new required field, they must update the contract, and your frontend mocks fail immediately.

Common mistakes

  • Mocking everything, everywhere. If you mock all network calls, you lose visibility into whether your API integration actually works. Reserve mocks for tests that focus on UI behavior, not integration testing.
  • No cleanup between real backend tests. When tests hit a real database, one test's data pollutes the next. Always implement test isolation: reset database state before each test, use unique test data (emails, IDs), or use transactions that roll back.
  • Mocks that don't match production behavior. If your mock never returns errors, your UI has no error handling. If your mock is always instant, you never test loading states. Make mocks realistic: add latency, return error responses, test edge cases.
  • Forgetting to version your contracts. APIs evolve. If your contract doesn't version, you can't tell if a change is backwards-compatible. Use semantic versioning for schemas.
  • Flaky real backend tests as a sign to add more mocks. Flakiness usually means test isolation is poor or the test environment is unstable. Fix the root cause rather than hiding behind mocks.

Key takeaways

  • Real backend tests give highest confidence but are slower, require infrastructure, and risk flakiness. Use them for smoke testing critical journeys on a schedule.
  • Mocked API tests are fast and deterministic but can drift from reality. Use them for the bulk of your test suite and rely on contract tests to catch drift.
  • Hybrid approaches—real backend for smoke tests, mocks for everything else—balance confidence and speed. Run them on different schedules.
  • Contract testing ensures mocks stay honest by enforcing schemas. Consider it a safety net between frontend and backend teams.
  • The best strategy depends on your team's risk tolerance, infrastructure maturity, and how often you ship. Start with mocks for speed, add smoke tests as your product grows, and introduce contract tests when backend and frontend teams operate independently.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles