In This Guide
Key Takeaways
- Test behavior, not implementation: Write tests that describe what the code does, not how it does it. Tests coupled to implementation details break every time you refactor, even when the behavior is correct.
- The testing pyramid: More unit tests, fewer integration tests, even fewer E2E tests. Unit tests are fast and cheap. E2E tests are slow and brittle. Invert the pyramid only if you have good reasons.
- Playwright over Cypress for new projects: Playwright is the best E2E testing framework in 2026: faster, more reliable, better multi-browser support, and first-class TypeScript support. Cypress is still excellent but Playwright has surpassed it in most benchmarks.
- 80% coverage is a reasonable target: 100% code coverage is a vanity metric that produces low-value tests. 80% coverage with meaningful tests that cover critical paths is more valuable than 100% coverage with shallow tests that just exercise lines.
Most developers write too few tests until a production bug burns them badly enough to write more. Then they sometimes overcorrect and write so many tests that refactoring becomes painful because tests break on every change to implementation details.
Good testing is about finding the right level at each layer of the testing pyramid: enough unit tests to catch logic errors fast, enough integration tests to verify key workflows, and enough E2E tests to ensure critical user journeys work end-to-end — without making the test suite so slow and brittle that the team stops running it.
The Testing Pyramid: Unit, Integration, E2E
The testing pyramid describes the ideal distribution of test types: many fast unit tests at the base, fewer integration tests in the middle, and a small number of slow E2E tests at the top. The pyramid shape reflects the cost and speed trade-offs at each level.
Unit tests test individual functions and components in isolation. They mock all dependencies (APIs, databases, other modules). They run in milliseconds. A suite of 500 unit tests completes in under a minute.
Integration tests test the interaction between components or between the application and real dependencies (database, API). They test that the pieces work together, not just that they work in isolation. They are slower than unit tests but catch problems that unit tests with mocks miss.
End-to-end tests (E2E) simulate a real user interacting with the full application in a real browser. They catch the final category of bugs — UI interactions, full request/response cycles, multi-step workflows — but they are the slowest (seconds to minutes per test) and the most brittle (dependent on timing, element selectors, and environment state).
The recommended distribution for a typical web application: 70% unit tests, 20% integration tests, 10% E2E tests. More E2E tests for applications where the full user journey is the product (e-commerce checkout, onboarding flows).
Unit Tests with Jest
Jest is the dominant JavaScript/TypeScript test runner for unit and integration tests. It provides a test runner, assertion library, mock/spy utilities, and code coverage — all in one package.
// utils/format.test.ts import { formatCurrency } from './format' describe('formatCurrency', () => { it('formats a number as USD currency', () => { expect(formatCurrency(1234.5)).toBe('$1,234.50') }) it('handles zero', () => { expect(formatCurrency(0)).toBe('$0.00') }) it('handles negative values', () => { expect(formatCurrency(-50)).toBe('-$50.00') }) })
Jest features to know: jest.fn() creates mock functions, jest.spyOn() mocks specific methods while preserving the original, jest.mock('module') replaces an entire module import with a mock, and beforeEach/afterEach run setup and teardown before and after each test.
For async code, use async/await with Jest natively or use done callbacks for callback-based async. Always return or await Promises in tests — Jest will not catch assertion failures in unawaited async code.
Component Tests with React Testing Library
React Testing Library (RTL) tests React components the way users interact with them — through text, labels, roles, and visible UI — rather than testing internal component state or implementation details.
import { render, screen, fireEvent } from '@testing-library/react' import { LoginForm } from './LoginForm' test('submits form with email and password', async () => { const onSubmit = jest.fn() render(<LoginForm onSubmit={onSubmit} />) fireEvent.change(screen.getByLabelText('Email'), { target: { value: '[email protected]' } }) fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } }) fireEvent.click(screen.getByRole('button', { name: 'Sign In' })) expect(onSubmit).toHaveBeenCalledWith({ email: '[email protected]', password: 'password123' }) })
RTL querying priority (use the highest-priority query that applies): getByRole (buttons, inputs by role and accessible name), getByLabelText (form fields), getByText (visible text), getByTestId (last resort — adds test coupling to implementation). Using role-based queries ensures your tests also verify accessibility.
End-to-End Tests with Playwright
Playwright is Microsoft's E2E testing framework. It controls real browsers (Chromium, Firefox, WebKit) through a TypeScript/JavaScript API, supports parallel test execution, and includes trace viewer and screenshot-on-failure debugging capabilities.
import { test, expect } from '@playwright/test' test('user can sign up and see dashboard', async ({ page }) => { await page.goto('https://myapp.com/signup') await page.fill('[name="email"]', '[email protected]') await page.fill('[name="password"]', 'SecurePass123!') await page.click('button[type="submit"]') await expect(page).toHaveURL(/\/dashboard/) await expect(page.getByText('Welcome')).toBeVisible() })
Playwright features that make it better than Cypress for most new projects: auto-wait (Playwright automatically waits for elements to be actionable — no manual cy.wait() calls), multi-browser support in a single run, isolated browser contexts (test isolation without browser restarts), and first-class TypeScript support.
For E2E tests: focus on critical user journeys (sign up, sign in, core feature usage, checkout). Do not write E2E tests for every UI state — that is what RTL tests are for.
Testing in CI/CD
Your test suite is only as valuable as how often it runs. A test suite that only runs on a developer's laptop catches bugs too late. Run tests on every pull request and block merges that fail tests.
GitHub Actions test workflow:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: {{ node-version: '20' }}
- run: npm ci
- run: npm test -- --coverage
- run: npx playwright install --with-deps
- run: npx playwright testOptimize for speed: run unit tests first (fast, fail fast on logic errors), then integration tests, then E2E tests. Fail the pipeline immediately on unit test failure rather than running the full suite. Cache node_modules between runs. Run E2E tests only on PRs to the main branch if they are slow.
Frequently Asked Questions
How many tests should I write?
Write enough tests to give you confidence that the code works and enough to catch regressions when you change it. A practical target: write tests for every public function, every edge case that is non-obvious, every bug you find in production (a test that would have caught it), and every critical user journey. Test coverage of 70-80% is a reasonable target for most applications.
What is the difference between Jest and Vitest?
Both are JavaScript test runners. Jest is more mature with a larger ecosystem. Vitest is faster (uses Vite's bundler), has a nearly identical API to Jest, and is the preferred choice for new projects using Vite (which includes Nuxt, SvelteKit, and Vite-based React setups). For Next.js projects, Jest with SWC transformer is still the most common choice in 2026.
Should I use Playwright or Cypress for E2E tests?
For new projects in 2026, Playwright is the recommended choice. It has better multi-browser support, faster parallel execution, auto-waiting that eliminates most flakiness, and first-class TypeScript support. Cypress has a better developer experience for interactive debugging and a longer track record. If your team already has significant Cypress test infrastructure, there is no compelling reason to migrate.
What is code coverage and how do I measure it?
Code coverage measures the percentage of your code that is executed when your tests run. Jest generates coverage reports with the --coverage flag. Lines, branches, functions, and statements are the four coverage metrics. Branch coverage is the most meaningful — it ensures both the true and false paths of every conditional are tested. Configure Jest's coverageThreshold to fail the build if coverage drops below your minimum.
Well-tested code ships faster with fewer regressions. Get the skills.
Join professionals from Denver, NYC, Dallas, LA, and Chicago for two days of hands-on AI and tech training. $1,490. October 2026. Seats are limited.
Reserve Your SeatNote: Information in this article reflects the state of the field as of early 2026.