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.
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 GitHubTable of Contents
- Introduction
- Chapter 1: Unit Testing with Jest
- Chapter 2: Component Testing with React Testing Library
- Chapter 3: End-to-End Testing with Playwright
- Chapter 4: Playwright — Locators, Network Interception, Auth Flows & API Testing
- Chapter 5: Playwright — Visual Regression Testing, Codegen
- Chapter 6: E2E and Component Testing with Cypress
- Chapter 7: Playwright vs Cypress: A Deep Dive
- Chapter 8: Playwright vs Selenium: Why You Should Choose Playwright
- Chapter 9: Test-Driven Development in Web Apps
- Conclusion
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 Catalog — publicly viewable product listing
- 🔐Authentication — register and sign in with username/password
- 🛒Shopping Cart — add, update, and remove items (requires login)
- 📦Checkout — enter a shipping address and place an order
- 📋My Orders — view 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.
| Jest | RTL | Playwright | Cypress | Selenium | |
|---|---|---|---|---|---|
| What it tests | Pure functions, utilities, API logic | React component rendering & interaction | Full user journeys in a real browser | E2E journeys; component tests in real browser | Full user journeys in a real browser |
| Runs in | Node.js (no browser) | Node.js with jsdom | Real 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 | ✗ No | Partial | ✓ Yes | ✓ Yes | ✗ No — manual waits required |
| Catches CSS bugs | ✗ No | ✗ No | ✓ Yes | ✓ Yes | ✓ Yes |
Running the App
- 1Install dependencies:
npm install - 2Start the development server:
npm run devOpen http://localhost:3000 in your browser.
Tutorial Chapters
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.
Component Testing with React Testing Library
Render React components in jsdom, query by role and text, simulate user interactions, and handle async state updates.
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.
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.
Playwright — Visual Regression Testing, Codegen
Capture pixel-perfect screenshots with toHaveScreenshot, mask dynamic content, stabilise visual tests, and generate tests with Codegen.
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.
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.
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.
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.
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
--watchmode 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
| Package | Purpose |
|---|---|
jest | The test runner |
@types/jest | TypeScript type definitions for Jest globals |
jest-environment-jsdom | Browser-like DOM environment for component tests |
@testing-library/react | React component rendering and querying helpers |
@testing-library/jest-dom | Custom matchers: .toBeInTheDocument(), .toHaveValue(), … |
@testing-library/user-event | Realistic user-interaction simulation |
ts-node | Required 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 -- --coverageChapter 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
| Concept | Meaning |
|---|---|
render | Mounts the component into a jsdom DOM |
screen.getByRole | Finds an element by its ARIA role and accessible name |
screen.queryByText | Returns null instead of throwing when not found — useful for asserting absence |
waitFor | Retries the callback until it passes or times out — used to wait for async state updates |
userEvent.click | Simulates a realistic click with the full browser event sequence |
.toBeInTheDocument() | Asserts the element exists in the document (from @testing-library/jest-dom) |
getAllByText | Returns all elements matching the text — use when duplicates are expected |
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 installConfiguration: 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:
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-reportChapter 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.
| Locator | When 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);
});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
tests/ch05/__screenshots__/. The test fails on the first run — this is expected.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-snapshotsCodegen: 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:3000Tip: 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.
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 openE2E 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:componentChapter 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.
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.
Side-by-Side Comparison
| Feature | Playwright | Cypress |
|---|---|---|
| Browser support | Chromium, Firefox, WebKit ✓ | Chromium, Firefox; WebKit experimental |
| Async model | Native async/await | Custom command queue (chainable, not native async) |
| Multi-tab testing | ✓ Full support | ✗ Not supported |
| Component testing | Via experimental CT plugin | ✓ First-class, real browser with real CSS |
| API testing speed | ✓ Faster (no browser overhead) | Slower (routes through browser) |
| Parallelism | ✓ Built-in, free | Requires Cypress Cloud (paid) |
| Debugging | Trace Viewer, UI mode | Time-travel debugger, interactive GUI |
| iframes | ✓ Full support | Limited 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
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
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
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
| Feature | Playwright | Selenium |
|---|---|---|
| Protocol | CDP / WebSocket (persistent) | WebDriver over HTTP (round-trip per command) |
| Auto-waiting | ✓ Built-in for all actions | ✗ Manual WebDriverWait required |
| Setup | One command, browsers bundled | Multiple components + driver version management |
| Flakiness risk | Low — auto-waiting eliminates timing issues | High — manual waits are the primary source of flaky tests |
| Parallelism | ✓ Built-in, free | Requires Selenium Grid infrastructure |
| Browser support | Chromium, Firefox, WebKit | Broadest — includes IE and legacy browsers |
| Debugging | Trace Viewer, UI mode, Codegen | Dependent on external test runner |
| Language support | JS/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
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:
TDD Summary
| Aspect | Reality |
|---|---|
| What TDD is | Write one failing test, make it pass, refactor, repeat |
| Where it works best | Stable business logic, utility functions, bug fixes |
| Where it struggles | Rapidly changing UI, exploratory development, E2E tests |
| With Jest/RTL | Excellent for logic and hooks; harder for presentational components |
| With Playwright | Not practical for strict TDD; useful for ATDD/acceptance testing |
| In industry | Used selectively by most teams; rarely applied universally |
| The honest verdict | Valuable discipline worth learning; apply it where it adds value |
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:
storageState to avoid logging in for every test. Add visual regression tests for key pages.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:componentFurther Exploration
About the Author
Wayne Cheng is a software engineer and the founder of Audoir. He writes about software engineering, AI, and building products.