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

When to write E2E vs unit tests: lessons from production

The testing pyramid in practice.

Expona Admin9 min read

Hook: Why Your Test Suite Might Be Slowing You Down

We've all seen it: a codebase where end-to-end tests take 45 minutes to run, unit tests outnumber features by 3:1, and developers skip the test suite entirely on deadline. The problem isn't testing itself—it's misalignment between what we test and how we test it. When to write E2E vs unit tests is one of the most consequential decisions we make as a team, yet we often treat it as an afterthought.

The Testing Pyramid Exists for a Reason

The testing pyramid has been around for years, but it's worth revisiting because most teams get it backwards. The shape matters: a wide base of unit tests, a middle layer of integration tests, and a small peak of E2E tests. Each layer has a purpose, and conflating them creates brittle suites that slow shipping without catching real bugs.

In production systems we've maintained, the teams that moved fastest were those that understood this hierarchy deeply. They didn't just follow the rule—they questioned it for each piece of code and made deliberate choices about where to invest testing effort.

Unit Tests for Logic: Where the Pyramid Starts

Unit tests are your first line of defense. They're fast, deterministic, and they catch bugs before they reach integration layers. A unit test exercises a single function or class in isolation, with mocked dependencies. This is where you test business logic, edge cases, and error handling.

Consider a pricing calculation function in an e-commerce system. This logic might be called from multiple places—a cart endpoint, an order confirmation task, a billing report generator. Testing it once at the unit level means you're protected everywhere it's used.

typescript
// pricing.ts
export function calculateDiscount(
  amount: number,
  tier: 'bronze' | 'silver' | 'gold'
): number {
  const rates: Record = {
    bronze: 0.05,
    silver: 0.1,
    gold: 0.15,
  };
  return amount * rates[tier];
}

// pricing.test.ts
describe('calculateDiscount', () => {
  it('applies bronze tier discount', () => {
    expect(calculateDiscount(100, 'bronze')).toBe(5);
  });

  it('applies gold tier discount', () => {
    expect(calculateDiscount(100, 'gold')).toBe(15);
  });

  it('handles zero amount', () => {
    expect(calculateDiscount(0, 'gold')).toBe(0);
  });
});

These tests run in milliseconds. They're reliable because they don't depend on databases, APIs, or browser state. When someone changes the discount logic three months from now, these tests catch it immediately. Unit tests are where you document expected behavior for the boring but critical stuff: calculations, transformations, validations.

The rule of thumb: if it has business logic, write a unit test. If it's a database query, a network call, or browser interaction, you're in the wrong layer.

Integration Tests for APIs: The Middle Layer

Integration tests sit between unit and E2E. They exercise multiple components together but in isolation from the full system. For backend teams, this often means testing an API endpoint with a real (or containerized) database, but no browser.

Let's extend the pricing example. The unit test checked the calculation. Now the integration test checks that the API correctly applies discounts to a user's cart:

typescript
// cart.integration.test.ts
describe('POST /api/cart/apply-discount', () => {
  let app: INestApplication;
  let db: Database;

  beforeEach(async () => {
    // Spin up test instance with real database
    app = await setupTestApp();
    db = app.get(Database);
    await db.clear();
  });

  it('applies discount to cart items', async () => {
    // Create test user and cart
    const user = await db.users.create({ id: 'user123', tier: 'gold' });
    const cart = await db.carts.create({
      userId: 'user123',
      items: [{ sku: 'ABC', quantity: 1, price: 100 }],
    });

    // Call the actual endpoint
    const response = await request(app.getHttpServer())
      .post('/api/cart/apply-discount')
      .send({ cartId: cart.id });

    expect(response.status).toBe(200);
    expect(response.body.total).toBe(85); // 100 - 15% gold discount
  });

  it('rejects discount for inactive users', async () => {
    const user = await db.users.create({
      id: 'user456',
      tier: 'gold',
      status: 'inactive',
    });
    const cart = await db.carts.create({
      userId: 'user456',
      items: [{ sku: 'ABC', quantity: 1, price: 100 }],
    });

    const response = await request(app.getHttpServer())
      .post('/api/cart/apply-discount')
      .send({ cartId: cart.id });

    expect(response.status).toBe(403);
  });

  afterEach(async () => {
    await app.close();
  });
});

Integration tests verify that components work together. They catch issues that unit tests miss: database constraints, API contract violations, middleware behavior. They're slower than unit tests (seconds instead of milliseconds) but faster than E2E, and they don't depend on UI automation.

In CRM and SaaS systems, integration tests are invaluable for workflows that touch multiple services. Testing that a user creation triggers the right emails, webhooks, and permission assignments belongs here, not in E2E.

E2E for Critical User Journeys: The Peak of the Pyramid

End-to-end tests with Playwright should be reserved for the paths that matter most to your business. If a user completes a purchase, books a ride, or sends money, that's critical. If they change their profile photo or toggle a theme—that's not.

The distinction is stark in production. A poorly written E2E suite for non-critical flows burns time and reduces confidence. A focused E2E suite for core journeys catches the bugs that your unit and integration tests miss: race conditions, timing issues, form submission failures, navigation problems.

Here's a realistic E2E test for an e-commerce checkout. Note what it tests and what it doesn't:

typescript
// checkout.e2e.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Checkout journey', () => {
  test('user completes purchase from cart to confirmation', async ({ page }) => {
    // Navigate to app
    await page.goto('https://app.example.com');

    // Log in (this might be mocked at the API level, or you might
    // have test credentials—use what's practical)
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'testpassword');
    await page.click('button:has-text("Sign in")');

    // Wait for logged-in state
    await page.waitForURL('**/dashboard**');

    // Add item to cart
    await page.goto('https://app.example.com/products/item-123');
    await page.click('button:has-text("Add to cart")');
    await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');

    // Go to checkout
    await page.goto('https://app.example.com/checkout');
    await expect(page).toHaveURL('**/checkout**');

    // Fill shipping address
    await page.fill('input[name="address"]', '123 Main St');
    await page.fill('input[name="city"]', 'San Francisco');
    await page.selectOption('select[name="state"]', 'CA');

    // Fill payment
    await page.fill('[data-testid="card-number"]', '4242424242424242');
    await page.fill('[data-testid="card-expiry"]', '12/25');
    await page.fill('[data-testid="card-cvc"]', '123');

    // Submit and verify
    await page.click('button:has-text("Complete purchase")');

    // Wait for confirmation page
    await page.waitForURL('**/confirmation**');
    await expect(page.locator('h1')).toContainText('Order confirmed');
    await expect(page.locator('[data-testid="order-id"]')).toBeVisible();
  });

  test('checkout shows error for invalid card', async ({ page }) => {
    await page.goto('https://app.example.com/checkout');
    await page.fill('[data-testid="card-number"]', '4000000000000002'); // Known test card that declines

    await page.click('button:has-text("Complete purchase")');
    await expect(page.locator('[data-testid="error-message"]')).toContainText(
      'Card declined'
    );
  });
});

Notice what we're testing: the complete user flow from login through purchase confirmation. We're not testing every field validation (that's integration or unit level), every button style, or every edge case of address parsing. We're testing the critical path.

In ride-hailing and travel systems, critical E2E journeys might be: booking a ride, cancelling it, confirming payment, or viewing past trips. In a trading platform, it's placing and confirming an order. In a multi-tenant CRM, it's inviting a user, assigning them a role, and verifying permissions. These are the paths that lose you money or customers when they break.

Keep E2E Suites Focused and Fast

A common mistake is treating E2E as a catch-all. Teams add test after test—for every feature, every edge case, every minor flow. The suite balloons to 200 tests that take 2 hours to run. Developers stop running them locally. CI becomes a bottleneck. New features slow down because the test suite is in the way.

The solution is ruthless prioritization. Ask for each E2E test:

  • Is this a money-making flow? Purchase, payment, subscription renewal, resource booking.
  • Is this a core user feature? Sign up, password reset, role assignment, data export.
  • Do earlier test layers not catch this? UI race conditions, form submission bugs, navigation issues, third-party integration quirks.

If the answer to all three is yes, add the E2E test. Otherwise, move it down the pyramid.

We've seen teams cut their E2E suites from 150 tests (90 minutes) down to 30 focused tests (12 minutes) without losing coverage. The reduction came from:

  • Moving form validation to integration tests
  • Testing error states at the API level instead of the UI
  • Using unit tests for business logic and computations
  • Keeping E2E for journeys that involve multiple pages, user interactions, and state changes

Practically, this means fewer Playwright tests, but more robust ones. Each test should take 3–10 seconds. If it's taking longer, it's probably testing too much.

Common Mistakes

Testing implementation details in unit tests: If your unit test breaks whenever you refactor, you're testing the wrong thing. Test behavior, not internals.

Skipping integration tests: Teams often jump straight from unit to E2E, skipping the middle layer. This leaves gaps: API contract issues, permission bugs, and database constraint violations slip through.

Using E2E for everything: Writing E2E tests for form validation, dropdown behavior, or utility functions makes the suite slow and fragile. Save E2E for journeys.

Not using test data properly: Seeding databases manually in tests, using real third-party APIs, or depending on environment state makes tests unreliable. Containerize databases, mock external services, and reset state between tests.

Ignoring flakiness: A test that passes 90% of the time is not a test—it's noise. If your E2E suite is flaky, fix it before adding more tests.

Key Takeaways

  • The testing pyramid exists for a reason: many unit tests, fewer integration tests, minimal E2E tests.
  • Unit tests catch logic bugs fast and cheap. Write them for calculations, transformations, and business rules.
  • Integration tests verify that components work together. Use them for API endpoints, database interactions, and multi-service workflows.
  • E2E tests with Playwright should cover critical user journeys only: sign up, checkout, booking, payment—the paths that matter to revenue and retention.
  • A focused E2E suite (30–50 tests, 10–20 minutes total) is more valuable than a bloated one (200+ tests, 2+ hours). Prioritize ruthlessly.
  • Use test doubles (mocks, stubs, containerized services) at the unit and integration levels to keep tests fast and reliable.
  • If a test is flaky, fix it before adding new tests. Unreliable tests undermine confidence in the entire suite.
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