The State of JavaScript Testing in 2026
TL;DR
Vitest replaced Jest for new projects. Playwright replaced Cypress for E2E. Testing Library remained the React testing standard. The JavaScript testing landscape in 2026 is the most consolidated it's ever been — three tools dominate, and the choice is rarely controversial. Vitest (~8M weekly downloads, growing fast) won the unit test war. Playwright (~5M) won E2E. Testing Library (~10M) remains the standard for component testing. Jest (~18M) still dominates by raw numbers but new project adoption is near zero.
Key Takeaways
- Jest: ~18M weekly downloads — legacy dominant, rarely chosen for new projects
- Vitest: ~8M downloads — Jest replacement, native ESM, 10x faster cold start
- Playwright: ~5M downloads — E2E standard, cross-browser, auto-waits
- Testing Library: ~10M downloads — component testing, user-centric queries
- Cypress: ~5M downloads — still popular, but Playwright has surpassed it for new projects
The Test Stack in 2026
Unit/Integration Tests: Vitest + Testing Library (React/Vue/Svelte)
E2E Tests: Playwright
Component Testing: Vitest + Testing Library OR Playwright component tests
Visual Testing: Playwright screenshots + Percy/Chromatic
API Testing: Vitest (unit) OR Playwright (integration)
Vitest (Unit Testing)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom', // Faster than jsdom (~10x)
globals: true, // No need to import describe/it/expect
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8', // Native V8 coverage (fast)
reporter: ['text', 'html', 'lcov'],
exclude: ['**/*.d.ts', '**/index.ts', '**/*.config.*'],
},
},
});
// Example: Testing a utility function
// src/lib/health-score.test.ts
import { describe, it, expect } from 'vitest';
import { calculateHealthScore } from './health-score';
describe('calculateHealthScore', () => {
it('returns 100 for a perfect package', () => {
const score = calculateHealthScore({
weeksSinceLastRelease: 2,
openIssues: 5,
hasTests: true,
hasTypeScript: true,
downloadTrend: 'up',
});
expect(score).toBe(100);
});
it('penalizes packages not updated in 2 years', () => {
const score = calculateHealthScore({
weeksSinceLastRelease: 104, // 2 years
openIssues: 50,
hasTests: false,
hasTypeScript: false,
downloadTrend: 'down',
});
expect(score).toBeLessThan(30);
});
it.each([
[4, 90], // 4 weeks → 90%
[26, 70], // 26 weeks → 70%
[52, 50], // 52 weeks → 50%
])('weeksSinceLastRelease=%i gives score near %i', (weeks, expectedMin) => {
const score = calculateHealthScore({ weeksSinceLastRelease: weeks });
expect(score).toBeGreaterThanOrEqual(expectedMin - 10);
});
});
// Vitest — mocking (vi = jest equivalent)
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { fetchPackageData } from './npm-api';
import { getPackageHealth } from './health';
vi.mock('./npm-api'); // Auto-mock the module
describe('getPackageHealth', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches data and computes health', async () => {
vi.mocked(fetchPackageData).mockResolvedValue({
name: 'vitest',
weeklyDownloads: 8_000_000,
lastPublish: new Date('2026-03-01'),
});
const health = await getPackageHealth('vitest');
expect(fetchPackageData).toHaveBeenCalledWith('vitest');
expect(health.score).toBeGreaterThan(80);
expect(health.name).toBe('vitest');
});
});
Testing Library (React Component Tests)
// Testing Library — test user behavior, not implementation
// src/components/PackageCard.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PackageCard } from './PackageCard';
describe('PackageCard', () => {
const mockPackage = {
name: 'zustand',
description: 'Simple state management',
weeklyDownloads: 4_000_000,
healthScore: 92,
};
it('renders package name and health score', () => {
render(<PackageCard pkg={mockPackage} />);
expect(screen.getByRole('heading', { name: 'zustand' })).toBeInTheDocument();
expect(screen.getByText('92/100')).toBeInTheDocument();
});
it('calls onSelect when clicked', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<PackageCard pkg={mockPackage} onSelect={onSelect} />);
await user.click(screen.getByRole('button', { name: /compare/i }));
expect(onSelect).toHaveBeenCalledWith('zustand');
});
it('shows loading state while fetching details', async () => {
render(<PackageCard pkg={mockPackage} loadDetails />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
});
// Testing Library query priority (2026 best practices)
// ✅ Best: accessible queries (test what users see)
screen.getByRole('button', { name: 'Submit' })
screen.getByLabelText('Email address')
screen.getByPlaceholderText('Search packages')
// ✅ Good: text content
screen.getByText('Download trends')
screen.getByTitle('Health score')
// ⚠️ Acceptable when needed
screen.getByTestId('package-grid')
// ❌ Avoid: implementation details
screen.getByClassName('btn-primary')
document.querySelector('.card')
Playwright (E2E Testing)
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true, // Run tests in parallel
forbidOnly: !!process.env.CI, // Fail if test.only left in code
retries: process.env.CI ? 2 : 0,
reporter: [['html'], ['github']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // Record trace on failure
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', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run dev', // Start app before tests
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
// e2e/package-search.spec.ts
import { test, expect, Page } from '@playwright/test';
test.describe('Package Search', () => {
test('searches for a package and shows comparison', async ({ page }) => {
await page.goto('/');
// Fill search — Playwright auto-waits for element to be visible
await page.getByPlaceholder('Search npm packages...').fill('react');
await page.keyboard.press('Enter');
// Assert results
await expect(page.getByRole('heading', { name: 'react' })).toBeVisible();
await expect(page.getByText(/weekly downloads/i)).toBeVisible();
});
test('compares two packages', async ({ page }) => {
await page.goto('/compare/react-vs-vue');
await expect(page.getByRole('heading', { name: /react vs vue/i })).toBeVisible();
// Check health scores visible
const scores = page.getByTestId('health-score');
await expect(scores).toHaveCount(2);
});
test('mobile navigation works', async ({ page }) => {
// Test at mobile viewport
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
await page.getByRole('button', { name: 'Menu' }).click();
await expect(page.getByRole('navigation')).toBeVisible();
});
});
// Playwright — Page Object Model (for larger test suites)
class SearchPage {
constructor(private page: Page) {}
async search(query: string) {
await this.page.getByPlaceholder('Search npm packages...').fill(query);
await this.page.keyboard.press('Enter');
await this.page.waitForURL(/\?q=/);
}
async getFirstResult() {
return this.page.getByTestId('package-result').first();
}
}
test('search returns results', async ({ page }) => {
const searchPage = new SearchPage(page);
await page.goto('/');
await searchPage.search('zustand');
const first = await searchPage.getFirstResult();
await expect(first).toBeVisible();
});
Jest vs Vitest: Why the Switch
| Factor | Jest | Vitest |
|---|---|---|
| Cold start | ~5s | ~0.5s |
| Native ESM support | ❌ (via Babel) | ✅ |
| TypeScript support | Via Babel/ts-jest | ✅ Native |
| Watch mode | ~2s re-run | ~100ms re-run |
| Config complexity | High (babel, transform) | Low (Vite config) |
| API compatibility | Jest API | Jest-compatible |
| In-source tests | ❌ | ✅ |
Migration from Jest to Vitest typically takes 1-2 hours: change imports, update config, run tests. API is nearly identical.
When to Choose
| Scenario | Pick |
|---|---|
| Unit and integration tests | Vitest |
| React component tests | Vitest + Testing Library |
| E2E tests | Playwright |
| Visual regression | Playwright + Chromatic |
| Existing Jest codebase | Stay with Jest (or migrate cheaply to Vitest) |
| CI performance | Vitest (10x faster cold starts) |
| Cross-browser testing | Playwright (Cypress: Chromium-only without paid plan) |
Compare testing library package health on PkgPulse.
See the live comparison
View vitest vs. jest on PkgPulse →