Vitest + Jest + Playwright: Full Testing Stack 2026
TL;DR
The modern testing stack: Vitest (unit/integration) + Playwright (E2E). Jest is still running millions of tests across the npm ecosystem, but new projects default to Vitest because it shares Vite's config, runs tests in parallel by default, and is 5-10x faster. Playwright replaced Cypress as the E2E tool of choice — better multi-tab support, less flakiness, and first-class TypeScript. The old stack (Jest + Enzyme/React Testing Library + Cypress) still works, but the new stack (Vitest + Testing Library + Playwright) is faster, simpler, and better.
Key Takeaways
- Vitest: Jest-compatible API, Vite-native, ~10x faster, TypeScript without setup
- Jest: 40M+ weekly downloads (legacy), still excellent, no reason to migrate working tests
- Playwright: multi-browser E2E, trace viewer, 80%+ market share in new projects
- Cypress: real-time browser view is great DX but slower and less capable than Playwright
- Testing Library: the default React component testing approach — framework-agnostic
Unit Testing: Vitest vs Jest
// The APIs are nearly identical — migration is usually find-and-replace:
// ─── Jest ───
// jest.config.js
module.exports = {
transform: { '^.+\\.tsx?$': ['ts-jest', {}] }, // setup required
testEnvironment: 'jsdom',
};
// test file:
import { sum } from './math';
describe('math utils', () => {
test('adds two numbers', () => {
expect(sum(1, 2)).toBe(3);
});
it('handles negatives', () => {
expect(sum(-1, -2)).toBe(-3);
});
});
// ─── Vitest ───
// vite.config.ts (reuses existing Vite config!)
import { defineConfig } from 'vite';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true, // optional: makes describe/test/expect global without import
},
});
// test file — identical to Jest:
import { sum } from './math';
describe('math utils', () => {
test('adds two numbers', () => {
expect(sum(1, 2)).toBe(3);
});
});
// Performance comparison (500 unit tests, React project):
// Jest (with ts-jest): 8.4s
// Jest (with babel-jest): 11.2s
// Vitest: 1.8s 🏆
// Why Vitest is faster:
// → Uses esbuild for transforms (same as Vite dev server)
// → Parallel by default (worker threads, one per test file)
// → No separate config for TS — shares Vite's esbuild config
// → Module resolution uses Vite's resolver (no duplicate setup)
The 10x speed improvement Vitest shows on the same test suite isn't marketing — it's architectural. Jest's transformation pipeline was designed in an era before native ESM and before esbuild. TypeScript transformation through ts-jest or babel-jest requires a full separate compilation step that processes each file before Jest can run it. Vitest uses esbuild, the same transformer that powers the Vite dev server, which handles TypeScript, JSX, and module resolution through native, highly optimized compilation paths. The cold start on a 500-test suite drops from 8-11 seconds (Jest) to under 2 seconds (Vitest) because there is simply less work being done.
The worker thread parallelism matters too. Vitest runs each test file in a separate worker thread by default, fully utilizing multi-core systems. Jest's process isolation model spawns separate child processes, which has higher startup overhead per process. On an 8-core development machine, Vitest's worker threads amortize startup cost across cores in a way that Jest's processes cannot.
Migration from Jest is almost always mechanical. The API surface is deliberately compatible — Vitest's creators tracked Jest's API closely. The complete list of changes in a typical migration: replace jest.fn() with vi.fn(), replace jest.mock() with vi.mock(), and remove the Jest configuration file in favor of a test section in vite.config.ts. The automated migration script vitest-migration handles most of this. Teams consistently report 90-95% of their Jest tests passing on Vitest without modification.
Component Testing with React Testing Library
// Testing Library works with both Jest and Vitest — same API:
// Setup (Vitest):
// package.json:
{
"test": "vitest",
"dependencies": {
"@testing-library/react": "^15",
"@testing-library/user-event": "^14",
"@testing-library/jest-dom": "^6"
}
}
// vitest.setup.ts:
import '@testing-library/jest-dom/vitest'; // extends expect with toBeInDocument etc.
// vite.config.ts:
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
},
});
// Component test:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits with email and password', async () => {
const mockSubmit = vi.fn(); // vi.fn() instead of jest.fn()
render(<LoginForm onSubmit={mockSubmit} />);
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
});
it('shows error for invalid email', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.type(screen.getByLabelText('Email'), 'not-an-email');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
});
Testing Library's design philosophy changed how React teams think about component tests. The core constraint — query by role, label, or accessible name, never by class or test ID — is not an arbitrary style preference. It's a claim about what tests should verify: user-observable behavior, not implementation details. When a test breaks because you renamed a CSS class, the test was never measuring anything that mattered. When a test breaks because you removed the submit button, it was correctly catching a regression that would affect users.
The practical consequence is that Testing Library tests survive refactoring. Refactor a component from class to function, from local state to a custom hook, from direct state to a context consumer — none of those internal changes should break a well-written Testing Library test, because the test only cared about what a user sees and can interact with. This is the opposite of Enzyme's approach, which exposed component internals and therefore broke on every significant refactor. Teams that migrated from Enzyme to Testing Library consistently report that their test suites became maintenance assets rather than liabilities.
userEvent from @testing-library/user-event deserves specific mention. The older fireEvent API dispatches synthetic events — technically functional but bypassing browser event handling nuances like focus management and input validation triggers. userEvent simulates the full sequence of events a real user generates: keydown, keypress, keyup, focus, blur events fire in realistic order. For form validation that activates on blur, or components that handle keyboard shortcuts, userEvent produces tests that fail in ways that indicate real bugs rather than synthetic event handling quirks.
E2E Testing: Playwright vs Cypress
// ─── Playwright ───
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // Capture traces on failure
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'safari', use: { browserName: 'webkit' } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
});
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toHaveText('Welcome back');
});
// Multi-tab test (Playwright exclusive):
test('cart persists across tabs', async ({ context }) => {
const page1 = await context.newPage();
const page2 = await context.newPage();
await page1.goto('/product/1');
await page1.click('button:text("Add to Cart")');
await page2.goto('/cart');
await expect(page2.locator('.cart-item')).toHaveCount(1);
});
// API mocking in tests:
test('shows error when API fails', async ({ page }) => {
await page.route('**/api/users', route => route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server error' }),
}));
await page.goto('/users');
await expect(page.locator('.error-message')).toBeVisible();
});
What made Playwright the dominant E2E choice by 2026 is clearer from architecture than from the feature list. Playwright communicates with browsers through the native DevTools Protocol — the same channel browser debugging tools use. Cypress runs JavaScript inside the browser itself, which gives it real-time test visualization but creates fundamental limitations: no multi-tab support, no true cross-browser execution in a single runner, no way to handle browser dialogs that lock the main thread.
The page.route() API in the error-state test above intercepts requests at the browser's network layer, before the page's JavaScript ever sees them. This makes error state testing deterministic: you force a 500 response and verify the error UI renders — no race conditions, no mock server, no application code changes. The multi-tab test demonstrates Playwright's BrowserContext model: pages created from the same context share cookies and session storage, exactly as real browser tabs do. Testing cart persistence across tabs isn't a contrived edge case — it's a real user behavior that was simply untestable in Cypress.
The auto-waiting mechanism underpins Playwright's reliability advantage. Playwright's locators don't fail immediately if an element isn't present — they poll until the element appears or a timeout expires. This eliminates the class of test flakiness that dominated Selenium-era suites: element not found errors from tests that ran faster than JavaScript could render. Playwright E2E suites consistently run at dramatically lower flake rates than equivalent Cypress or Selenium setups, which changes the economics of E2E testing: when failures reliably indicate real bugs, teams trust the suite and invest in it rather than ignoring it.
Playwright Trace Viewer: Debugging E2E Failures
# Run tests with trace on failure:
npx playwright test --trace on
# Or configure in playwright.config.ts:
use: { trace: 'on-first-retry' }
# After a failure, view the trace:
npx playwright show-trace test-results/trace.zip
# The trace viewer shows:
# → Screenshot at each action
# → Network requests and responses
# → Console logs and errors
# → DOM snapshots you can inspect
# → Timeline of the test execution
# This replaces hours of debugging with 5 minutes of trace review
# Run specific test in headed mode (see the browser):
npx playwright test --headed auth.spec.ts
# Generate test code by recording browser actions:
npx playwright codegen http://localhost:3000
# → Opens browser, records your clicks, generates test code
The trace viewer is the feature that most changes the debugging experience for E2E failures in CI. Before Playwright, a failing E2E test in CI meant searching through logs, adding debug output, re-running the test to reproduce, and hoping the failure wasn't intermittent. With the trace file uploaded as a CI artifact, the debugging workflow collapses to: download the zip, run show-trace, and click through the timeline to see exactly what the application looked like at each step. The codegen command serves a complementary role during test authoring — developers who find writing locator selectors tedious can record their browser interactions and get generated test code as a starting point, which is especially useful for onboarding engineers new to Playwright.
Complete Testing Stack Setup
# Install everything for the modern stack:
npm install --save-dev \
vitest \
@vitest/ui \ # visual test runner UI
jsdom \ # browser environment for unit tests
@testing-library/react \
@testing-library/user-event \
@testing-library/jest-dom \
@playwright/test
# Install Playwright browsers (one-time):
npx playwright install
# package.json scripts:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "vitest run && playwright test"
}
}
# File structure:
src/
components/
Button/
Button.tsx
Button.test.tsx ← unit/integration test (Vitest + Testing Library)
utils/
math.test.ts ← unit test
e2e/
auth.spec.ts ← E2E test (Playwright)
checkout.spec.ts
playwright.config.ts
vite.config.ts ← Vitest config lives here
The file structure above reflects a deliberate organization philosophy. Unit and integration tests live colocated with source files in src/ — a test file is one directory level away from the code it tests, so updating the implementation and updating its tests happen in the same mental context. E2E tests live in a top-level e2e/ directory because they test the running application, not individual modules. There is no component to colocate an authentication flow test with.
The --ui flag in both vitest --ui and playwright test --ui opens visual test runners that change how testing feels during development. Vitest's UI mode shows a file tree, real-time pass/fail state, coverage visualization, and a diff view when assertions fail. Playwright's UI mode shows the browser alongside test steps, a scrub-able timeline, and highlights which test line corresponds to each browser action. For developers writing new tests, UI mode compresses the iteration loop from "run the suite and scan the terminal" to watching the test execute visually — often catching the problem mid-run rather than reading an error message after the fact.
The test:all script runs the full suite sequentially — Vitest with --run (single pass, no file watching) followed by Playwright. This is the pre-merge command. During development, the common pattern is test:watch in a background terminal for continuous unit test feedback, plus test:e2e:ui when actively writing or debugging E2E tests. Unit tests give sub-second feedback on logic changes; E2E tests confirm the assembled application works end to end. Running them at different cadences in development, together in CI, captures both kinds of confidence without constant three-minute waits.
When to Keep Jest
Keep Jest when:
→ Existing test suite works — don't migrate for the sake of it
→ Your project doesn't use Vite (Create React App, custom Webpack setup)
→ You use Jest-specific features (jest.spyOn, jest.useFakeTimers) extensively
→ Your team knows Jest deeply and migration would cause disruption
Migrate to Vitest when:
→ New project (always use Vitest)
→ Test suite is slow and painful (10+ second runs for unit tests)
→ You've already migrated to Vite for bundling
→ TypeScript setup with ts-jest is causing friction
Migration process (from Jest to Vitest):
1. npx vitest-migration # automated codemods available
2. Replace jest.fn() → vi.fn()
3. Replace jest.mock() → vi.mock()
4. Update jest.config.js → vitest config in vite.config.ts
5. Run tests: expect ~95% to pass without changes
The compatibility is excellent — most Jest tests run on Vitest unchanged.
The migration path from Jest to Vitest is unusually smooth by JavaScript ecosystem standards. The compatibility decision was intentional — Vitest's design goal from the start was to be a drop-in replacement for typical Jest configurations. The most common friction point is jest.mock() module factory behavior: Jest hoists mock declarations to the top of the file via a Babel transform. Vitest replicates this with vi.mock(), but the hoisting requires vi to be imported from vitest inside the factory function rather than relying on a global. The automated codemod handles this transformation in most codebases without manual intervention.
Teams that have completed the migration consistently describe three improvements beyond raw speed: TypeScript errors in test files appear without a separate ts-jest configuration step; path aliases and module resolution configured in vite.config.ts for the application automatically apply in tests; and the gap between development behavior and test environment behavior shrinks because both run through the same Vite pipeline. The third improvement is underrated — maintaining separate TypeScript configurations for source, bundling, and testing is a recurring source of subtle bugs where test environments make different assumptions than production environments. Vitest eliminates that configuration split by extending the application's existing Vite setup directly, making the development and test contexts genuinely consistent.
The Testing Pyramid Redrawn for 2026
The classic testing pyramid — many unit tests at the base, fewer integration tests in the middle, a thin slice of E2E tests at the top — remains structurally sound, but the optimal ratios have shifted. Vitest's cold start is roughly 10x faster than Jest's, which fundamentally changes the economics of unit testing. When a full unit test suite completes in under two seconds, the cost of writing one more unit test is negligible. That changes behavior: teams that previously skipped unit tests because the suite was slow now write them freely.
The updated heuristic that's working in 2026 is roughly 70/20/10: 70% unit tests, 20% integration tests, 10% E2E tests. But the definition of each tier matters. Integration tests are increasingly hitting real databases rather than mocked ones — tools like Testcontainers spin up a Postgres or Redis instance in Docker for the duration of the test run, then tear it down. Mocked data access layers produce tests that pass confidently while hiding real bugs at the ORM or query level. The integration test tier is where that gap gets closed.
E2E tests with Playwright are used differently than they were with Cypress. Playwright's auto-waiting mechanism — the test runner automatically waits for network requests, animations, and DOM changes to settle before proceeding — eliminated most of the flakiness that made teams distrust their E2E suites. When a test fails in Playwright, it almost always means the application is actually broken. That reliability shift means teams are willing to invest in E2E tests again, but with discipline: 20 to 30 carefully written tests covering critical user journeys (authentication, checkout, onboarding, core CRUD flows), not broad coverage that duplicates what unit tests already verify.
The testing strategy that's working in 2026 is comprehensive unit tests for business logic and pure functions, integration tests for data access and API routes, and 20 to 30 Playwright E2E tests for the paths where a failure would cost the business money. This structure gives fast feedback on most changes (unit tests), confidence in data correctness (integration), and assurance that the deployed application works end-to-end (E2E) — without a test suite that takes fifteen minutes to run.
CI/CD for the Full Testing Stack
Getting CI feedback down to three minutes on a typical Next.js project is achievable with the right pipeline structure. The key is sequential gating with parallel execution within each tier. Run unit tests first — they're the fastest and if they fail, there is no point running slower tiers. Use Vitest with --reporter=github to get inline PR annotations that surface failing test names directly in the GitHub diff view, rather than forcing developers to navigate to a separate CI log.
Integration tests run second, gated on unit test success with GitHub Actions' needs: dependency syntax. This tier typically takes one to two minutes. Playwright E2E tests run third and benefit from sharding: splitting the test suite across four parallel runners with --shard 1/4, 2/4, 3/4, 4/4 reduces a 12-minute E2E suite to three minutes of wall-clock time. Playwright merges the shard results into a unified HTML report automatically.
Cache optimization compounds the savings. Cache the node_modules directory keyed on package-lock.json hash to avoid reinstalling on every run. If the project uses Turborepo, remote caching means unchanged packages never rebuild at all — only the files that actually changed get reprocessed.
For observability, run vitest run --coverage and upload the coverage report to Codecov or a similar service. Coverage trends over time are more useful than point-in-time numbers. On E2E failures, Playwright's HTML reporter generates an artifact — screenshots, traces, and network logs — that gets uploaded as a GitHub Actions artifact. Developers can download the trace ZIP and open it with npx playwright show-trace to see exactly what the test saw, frame by frame, without needing to reproduce the failure locally.
The complete pipeline looks like: unit tests in 30 seconds, integration tests in two minutes, E2E tests in three minutes running across four parallel shards — total CI feedback in roughly five to six minutes from push to green or red.
Component Test Coverage: What's Enough?
One hundred percent code coverage is a misleading goal. It measures whether lines were executed during tests, not whether the tests are actually useful. Teams that optimize for coverage numbers write tests that touch every line but verify nothing meaningful — they end up with a large, brittle test suite that breaks constantly during refactoring and gives false confidence about behavior.
The Testing Library philosophy provides a better frame: test what the user experiences, not how the component is implemented internally. Query elements by ARIA role, label text, or placeholder text — the same attributes a screen reader or user would interact with. Never query by class name or test ID unless absolutely necessary. A test that says screen.getByRole('button', { name: /submit/i }) is testing a contract: there is a button labeled "submit." A test that says container.querySelector('.submit-btn') is testing an implementation detail that can change without any user-visible behavior changing.
The sweet spot for component coverage: every component should have at minimum three categories of tests. First, a render test — does it display the content and elements it should display given its props? Second, an interaction test — does the primary user interaction (a button click, a form submission, a dropdown selection) produce the expected outcome? Third, state variant tests — does it handle loading states, error states, and empty states correctly?
What to skip: React internals that the component library already tests, snapshot tests that generate hundreds of lines of serialized HTML and break whenever any styling changes, and tests that verify prop types or TypeScript interface compliance (the compiler handles that). The shadcn/ui components that most projects build on are already tested upstream — testing that a <Button> renders a button element is not a valuable use of test-writing time.
Aim for meaningful coverage of the behaviors users depend on. A suite with 80 targeted behavioral tests provides more confidence than 200 tests that each assert one internal state variable.
Compare Vitest, Jest, Playwright, and other testing library trends at PkgPulse.
See also: Jest vs Vitest and Vite vs webpack, Bun Test vs Vitest vs Jest 2026: Speed Compared.
See the live comparison
View vitest vs. jest on PkgPulse →