Frontend Testing Tools: Playwright, Cypress, and Testing Library
Frontend Testing Tools: Playwright, Cypress, and Testing Library
Frontend testing has matured significantly. The tools are better, the patterns are clearer, and the excuses for not testing are fewer. But choosing between Playwright, Cypress, and Testing Library -- and knowing how they fit together -- still trips people up.
This guide covers the practical decisions: which tool for which job, how to structure your testing strategy, and how to keep tests from becoming a maintenance burden.
The Frontend Testing Pyramid
The testing pyramid applies to frontend code, but with different layers than backend:
/ Visual \ Few, expensive, catch visual regressions
/ Regression \
/──────────────\
/ End-to-End \ Moderate, test critical user flows
/────────────────────\
/ Integration/Component \ Many, test component behavior
/────────────────────────────\
/ Unit Tests \ Many, test logic functions
/────────────────────────────────\
Unit tests cover pure functions, utilities, state management logic, and data transformations. Use Vitest or Bun test.
Component/integration tests render components, simulate user interactions, and verify behavior. Use Testing Library with Vitest.
End-to-end tests run in real browsers and test complete user flows. Use Playwright or Cypress.
Visual regression tests capture screenshots and detect unintended visual changes. Use Playwright's screenshot comparison or dedicated tools like Chromatic.
Playwright: The Technical Leader
Playwright is Microsoft's browser automation library. It supports Chromium, Firefox, and WebKit, runs headless by default, and has the most capable API of any browser testing tool.
Setup
# Install
npm init playwright@latest
# Or add to existing project
npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 7"] } },
],
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
Writing Tests
import { test, expect } from "@playwright/test";
test("user can complete checkout flow", async ({ page }) => {
// Navigate to product page
await page.goto("/products/widget-pro");
// Add to cart
await page.getByRole("button", { name: "Add to Cart" }).click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
// Go to checkout
await page.getByRole("link", { name: "Checkout" }).click();
// Fill shipping info
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Address").fill("123 Test St");
await page.getByLabel("City").fill("Portland");
await page.getByLabel("State").selectOption("OR");
await page.getByLabel("ZIP").fill("97201");
// Submit order
await page.getByRole("button", { name: "Place Order" }).click();
// Verify confirmation
await expect(page.getByRole("heading", { name: "Order Confirmed" })).toBeVisible();
await expect(page.getByText("[email protected]")).toBeVisible();
});
Playwright's Killer Features
Auto-waiting. Playwright automatically waits for elements to be visible, enabled, and stable before interacting. No explicit waits or sleep calls:
// Playwright waits for the button to be visible and enabled
await page.getByRole("button", { name: "Submit" }).click();
// No need for: await page.waitForSelector("button")
Trace viewer. When a test fails, Playwright captures a trace -- a recording of every action, network request, and DOM snapshot. You can step through the trace in a browser:
npx playwright show-trace trace.zip
Codegen. Generate test code by interacting with your app:
npx playwright codegen http://localhost:3000
Network interception. Mock API responses without changing your application code:
await page.route("**/api/products", (route) => {
route.fulfill({
status: 200,
body: JSON.stringify([{ id: 1, name: "Test Product", price: 29.99 }]),
});
});
Multi-tab and multi-context. Test scenarios involving multiple browser windows or user sessions:
test("real-time collaboration", async ({ browser }) => {
const userA = await browser.newContext();
const userB = await browser.newContext();
const pageA = await userA.newPage();
const pageB = await userB.newPage();
await pageA.goto("/doc/123");
await pageB.goto("/doc/123");
await pageA.getByRole("textbox").fill("Hello from User A");
await expect(pageB.getByRole("textbox")).toHaveValue("Hello from User A");
});
Cypress: The Developer Experience Champion
Cypress pioneered the modern approach to browser testing -- a test runner that shows your app alongside the test execution in real time. It has a devoted community and excellent documentation.
Setup
npm install -D cypress
npx cypress open
// cypress.config.js
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
},
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
});
Writing Tests
// cypress/e2e/checkout.cy.js
describe("Checkout Flow", () => {
it("completes a purchase", () => {
cy.visit("/products/widget-pro");
cy.contains("button", "Add to Cart").click();
cy.get("[data-testid=cart-count]").should("have.text", "1");
cy.contains("a", "Checkout").click();
cy.get("#email").type("[email protected]");
cy.get("#address").type("123 Test St");
cy.get("#city").type("Portland");
cy.get("#state").select("OR");
cy.get("#zip").type("97201");
cy.contains("button", "Place Order").click();
cy.contains("h1", "Order Confirmed").should("be.visible");
});
});
Cypress vs Playwright: Honest Trade-offs
| Aspect | Playwright | Cypress |
|---|---|---|
| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, WebKit (experimental) |
| Architecture | Out-of-process (CDP/BiDi) | In-process (runs inside browser) |
| Multi-tab support | Yes | No |
| iframe support | Full | Limited |
| Speed | Faster (parallel by default) | Slower (serial by default) |
| Debugging | Trace viewer, VS Code integration | Time-travel debugger in Test Runner |
| Network mocking | page.route() |
cy.intercept() |
| Component testing | Experimental | Stable (Cypress Component Testing) |
| Cloud dashboard | No (use Playwright's HTML reporter) | Cypress Cloud ($75+/mo) |
| Language | TypeScript, JavaScript, Python, .NET, Java | JavaScript, TypeScript only |
| Community | Growing fast | Larger, established |
| Learning curve | Moderate | Easier (great docs, interactive runner) |
Choose Playwright when: You need cross-browser testing including WebKit/Safari, multi-tab scenarios, maximum speed in CI, or non-JavaScript language support.
Choose Cypress when: Developer experience matters most to your team, you value the interactive test runner for debugging, or you want stable component testing alongside E2E.
The trend is clearly toward Playwright. Its capabilities are broader, its CI performance is better, and it does not have the architectural limitations that come from running inside the browser. But Cypress's developer experience -- watching tests run in real time, time-travel debugging -- is genuinely excellent for teams new to E2E testing.
Testing Library: Component Testing Done Right
Testing Library is not an alternative to Playwright or Cypress -- it operates at a different level. It renders components in a simulated DOM (jsdom or happy-dom) and provides utilities to interact with them the way a user would.
The core philosophy: test what the user sees, not implementation details. Query by role, label, text, and placeholder -- not by CSS class or component internals.
Setup with Vitest
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest happy-dom
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "happy-dom",
setupFiles: ["./src/test-setup.ts"],
globals: true,
},
});
// src/test-setup.ts
import "@testing-library/jest-dom/vitest";
Writing Component Tests
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
test("shows validation error for invalid email", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "not-an-email");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByRole("button", { name: "Sign In" }));
expect(screen.getByText("Please enter a valid email")).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
test("submits valid form data", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByRole("button", { name: "Sign In" }));
expect(onSubmit).toHaveBeenCalledWith({
email: "[email protected]",
password: "password123",
});
});
Query Priority
Testing Library has a deliberate query priority. Use the highest-priority query that works:
getByRole-- Accessible roles (button, textbox, heading). Best for most elements.getByLabelText-- Form elements with associated labels. Best for inputs.getByPlaceholderText-- Fallback for inputs without labels.getByText-- Visible text content. Good for paragraphs, spans, divs.getByDisplayValue-- Current value of form elements.getByTestId-- Last resort. Usedata-testidwhen nothing else works.
Avoid querying by CSS class, component name, or DOM structure. These are implementation details that change during refactoring and break tests for no user-facing reason.
Visual Regression Testing
Visual regression tests catch unintended CSS changes, layout shifts, and rendering bugs that functional tests miss entirely.
Playwright Screenshot Comparison
import { test, expect } from "@playwright/test";
test("dashboard renders correctly", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot("dashboard.png", {
maxDiffPixels: 50,
});
});
test("responsive layout on mobile", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/dashboard");
await expect(page).toHaveScreenshot("dashboard-mobile.png");
});
On first run, Playwright saves reference screenshots. On subsequent runs, it compares against them. Differences beyond the threshold fail the test and produce a diff image showing exactly what changed.
# Update reference screenshots after intentional changes
npx playwright test --update-snapshots
Dedicated Visual Testing Tools
For teams that need more sophisticated visual testing:
- Chromatic (by Storybook) captures component screenshots across browsers and viewports, with review workflows.
- Percy (by BrowserStack) integrates with any test framework and provides a dashboard for visual review.
- Argos is an open source alternative that integrates with Playwright.
These tools handle the hard parts: cross-browser rendering differences, anti-aliasing variation, and font rendering inconsistencies that cause false positives in raw screenshot comparison.
CI Setup
Playwright in GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --project=chromium
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
component:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx vitest --coverage
Optimization tips:
- Install only the browsers you need (
--with-deps chromiuminstead of all three) - Run E2E and component tests in parallel jobs
- Use
forbidOnly: !!process.env.CIin Playwright config to fail if.onlyis left in - Cache Playwright browsers between runs with
actions/cache
Reducing Flaky Tests
Flaky tests -- tests that sometimes pass and sometimes fail -- are the biggest threat to a test suite's credibility. Once developers stop trusting tests, they stop running them.
Common Causes and Fixes
Timing issues. The most common source of flakiness. Fix by using built-in waiting mechanisms instead of arbitrary timeouts:
// Bad: arbitrary wait
await page.waitForTimeout(2000);
await page.click("#submit");
// Good: wait for the specific condition
await page.getByRole("button", { name: "Submit" }).click();
// Playwright auto-waits for the button to be actionable
Shared state between tests. Tests that depend on database state, cookies, or local storage from previous tests. Fix by isolating each test:
test.beforeEach(async ({ page }) => {
// Reset state before each test
await page.context().clearCookies();
// Or use API to reset test data
await page.request.post("/api/test/reset");
});
Animations. CSS animations and transitions cause elements to be in intermediate states. Disable them in tests:
// In playwright.config.ts
use: {
// Reduce motion to minimize animation-related flakiness
reducedMotion: "reduce",
}
Network dependencies. Tests that rely on external APIs. Mock them:
await page.route("**/api/external-service/**", (route) => {
route.fulfill({ status: 200, body: JSON.stringify({ data: "mocked" }) });
});
Non-deterministic data. Tests that depend on data ordering, timestamps, or random values. Use deterministic seeds or sort results before asserting.
Retry Strategy
Retries are a pragmatic compromise -- they don't fix flakiness, but they prevent flaky tests from blocking merges:
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // Retry in CI, not locally
});
Track retry frequency. If a test consistently needs retries, it needs fixing, not more retries.
Recommended Stack
For most frontend projects, this combination covers all bases:
- Vitest + Testing Library for component and unit tests (fast, good DX, tests user behavior)
- Playwright for E2E tests (fastest, best cross-browser support, trace viewer for debugging)
- Playwright screenshot comparison for basic visual regression (free, built-in)
Write many component tests, fewer E2E tests, and run both in CI. The component tests catch logic and behavior issues quickly. The E2E tests verify that the full stack works together. Neither alone is sufficient -- together they give you confidence that your application works as users expect.