Skip to main content

The State of JavaScript Testing in 2026

·PkgPulse Team

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

FactorJestVitest
Cold start~5s~0.5s
Native ESM support❌ (via Babel)
TypeScript supportVia Babel/ts-jest✅ Native
Watch mode~2s re-run~100ms re-run
Config complexityHigh (babel, transform)Low (Vite config)
API compatibilityJest APIJest-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

ScenarioPick
Unit and integration testsVitest
React component testsVitest + Testing Library
E2E testsPlaywright
Visual regressionPlaywright + Chromatic
Existing Jest codebaseStay with Jest (or migrate cheaply to Vitest)
CI performanceVitest (10x faster cold starts)
Cross-browser testingPlaywright (Cypress: Chromium-only without paid plan)

Compare testing library package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.