Testing used to mean installing Jest, configuring Babel, wrestling with mocks, and waiting
for your test suite to crawl through thousands of files. It was powerful but heavy.
The landscape has shifted dramatically. Node.js now ships with a built-in test runner.
Vitest brings instant startup and native ESM support. Playwright makes browser automation
feel almost magical. And you don't need any of the old complexity to get started.
This guide covers everything you need to know to test modern web applications effectively.
The Testing Pyramid
Before diving into tools, let's establish the foundation. The testing pyramid remains
the gold standard for test strategy:
Unit tests form the base: fast, isolated, and numerous. They validate individual functions and components.
Integration tests verify that components work together correctly.
End-to-end (E2E) tests simulate real user journeys through your entire application.
The key insight: balance. Too many E2E tests and your suite is slow and brittle.
Too few and you miss critical user flows. A healthy ratio might be 70% unit, 20% integration,
10% E2E.
Node.js Built-in Test Runner
The biggest shift in JavaScript testing is that you might not need an external framework
at all. Node.js 20 promoted the built-in test runner from experimental to stable, and
it's surprisingly capable.
Getting Started
No installation required. Create a test file:
import { describe, it } from 'node:test';
import assert from 'node:assert';
// The function we're testing
function add(a, b) {
return a + b;
}
describe('add', () => {
it('adds two positive numbers', () => {
assert.strictEqual(add(2, 3), 5);
});
it('handles negative numbers', () => {
assert.strictEqual(add(-1, 1), 0);
});
it('handles zero', () => {
assert.strictEqual(add(0, 0), 0);
});
});
Run it:
node --test
# Or with a specific pattern
node --test tests/*.test.js
# With watch mode
node --test --watch
Built-in Mocking
The test runner includes mocking capabilities via the mock module:
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
describe('API client', () => {
it('fetches user data', async (t) => {
// Mock fetch for this test only
const mockFetch = t.mock.fn(async () => ({
json: async () => ({ id: 1, name: 'Alice' })
}));
// Replace global fetch
t.mock.method(globalThis, 'fetch', mockFetch);
const response = await fetch('/api/user/1');
const user = await response.json();
assert.strictEqual(user.name, 'Alice');
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
// Mock is automatically restored after the test
});
Test Reporters
The built-in runner supports multiple output formats:
Vitest was built for the modern JavaScript ecosystem. It uses Vite under the hood,
providing instant startup and native ESM/TypeScript support:
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Button from './Button';
describe('Button', () => {
it('renders with label', () => {
render();
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn(); // vi instead of jest
render();
screen.getByRole('button').click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
The syntax is nearly identical—Vitest was designed to be a drop-in replacement.
Key Differences
Aspect
Jest
Vitest
Startup time
Slower (Babel/ts-jest)
Instant (Vite)
ESM support
Experimental
Native
TypeScript
Requires config
Out of the box
Ecosystem
Massive
Growing fast
React Native
First-class
Limited
The Verdict
New projects: Start with Vitest. The speed difference is noticeable,
and the modern ESM/TypeScript support eliminates configuration headaches.
Existing Jest projects: Migration is straightforward if you want the
speed benefits. But if Jest is working well for you, there's no urgency to switch.
React Native: Stick with Jest—it has better support in that ecosystem.
Browser Automation: Playwright vs Puppeteer
For end-to-end testing, you need to automate real browsers. The two major players
are Playwright (Microsoft) and Puppeteer (Google).
Puppeteer: The Pioneer
Puppeteer launched in 2017 and pioneered modern browser automation. It's simple,
focused, and excellent for Chrome-based testing:
export async function waitForComponent(page, tagName) {
await page.waitForFunction(
(tag) => {
const el = document.querySelector(tag);
return el && el.shadowRoot;
},
{ timeout: 5000 },
tagName
);
}
export async function getShadowText(page, hostSelector, innerSelector) {
return page.evaluate(
(host, inner) => {
const hostEl = document.querySelector(host);
if (!hostEl?.shadowRoot) return null;
const innerEl = hostEl.shadowRoot.querySelector(inner);
return innerEl?.textContent?.trim() || null;
},
hostSelector,
innerSelector
);
}
export async function clickShadowElement(page, hostSelector, innerSelector) {
await page.evaluate(
(host, inner) => {
const hostEl = document.querySelector(host);
if (!hostEl?.shadowRoot) throw new Error(`Host ${host} not found`);
const innerEl = hostEl.shadowRoot.querySelector(inner);
if (!innerEl) throw new Error(`Inner ${inner} not found`);
innerEl.click();
},
hostSelector,
innerSelector
);
}
Best Practices for 2026
Based on current trends and tooling, here are my recommendations:
1. Start with the Simplest Tool
Before reaching for Jest or Vitest, consider if the Node.js built-in runner is enough.
For libraries and backend code, it often is. Fewer dependencies means faster CI and
fewer security vulnerabilities.
2. Use Role-Based Queries
Instead of querying by class or ID, query by accessibility role:
// Good: Uses semantic role
page.getByRole('button', { name: 'Submit' })
// Avoid: Tightly coupled to implementation
page.locator('.btn-primary.submit-form')
This makes tests more resilient to refactoring and ensures your UI is accessible.
3. Test Behavior, Not Implementation
Your tests should verify what your code does, not how it does it:
Screenshot comparisons are powerful but high-maintenance. Use them for critical
UI components, not for every page. False positives (from minor rendering differences)
will erode trust in your test suite.
7. Test Edge Cases in Unit Tests
Edge cases (empty inputs, network failures, boundary conditions) are much easier
to test at the unit level. Save your E2E tests for happy paths:
That's it. No Babel. No complex configuration. Just Node.js for unit tests
and Playwright for E2E. Add Vitest if you need more features, but start simple.
Summary
Web testing in 2026 is faster, simpler, and more powerful than ever:
Node.js built-in test runner eliminates external dependencies for many projects
Vitest brings instant startup and native ESM for modern JavaScript
Playwright makes cross-browser E2E testing almost enjoyable
The testing pyramid still guides strategy: many unit tests, fewer integration, even fewer E2E
Web Components require special handling, but Playwright pierces Shadow DOM automatically
The best test suite is one that runs fast, catches real bugs, and doesn't slow
down development. Start simple, add complexity only when needed, and remember:
a test that never runs is worse than no test at all.