Technology

The Complete Guide to Web Application Testing

A hands-on tutorial for learning webapp testing using Jest, React Testing Library, Playwright, and Cypress. The subject application is a simple full-stack online store (Next.js + SQLite) that serves as a realistic CRUD app to test against.

45 min read
Published

Complete Tutorial Code

Follow along with the complete source code for this web application testing tutorial. Includes nine chapters covering Jest, React Testing Library, Playwright, Cypress, visual regression testing, and Test-Driven Development — all tested against a real Next.js + SQLite online store.

View on GitHub

Introduction

This tutorial teaches web application testing from the ground up using a realistic subject application: a full-stack online store built with Next.js and SQLite. The store has a product catalog, user authentication, a shopping cart, checkout, and order history — exactly the kind of CRUD app you encounter in real projects.

You will work through nine chapters, each introducing a different testing tool or concept. By the end, you will have hands-on experience with Jest, React Testing Library, Playwright, and Cypress — and a clear mental model for when to use each one.

The Subject Application

The sample app is a simple online store with the following features:

  • 🛍️Product Catalogpublicly viewable product listing
  • 🔐Authenticationregister and sign in with username/password
  • 🛒Shopping Cartadd, update, and remove items (requires login)
  • 📦Checkoutenter a shipping address and place an order
  • 📋My Ordersview past orders, edit shipping address, or cancel

Note: The database runs in-memory (SQLite :memory:). All data resets on every server restart. The product catalog is automatically seeded with 8 sample products on startup.

Testing Tool Comparison

The tools covered in this tutorial serve different purposes and complement each other. Use this table to decide which tool is right for a given situation.

JestRTLPlaywrightCypressSelenium
What it testsPure functions, utilities, API logicReact component rendering & interactionFull user journeys in a real browserE2E journeys; component tests in real browserFull user journeys in a real browser
Runs inNode.js (no browser)Node.js with jsdomReal browser (Chromium, Firefox, WebKit)Real browser (Chromium, Firefox, WebKit)Real browser (Chrome, Firefox, Safari, Edge, IE)
Speed⚡ Very fast⚡ Fast🐢 Slow🐢 Slow for E2E; 🟡 Medium for component🐢 Slow
Auto-waiting✗ NoPartial✓ Yes✓ Yes✗ No — manual waits required
Catches CSS bugs✗ No✗ No✓ Yes✓ Yes✓ Yes

Running the App

  1. 1
    Install dependencies:
    npm install
  2. 2
    Start the development server:
    npm run dev

    Open http://localhost:3000 in your browser.

Tutorial Chapters

1

Unit Testing with Jest

Test pure functions, database access with mocking, and HTTP client functions. Learn describe/it/expect, jest.mock(), and global.fetch mocking.

JestUnit TestsMockingjest.fn()
2

Component Testing with React Testing Library

Render React components in jsdom, query by role and text, simulate user interactions, and handle async state updates.

RTLrenderscreenuserEventwaitFor
3

End-to-End Testing with Playwright

Write your first E2E test against a real browser. Configure playwright.config.ts, use the webServer option, and understand auto-waiting.

PlaywrightE2Epage.gotoauto-wait
4

Playwright — Locators, Network Interception, Auth Flows & API Testing

Accessible locators, network interception with page.route, session persistence with storageState, and direct API testing with the request fixture.

getByRolepage.routestorageStaterequest fixture
5

Playwright — Visual Regression Testing, Codegen

Capture pixel-perfect screenshots with toHaveScreenshot, mask dynamic content, stabilise visual tests, and generate tests with Codegen.

toHaveScreenshotVisual RegressionCodegenmask
6

E2E and Component Testing with Cypress

Write E2E tests with cy.visit/cy.get, mount React components in a real browser with cy.mount, and understand Cypress's auto-retry model.

Cypresscy.mountcy.interceptComponent Testing
7

Playwright vs Cypress: A Deep Dive

Architecture differences, async model, browser support, multi-tab testing, parallel execution, API testing speed, and when to choose each tool.

Architectureasync/awaitWebKitParallelism
8

Playwright vs Selenium: Why You Should Choose Playwright

WebDriver vs CDP architecture, auto-waiting vs manual waits, setup complexity, parallel execution, and the Trace Viewer advantage.

SeleniumWebDriverCDPTrace Viewer
9

Test-Driven Development in Web Apps

The Red-Green-Refactor loop, where TDD works well (utility functions, bug fixes), where it struggles (rapidly changing UI), and how it is actually used in industry.

TDDRed-Green-RefactorATDDBDD

Chapter 1: Unit Testing with Jest

Jest is a JavaScript testing framework maintained by Meta. It is the most widely used testing tool in the JavaScript/TypeScript ecosystem and works out of the box with Node.js projects, React apps, and Next.js applications. Jest provides everything you need in a single package: a test runner, assertion library, mocking utilities, and code coverage reports.

Why start with Jest?

Unit tests are the fastest and most focused kind of test. They verify a single function or module in isolation — no browser, no network, no database. This makes them:

Advantages

  • ✅ Easy to get started — works out of the box with almost no configuration
  • ✅ Everything in one package — test runner, assertions, mocking, and coverage built in
  • ✅ Great mocking support — replacing databases, HTTP calls, or any dependency is straightforward
  • ✅ Fast feedback — tests run quickly, and --watch mode re-runs only affected tests
  • ✅ Widely used — the default choice in React and Next.js projects

Disadvantages

  • ❌ No real browser — runs in Node.js; won't catch visual bugs or browser-specific issues
  • ❌ Not for end-to-end tests — can't open a browser, click through pages, or test a full user journey
  • ❌ Mocks can give false confidence — bugs that only appear with real dependencies can slip through

Project Setup

Installed packages

npm install --save-dev jest jest-environment-jsdom @testing-library/react \
@testing-library/dom @testing-library/jest-dom @testing-library/user-event \
ts-node @types/jest
PackagePurpose
jestThe test runner
@types/jestTypeScript type definitions for Jest globals
jest-environment-jsdomBrowser-like DOM environment for component tests
@testing-library/reactReact component rendering and querying helpers
@testing-library/jest-domCustom matchers: .toBeInTheDocument(), .toHaveValue(), …
@testing-library/user-eventRealistic user-interaction simulation
ts-nodeRequired by next/jest to load the Next.js configuration

Example 1 — Pure Function: lib/price.ts

calculateTotalPrice takes an array of items (each with a price and a quantity) and returns the grand total. It is a pure function — given the same input it always returns the same output and has no side effects — which makes it a perfect candidate for unit testing.

// lib/price.ts
export function calculateTotalPrice(items: PriceableItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// lib/price.test.ts
describe('calculateTotalPrice', () => {
  it('calculates the total for multiple items', () => {
    const items = [
      { price: 5, quantity: 2 },   // 10
      { price: 20, quantity: 1 },  // 20
      { price: 3, quantity: 4 },   // 12
    ];
    expect(calculateTotalPrice(items)).toBe(42);
  });

  it('handles decimal prices correctly', () => {
    const items = [
      { price: 1.5, quantity: 2 },  // 3.0
      { price: 0.99, quantity: 3 }, // 2.97
    ];
    expect(calculateTotalPrice(items)).toBeCloseTo(5.97);
  });
});

Example 2 — Database Access with Mocking: lib/products.ts

Functions that query a database are extremely common in web applications, but they present a challenge for unit testing. The solution is mocking: replace the real database with a lightweight fake that returns controlled data. Jest makes this straightforward with jest.mock().

// lib/products.test.ts
jest.mock('./db');  // Replace the real SQLite database with a mock

import { getDb } from './db';
import { getProductById } from './products';

const mockDb = getDb as jest.MockedFunction<typeof getDb>;

describe('getProductById', () => {
  it('returns the matching product when found', () => {
    const fakeProduct = { id: 1, name: 'Headphones', price: 79.99, ... };
    mockDb.mockReturnValue({
      prepare: jest.fn().mockReturnValue({
        get: jest.fn().mockReturnValue(fakeProduct),
      }),
    } as any);

    const result = getProductById(1);
    expect(result).toEqual(fakeProduct);
  });
});

Example 3 — Mocking fetch: lib/cartApi.ts

fetch is a global — it lives on the globalThis object. We create a jest.fn() and assign it directly to global.fetch. Every call to fetch(…) inside the module under test now goes through our mock.

// lib/cartApi.test.ts
const mockFetch = jest.fn();
global.fetch = mockFetch;

function makeResponse(body: unknown, status = 200) {
  return {
    ok: status >= 200 && status < 300,
    json: jest.fn().mockResolvedValue(body),
  } as unknown as Response;
}

beforeEach(() => { mockFetch.mockReset(); });

describe('addToCart', () => {
  it('sends a POST request with the correct body', async () => {
    mockFetch.mockResolvedValue(makeResponse({ success: true }));

    await addToCart(42, 3);

    expect(mockFetch).toHaveBeenCalledWith('/api/cart', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ productId: 42, quantity: 3 }),
    });
  });
});

Running the Tests

npm run test:jest

# Watch mode during development
npm run test:jest -- --watch

# Run a specific test file
npm run test:jest -- lib/price.test.ts

# Generate a coverage report
npm run test:jest -- --coverage
describe / it / expectjest.mock()global.fetchmockResolvedValuetoBeCloseTotoHaveBeenCalledWith

Chapter 2: Component Testing with React Testing Library

React Testing Library (RTL) is a lightweight testing utility built on top of the DOM Testing Library. It provides helper functions for rendering React components and querying the resulting DOM — the same way a real user would interact with the page. Its guiding principle: “The more your tests resemble the way your software is used, the more confidence they can give you.”

Advantages

  • ✅ Tests resemble real usage — queries like getByRole('button', { name: 'Add to Cart' }) mirror how users find elements
  • ✅ Discourages implementation-detail testing — keeps tests resilient to refactors
  • ✅ Works with Jest — plugs into Jest and reuses all the matchers and mocking you already know
  • ✅ Accessible by default — preferring role-based queries nudges you toward accessible markup

Disadvantages

  • ❌ jsdom is not a real browser — CSS is not applied, layout is not computed
  • ❌ Async state updates need care — React state changes must be awaited with waitFor
  • ❌ Not for end-to-end tests — cannot navigate between pages or test real network calls

Example — Render Test: app/page.tsx

The home page fetches products and auth status in a useEffect, shows a loading spinner, then renders a product catalogue. Because the component makes network calls and uses Next.js routing, both dependencies must be mocked before the component can be rendered in a test.

// app/page.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import HomePage from './page';

// Mock Next.js router — useRouter() would throw without this
jest.mock('next/navigation', () => ({
  useRouter: () => ({ push: jest.fn() }),
  usePathname: () => '/',
}));

// Replace global fetch with a Jest mock
const mockFetch = jest.fn();
global.fetch = mockFetch;

function makeResponse(body: unknown, status = 200) {
  return {
    ok: status >= 200 && status < 300,
    json: jest.fn().mockResolvedValue(body),
  } as unknown as Response;
}

beforeEach(() => {
  mockFetch.mockReset();
  mockFetch
    .mockResolvedValueOnce(makeResponse([]))                     // /api/products
    .mockResolvedValueOnce(makeResponse({ isLoggedIn: false })); // /api/auth/me
});

it('renders the product catalogue heading after data loads', async () => {
  render(<HomePage />);

  // Wait for the loading spinner to disappear
  await waitFor(() =>
    expect(screen.queryByText('Loading products...')).not.toBeInTheDocument()
  );

  expect(screen.getByRole('heading', { name: 'Product Catalog' })).toBeInTheDocument();
});

Example — Interaction Test: Add to Cart

userEvent.click simulates a realistic click with the full browser event sequence. expect.objectContaining matches any object that has at least the listed keys — keeping the assertion focused on what matters without being brittle about the full options object.

it('clicking "Add to Cart" calls the cart API and shows a success message', async () => {
  mockFetch.mockReset();
  mockFetch
    .mockResolvedValueOnce(makeResponse([product]))                                // /api/products
    .mockResolvedValueOnce(makeResponse({ isLoggedIn: true, username: 'alice' })) // /api/auth/me
    .mockResolvedValueOnce(makeResponse({ cartCount: 1 }));                       // POST /api/cart

  render(<HomePage />);

  await waitFor(() =>
    expect(screen.queryByText('Loading products...')).not.toBeInTheDocument()
  );

  await userEvent.click(screen.getByRole('button', { name: 'Add to Cart' }));

  expect(mockFetch).toHaveBeenCalledWith('/api/cart', expect.objectContaining({
    method: 'POST',
    body: JSON.stringify({ productId: 42, quantity: 1 }),
  }));

  await waitFor(() =>
    expect(screen.getByText('Added to cart!')).toBeInTheDocument()
  );
});

Key RTL Concepts

ConceptMeaning
renderMounts the component into a jsdom DOM
screen.getByRoleFinds an element by its ARIA role and accessible name
screen.queryByTextReturns null instead of throwing when not found — useful for asserting absence
waitForRetries the callback until it passes or times out — used to wait for async state updates
userEvent.clickSimulates a realistic click with the full browser event sequence
.toBeInTheDocument()Asserts the element exists in the document (from @testing-library/jest-dom)
getAllByTextReturns all elements matching the text — use when duplicates are expected
renderscreenwaitForuserEventgetByRolejest.mockCartProvider

Chapter 3: End-to-End Testing with Playwright

Playwright is a browser automation library developed by Microsoft. It controls real browsers — Chromium, Firefox, and WebKit — from Node.js. Unlike Jest and RTL, Playwright tests run against a fully built and running application, making them the closest thing to testing what a real user experiences.

Installation

npm init playwright@latest

# Or install manually
npm install --save-dev @playwright/test
npx playwright install

Configuration: playwright.config.ts

The webServer option is the key to making Playwright self-contained. It starts the Next.js server before the tests run and shuts it down afterwards — no manual server management needed.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: {
    command: 'npm run start',   // start the production build
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Your First Test: Product Catalog

// tests/ch03/products.spec.ts
import { test, expect } from '@playwright/test';

test('product catalog loads and displays products', async ({ page }) => {
  await page.goto('/');

  // Wait for the heading to appear
  await expect(page.getByRole('heading', { name: 'Product Catalog' })).toBeVisible();

  // At least one product card should be visible
  const productCards = page.locator('[data-testid="product-card"]');
  await expect(productCards.first()).toBeVisible();
});

Auto-Waiting

Playwright's most important feature is auto-waiting. Every action and assertion automatically waits for the element to be:

AttachedPresent in the DOM
VisibleNot hidden by CSS
StableNot animating
EnabledNot disabled
EditableNot read-only (for inputs)
Receives eventsNot obscured by another element

Running Playwright Tests

# Build the app first
npm run build

# Run all Playwright tests
npm run test:playwright

# Open the interactive Playwright UI
npm run test:playwright -- --ui

# Run a specific test file
npx playwright test tests/ch03/products.spec.ts

# Show the HTML report
npx playwright show-report
page.gotoexpect(locator).toBeVisible()webServerauto-waitingbaseURLdata-testid

Chapter 4: Playwright — Locators, Network Interception, Auth Flows & API Testing

This chapter builds on the basics from Chapter 3 by introducing the locators, assertions, and patterns you will use in real test suites. Each concept is demonstrated with a focused test from tests/ch04/auth.spec.ts or tests/ch04/products.spec.ts.

Accessible Locators

Playwright recommends locating elements the way a user would — by their visible text, role, or label. This makes tests resilient to markup changes and encourages accessible HTML.

LocatorWhen to use
getByRole('button', { name: 'Add to Cart' })Buttons, links, headings, inputs — the most resilient locator
getByLabel('Username')Form inputs associated with a <label>
getByPlaceholder('Search…')Inputs with a placeholder attribute
getByText('Sign in')Any element containing the text
getByTestId('product-card')Elements with data-testid — last resort when no semantic locator works

Network Interception with page.route

page.route intercepts network requests matching a URL pattern. You can abort, modify, or mock the response — without changing the application code.

test('shows error message when products API fails', async ({ page }) => {
  // Intercept the products API and return a 500 error
  await page.route('/api/products', (route) =>
    route.fulfill({ status: 500, body: 'Internal Server Error' })
  );

  await page.goto('/');

  await expect(page.getByText('Failed to load products')).toBeVisible();
});

test('shows empty state when no products exist', async ({ page }) => {
  await page.route('/api/products', (route) =>
    route.fulfill({ status: 200, json: [] })
  );

  await page.goto('/');

  await expect(page.getByText('No products available')).toBeVisible();
});

Auth Flows with storageState

Logging in through the UI for every test is slow and fragile. Playwright's storageState lets you save the browser's cookies and localStorage after a single login, then restore that state for subsequent tests — skipping the login UI entirely.

// tests/auth.setup.ts — run once before the test suite
import { test as setup } from '@playwright/test';

setup('authenticate as alice', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Username').fill('alice');
  await page.getByLabel('Password').fill('Password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/');

  // Save the authenticated session to disk
  await page.context().storageState({ path: 'playwright/.auth/alice.json' });
});

// playwright.config.ts — use the saved state in tests
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /auth.setup.ts/ },
    {
      name: 'authenticated',
      use: { storageState: 'playwright/.auth/alice.json' },
      dependencies: ['setup'],
    },
  ],
});

Direct API Testing with the request Fixture

Playwright's request fixture sends HTTP requests directly — no browser, no page navigation. This is ideal for testing API routes in isolation, or for setting up test data before a UI test.

test('POST /api/cart adds an item', async ({ request }) => {
  // First, log in via the API
  await request.post('/api/auth/login', {
    data: { username: 'alice', password: 'Password123' },
  });

  // Add an item to the cart
  const response = await request.post('/api/cart', {
    data: { productId: 1, quantity: 2 },
  });

  expect(response.ok()).toBe(true);
  const body = await response.json();
  expect(body.cartCount).toBeGreaterThan(0);
});
getByRolegetByLabelpage.routeroute.fulfillstorageStaterequest fixtureauth setup

Chapter 5: Playwright — Visual Regression Testing, Codegen

Visual regression testing captures screenshots of your application and compares them pixel-by-pixel against stored reference images. Any unintended change to the layout, colours, or typography causes the test to fail — an automatic safety net against accidental UI regressions.

How toHaveScreenshot Works

1
First run: No baseline exists. Playwright captures a screenshot and saves it as the baseline in tests/ch05/__screenshots__/. The test fails on the first run — this is expected.
2
Subsequent runs: Playwright captures a new screenshot and compares it pixel-by-pixel against the baseline. If they match (within the configured threshold), the test passes.
3
On failure: Playwright saves three images — the baseline, the actual screenshot, and a diff image highlighting the differences.

Basic Visual Test

// tests/ch05/visual.spec.ts
import { test, expect } from '@playwright/test';

test('product catalog matches snapshot', async ({ page }) => {
  await page.goto('/');

  // Wait for products to load before taking the screenshot
  await page.waitForSelector('[data-testid="product-card"]');

  await expect(page).toHaveScreenshot('product-catalog.png');
});

Masking Dynamic Content

Dynamic content — timestamps, user-specific data, ads — causes false positives in visual tests. Use the mask option to exclude specific elements from the comparison.

test('product catalog with masked dynamic content', async ({ page }) => {
  await page.goto('/');
  await page.waitForSelector('[data-testid="product-card"]');

  await expect(page).toHaveScreenshot('product-catalog-masked.png', {
    mask: [
      page.locator('[data-testid="cart-count"]'),  // mask the cart badge
      page.locator('[data-testid="timestamp"]'),   // mask any timestamps
    ],
    maxDiffPixelRatio: 0.02,  // allow up to 2% pixel difference
  });
});

Updating Baselines

# Update all baselines (after intentional UI changes)
npx playwright test --update-snapshots

# Update baselines for a specific test file
npx playwright test tests/ch05/visual.spec.ts --update-snapshots

Codegen: Generate Tests by Recording

Playwright Codegen records your browser interactions and generates the corresponding test code. It is a fast way to bootstrap a test for a complex user flow.

# Open Codegen — interact with the browser and watch the code appear
npx playwright codegen http://localhost:3000

# Record to a specific output file
npx playwright codegen --output tests/recorded.spec.ts http://localhost:3000

# Record with a saved auth state
npx playwright codegen --load-storage=playwright/.auth/alice.json http://localhost:3000

Tip: Codegen is a starting point, not a finished test. The generated code uses CSS selectors and text locators that may be brittle. After recording, review the output and replace fragile locators with getByRole or getByLabel equivalents.

toHaveScreenshotVisual RegressionmaskmaxDiffPixelRatio--update-snapshotsCodegen

Chapter 6: E2E and Component Testing with Cypress

Cypress is an end-to-end testing framework that runs directly inside the browser. Unlike Playwright, which controls the browser from the outside via CDP, Cypress executes in the same JavaScript runtime as your application — giving it unique access to the DOM, network, and application state.

Installation

npm install --save-dev cypress

# Open the Cypress GUI (first run creates cypress.config.ts)
npx cypress open

E2E Test: Login Flow

// cypress/e2e/auth.cy.ts
describe('Authentication', () => {
  it('allows a user to register and log in', () => {
    cy.visit('/register');

    cy.get('[data-testid="username"]').type('alice');
    cy.get('[data-testid="password"]').type('Password123');
    cy.get('[data-testid="register-button"]').click();

    // Should redirect to home after registration
    cy.url().should('include', '/');
    cy.contains('Welcome, alice').should('be.visible');
  });

  it('shows an error for invalid credentials', () => {
    cy.visit('/login');

    cy.get('[data-testid="username"]').type('nobody');
    cy.get('[data-testid="password"]').type('wrongpassword');
    cy.get('[data-testid="login-button"]').click();

    cy.contains('Invalid username or password').should('be.visible');
  });
});

Network Interception with cy.intercept

it('shows error state when products API fails', () => {
  cy.intercept('GET', '/api/products', {
    statusCode: 500,
    body: { error: 'Internal Server Error' },
  }).as('getProducts');

  cy.visit('/');
  cy.wait('@getProducts');

  cy.contains('Failed to load products').should('be.visible');
});

Component Testing with cy.mount

Cypress Component Testing mounts React components directly in a real browser — no jsdom, no simulated DOM. CSS is applied, layout is computed, and the component behaves exactly as it would in production. This is Cypress's unique advantage over Jest + RTL.

// cypress/component/ProductCard.cy.tsx
import ProductCard from '../../components/ProductCard';

describe('ProductCard', () => {
  const product = {
    id: 1,
    name: 'Wireless Headphones',
    price: 79.99,
    stock: 10,
    image_url: '/headphones.jpg',
  };

  it('renders the product name and price', () => {
    cy.mount(<ProductCard product={product} onAddToCart={cy.stub()} />);

    cy.contains('Wireless Headphones').should('be.visible');
    cy.contains('$79.99').should('be.visible');
  });

  it('calls onAddToCart when the button is clicked', () => {
    const onAddToCart = cy.stub().as('addToCart');
    cy.mount(<ProductCard product={product} onAddToCart={onAddToCart} />);

    cy.get('[data-testid="add-to-cart"]').click();
    cy.get('@addToCart').should('have.been.calledOnceWith', 1);
  });
});

Running Cypress Tests

# E2E tests require the app to be running first
npm run build && npm run start

# Open the interactive Cypress GUI
npm run cypress:open

# Run E2E tests headlessly
npm run test:cypress:e2e

# Component tests do NOT need a running server
# Interactive GUI (select "Component Testing")
npm run cypress:open

# Headless component tests
npm run test:cypress:component
cy.visitcy.getcy.interceptcy.mountcy.stubcy.waitComponent Testing

Chapter 7: Playwright vs Cypress: A Deep Dive

The Verdict Up Front: Both are excellent tools. Choose Playwright if you need WebKit/Safari coverage, multi-tab testing, or the fastest possible API test execution. Choose Cypress if you prefer its interactive GUI, want component tests in a real browser with real CSS, or your team is already invested in the Cypress ecosystem.

Architecture Differences

Playwright

Controls the browser from outside via the Chrome DevTools Protocol (CDP) or WebSocket. Tests run in Node.js; the browser is a separate process.

Node.js (test) → CDP/WebSocket → Browser

Cypress

Runs inside the browser in the same JavaScript runtime as your application. The test runner is a browser extension that injects into the page.

Browser (test + app in same runtime)

Side-by-Side Comparison

FeaturePlaywrightCypress
Browser supportChromium, Firefox, WebKit ✓Chromium, Firefox; WebKit experimental
Async modelNative async/awaitCustom command queue (chainable, not native async)
Multi-tab testing✓ Full support✗ Not supported
Component testingVia experimental CT plugin✓ First-class, real browser with real CSS
API testing speed✓ Faster (no browser overhead)Slower (routes through browser)
Parallelism✓ Built-in, freeRequires Cypress Cloud (paid)
DebuggingTrace Viewer, UI modeTime-travel debugger, interactive GUI
iframes✓ Full supportLimited support

When to Choose Each

Choose Playwright when:

  • ✅ You need Safari/WebKit coverage
  • ✅ Your tests involve multiple browser tabs or windows
  • ✅ You want the fastest API testing (no browser overhead)
  • ✅ You need built-in parallelism without paying for a cloud service
  • ✅ You want the Trace Viewer for CI failure diagnosis
  • ✅ You are starting a new project

Choose Cypress when:

  • ✅ You prefer the interactive time-travel debugger
  • ✅ You want component tests in a real browser with real CSS applied
  • ✅ Your team is already invested in the Cypress ecosystem
  • ✅ You want a more approachable learning curve for non-engineers
  • ✅ You need to test components that depend on real browser layout
CDPasync/awaitWebKitMulti-tabParallelismTrace ViewerTime-travel debugger

Chapter 8: Playwright vs Selenium: Why You Should Choose Playwright

The Verdict Up Front: For new projects, choose Playwright. For existing Selenium suites, migrate only when Selenium is genuinely limiting you — particularly around flakiness, speed, or driver management overhead.

Architecture: WebDriver vs CDP

Selenium — WebDriver Protocol

Test → HTTP request → WebDriver server → Browser

Each command is an HTTP round-trip. The WebDriver server translates commands into browser-specific actions. This adds latency to every interaction.

Playwright — CDP / WebSocket

Test → WebSocket (persistent) → Browser

A single persistent WebSocket connection. Commands are sent as JSON messages with no HTTP overhead. This is why Playwright is significantly faster than Selenium.

The Auto-Waiting Difference

The single biggest practical difference between Playwright and Selenium is auto-waiting. Selenium requires manual wait code for every interaction. Playwright waits automatically.

Playwright — 10 lines

test('login flow', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Username').fill('alice');
  await page.getByLabel('Password').fill('Password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL('/');
  await expect(page.getByText('Welcome')).toBeVisible();
});

Selenium — 40+ lines

@Test
void loginFlow() {
  driver.get("http://localhost:3000/login");
  WebDriverWait wait = new WebDriverWait(driver, 10);

  wait.until(ExpectedConditions
    .visibilityOfElementLocated(
      By.cssSelector("[data-testid='username']")
    )).sendKeys("alice");

  driver.findElement(
    By.cssSelector("[data-testid='password']")
  ).sendKeys("Password123");

  driver.findElement(
    By.cssSelector("[data-testid='login-button']")
  ).click();

  wait.until(ExpectedConditions
    .urlContains("dashboard"));
  assertTrue(driver.getCurrentUrl()
    .contains("dashboard"));
}

Side-by-Side Comparison

FeaturePlaywrightSelenium
ProtocolCDP / WebSocket (persistent)WebDriver over HTTP (round-trip per command)
Auto-waiting✓ Built-in for all actions✗ Manual WebDriverWait required
SetupOne command, browsers bundledMultiple components + driver version management
Flakiness riskLow — auto-waiting eliminates timing issuesHigh — manual waits are the primary source of flaky tests
Parallelism✓ Built-in, freeRequires Selenium Grid infrastructure
Browser supportChromium, Firefox, WebKitBroadest — includes IE and legacy browsers
DebuggingTrace Viewer, UI mode, CodegenDependent on external test runner
Language supportJS/TS, Python, Java, C#JS, Java, Python, Ruby, PHP, C#, Perl

When Selenium Still Makes Sense

  • 🏛️ You must support Internet Explorer or legacy browsers that Playwright does not support
  • 🔄 You have a large, mature existing Selenium suite — migration cost outweighs the benefits
  • 💻 Your team uses Ruby, PHP, or Perl — Playwright does not have official bindings for these languages
  • 🏢 Your organization has invested in Selenium Grid infrastructure for parallelism
WebDriverCDPAuto-waitingFlakinessSelenium GridTrace ViewerDriver management

Chapter 9: Test-Driven Development in Web Apps

Test-Driven Development (TDD) is a software development practice where you write a failing test before you write the code that makes it pass. The cycle is short and deliberate — and it is one of the most debated practices in web development.

The Red-Green-Refactor Loop

🔴

Red

Write a test for a small piece of behavior that does not exist yet. Run it. It fails. That failure is expected and intentional.

🟢

Green

Write the minimum amount of code needed to make that test pass. Nothing more.

🔵

Refactor

Clean up the code you just wrote — remove duplication, improve naming, simplify logic — while keeping all tests green.

The Case For TDD

  • Forces you to think before you code — writing a test first requires you to define the inputs, expected outputs, and edge cases before writing a single line of implementation
  • Tests document the behavior — a well-written TDD test suite reads like a specification; new developers can read the tests to understand what the code is supposed to do
  • You only write what you need — the discipline of writing the minimum code to pass a test discourages over-engineering
  • Refactoring becomes safe — once you have a comprehensive test suite, you can restructure your code with confidence
  • Replaces manual browser testing during development — running a test suite takes milliseconds; manually navigating to a page and checking the result takes minutes

The Case Against TDD (Especially on the Frontend)

  • Requirements change constantly on the frontend — a non-technical stakeholder says “can we move that button?” and suddenly your tests are invalid
  • TDD requires knowing what you are building — much of web development is exploratory; writing tests for code you have not designed yet is genuinely difficult
  • The upfront investment is real — writing tests before code takes longer in the short term; for teams under deadline pressure, this cost is not trivial
  • Not all code is equally testable — a React component that renders differently based on user interactions, network state, and browser APIs requires significant mocking infrastructure

Where TDD Works Best in Web Apps

Apply TDD to:

  • ✅ Pure utility functions (price calculations, data transformations, validators)
  • ✅ Custom hooks with complex logic
  • ✅ API route handlers and service layer code
  • Bug fixes — always write a failing test first
  • ✅ Any code that will be reused across the application

Be pragmatic about:

  • 🟡 Presentational React components — write tests after the fact to lock in behavior
  • 🟡 Complex UI interactions — integration tests with RTL are valuable, but strict TDD is difficult
  • 🟡 E2E tests with Playwright — use them as a regression safety net for critical flows, not as a TDD driver

How TDD Is Actually Used in Industry

The honest answer: TDD is used selectively, not universally. Very few teams practice strict TDD across their entire codebase. But many teams practice something that resembles TDD in specific contexts:

Backend developers are more likely to use TDD than frontend developers. Business logic, API endpoints, and data transformations are stable enough that writing tests first is practical and valuable.
Frontend developers tend to write tests after the fact, or alongside the code, rather than strictly before. The most common pattern: build the feature, then write tests to lock in the behavior and prevent regressions.
Bug fixes are the most universally accepted TDD use case. Writing a failing test before fixing a bug is a practice that even teams that do not otherwise do TDD will often adopt.

TDD Summary

AspectReality
What TDD isWrite one failing test, make it pass, refactor, repeat
Where it works bestStable business logic, utility functions, bug fixes
Where it strugglesRapidly changing UI, exploratory development, E2E tests
With Jest/RTLExcellent for logic and hooks; harder for presentational components
With PlaywrightNot practical for strict TDD; useful for ATDD/acceptance testing
In industryUsed selectively by most teams; rarely applied universally
The honest verdictValuable discipline worth learning; apply it where it adds value
Red-Green-RefactorATDDBDDBug-fix TDDRegression safety net

Conclusion

You have now worked through the full spectrum of web application testing — from fast, isolated unit tests with Jest to pixel-perfect visual regression tests with Playwright, and from component tests in a real browser with Cypress to the principles of Test-Driven Development.

The key insight from this tutorial is that no single tool does everything. Each layer of the testing pyramid serves a different purpose, and the best testing strategy uses all of them together:

1
Jest — Write unit tests for every pure function, utility, and API handler. These are your fastest feedback loop. Run them on every save.
2
React Testing Library — Write component tests for the interactive parts of your UI. Focus on behavior, not implementation details. Mock the network and router.
3
Playwright — Write E2E tests for your critical user flows: login, checkout, account creation. Use storageState to avoid logging in for every test. Add visual regression tests for key pages.
4
Cypress — Consider Cypress component tests when you need to verify that a component looks and behaves correctly with real CSS applied — something jsdom cannot do.
5
TDD — Apply the Red-Green-Refactor loop selectively: always for bug fixes, often for utility functions and hooks, pragmatically for everything else.

Running All the Tests

# Unit & Component Tests (Jest + RTL)
npm run test:jest

# Watch mode during development
npm run test:jest -- --watch

# End-to-End Tests (Playwright) — build first
npm run build
npm run test:playwright

# Playwright interactive UI
npm run test:playwright -- --ui

# Cypress E2E — app must be running
npm run build && npm run start
npm run test:cypress:e2e

# Cypress Component Tests — no server needed
npm run test:cypress:component

Further Exploration

About the Author

Wayne Cheng is a software engineer and the founder of Audoir. He writes about software engineering, AI, and building products.