Vitest vs Jest vs Playwright: Complete Testing Stack Guide 2026
·PkgPulse Team
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)
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();
});
});
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();
});
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
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
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.
Compare Vitest, Jest, Playwright, and other testing library trends at PkgPulse.
See the live comparison
View vitest vs. jest on PkgPulse →