JavaScript Testing Fundamentals 2026: Unit Tests, TDD & E2E Guide
Table of Contents
JavaScript testing fundamentals separate professional developers from hobbyists. Every production codebase you’ll work on in 2026 expects tests — not as a nice-to-have, but as a hard requirement enforced by CI pipelines that block your pull requests. Yet most tutorials teach testing as an afterthought, leaving developers confused about what to test, which framework to pick, and how test-driven development actually works in practice.
This lesson breaks down the entire JavaScript testing landscape: unit tests, integration tests, end-to-end tests, TDD workflow, assertions, mocking, and code coverage. You’ll write real tests using Jest, Vitest, and Playwright — the three tools that dominate JavaScript testing in 2026. By the end, you’ll understand not just how to write tests, but what to test and when each type of test makes sense.
Why Testing Matters in JavaScript Development
Testing isn’t about proving your code works — it’s about proving your code still works after someone changes it. JavaScript projects evolve fast. You refactor a utility function, update a dependency, or add a new feature. Without tests, every change is a gamble. With tests, you get instant feedback when something breaks.
The economics are straightforward. A bug caught by a unit test costs minutes to fix. The same bug discovered in production costs hours of debugging, hotfix deployments, and potentially lost users. Companies like Google and Meta enforce strict testing requirements because they’ve learned this lesson at scale. Their JavaScript testing fundamentals include mandatory code coverage thresholds and automated test gates.
If you’ve been following our ESLint and Prettier lesson, you already know that automated tools catch errors before they reach production. Testing takes that concept further — linting catches syntax and style problems, but tests catch logic problems. They verify that your functions return the right values, your components render correctly, and your API integrations handle edge cases.
Types of JavaScript Tests You Need to Know
JavaScript testing falls into three main categories, often visualized as a pyramid. Understanding when to use each type is a core JavaScript testing fundamental that will shape every testing decision you make.
Unit tests sit at the base of the pyramid. They test individual functions or modules in isolation. Unit tests are fast (milliseconds each), cheap to write, and give precise feedback about what broke. A well-tested JavaScript project has hundreds or thousands of unit tests.
Integration tests occupy the middle layer. They verify that multiple units work together correctly — your API handler talks to the database layer properly, or your form component validates and submits data through the right service. Integration tests are slower than unit tests but catch bugs that unit tests miss, like incorrect function call signatures or mismatched data formats.
End-to-end (E2E) tests sit at the top. They simulate real user behavior: opening a browser, clicking buttons, filling forms, and verifying what appears on screen. E2E tests are the slowest and most brittle, but they catch problems that no other test type can — like CSS hiding a critical button, or a third-party script breaking your checkout flow.
The pyramid shape matters. You want many unit tests, some integration tests, and few E2E tests. Inverting this pyramid (lots of E2E tests, few unit tests) creates a slow, fragile test suite that developers learn to ignore.
Unit Testing JavaScript Functions
A unit test verifies a single “unit” of code — typically a function — in complete isolation. The function receives known inputs, and you assert that the output matches your expectation. No database calls, no network requests, no file system access. Just pure input-output verification.
Here’s a simple function you might find in any JavaScript project:
// utils/price.js
export function calculateDiscount(price, discountPercent) {
if (price < 0) throw new Error('Price cannot be negative');
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100');
}
const discount = price * (discountPercent / 100);
return Math.round((price - discount) * 100) / 100;
}
This function has clear inputs, a clear output, and defined error conditions. That makes it a perfect candidate for unit testing. You’d test the happy path (valid inputs), edge cases (zero discount, 100% discount), and error conditions (negative price, invalid percentage). If you followed our npm and package.json lesson, you already know how to set up the project structure for adding test files alongside your source code.
Setting Up Jest for JavaScript Testing
Jest remains the most widely used JavaScript testing framework in 2026. Created by Meta, it provides a test runner, assertion library, and mocking utilities in a single package. Most React and Node.js projects use Jest out of the box.
Install Jest in your project:
npm install --save-dev jest
# For ES modules support (if using import/export)
npm install --save-dev @jest/globals
Add a test script to your package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Jest finds test files automatically. By convention, you can place tests in a __tests__ directory or name them with .test.js or .spec.js suffixes. The --watch flag reruns tests when files change — essential during development. The --coverage flag generates a report showing which lines your tests actually execute.
Writing Your First Unit Test
Let’s test the calculateDiscount function from earlier. Create a file called price.test.js:
// utils/price.test.js
const { calculateDiscount } = require('./price');
describe('calculateDiscount', () => {
test('applies percentage discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
test('handles decimal results with rounding', () => {
expect(calculateDiscount(99.99, 15)).toBe(84.99);
});
test('returns full price when discount is 0', () => {
expect(calculateDiscount(49.99, 0)).toBe(49.99);
});
test('returns 0 when discount is 100%', () => {
expect(calculateDiscount(250, 100)).toBe(0);
});
test('throws error for negative price', () => {
expect(() => calculateDiscount(-10, 20)).toThrow('Price cannot be negative');
});
test('throws error for discount over 100', () => {
expect(() => calculateDiscount(100, 150)).toThrow(
'Discount must be between 0 and 100'
);
});
test('throws error for negative discount', () => {
expect(() => calculateDiscount(100, -5)).toThrow(
'Discount must be between 0 and 100'
);
});
});
Run npm test and Jest executes every test() block inside the describe() group. Each expect() call is an assertion — a statement that must be true for the test to pass. If calculateDiscount(100, 20) returns anything other than 80, Jest reports a failure with a clear diff showing expected vs. received values.
Notice the test structure: each test verifies one specific behavior. The test name describes what should happen, not how the code works internally. This is a JavaScript testing fundamental that keeps tests maintainable — when you refactor the implementation, the tests still describe valid behavior.
Vitest: The Modern Testing Alternative
If you’re using Vitest, you get a Jest-compatible API with native ES module support and lightning-fast execution powered by Vite. As we covered in our JavaScript bundlers lesson, Vite’s dev server is incredibly fast — and Vitest inherits that speed.
npm install --save-dev vitest
The same test file works with Vitest with minimal changes:
// utils/price.test.js
import { describe, test, expect } from 'vitest';
import { calculateDiscount } from './price.js';
describe('calculateDiscount', () => {
test('applies percentage discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
// ... same tests as Jest
});
Vitest runs tests in parallel by default and reuses Vite’s module transformation pipeline, which means your TypeScript, JSX, and CSS modules work without extra configuration. For new projects in 2026, Vitest is the recommended choice. For existing Jest projects, the migration is straightforward since the APIs are nearly identical. If you’ve been learning TypeScript basics, Vitest handles .ts test files natively — no ts-jest configuration needed.
Test-Driven Development (TDD) Workflow
Test-driven development flips the normal coding workflow. Instead of writing code first and tests later, you write the test first, watch it fail, then write the minimum code to make it pass. This cycle is called Red-Green-Refactor:
Red: Write a test that describes the behavior you want. Run it — it fails because the code doesn’t exist yet. The failing test is your specification.
Green: Write the simplest possible code that makes the test pass. Don’t optimize, don’t handle edge cases you haven’t written tests for yet. Just make the red test turn green.
Refactor: Clean up the code you just wrote. Remove duplication, improve naming, optimize if needed. The tests ensure your refactoring doesn’t break anything.
TDD forces you to think about what your code should do before thinking about how to implement it. The result is code that’s inherently testable — because it was designed to be tested from the start. Functions stay small, dependencies stay explicit, and edge cases get handled because you wrote tests for them first.
TDD Practical Example: Building a Shopping Cart
Let’s build a shopping cart module using TDD. We start with the test file — no implementation exists yet:
// cart.test.js
import { describe, test, expect, beforeEach } from 'vitest';
import { createCart } from './cart.js';
describe('Shopping Cart', () => {
let cart;
beforeEach(() => {
cart = createCart();
});
test('starts empty', () => {
expect(cart.getItems()).toEqual([]);
expect(cart.getTotal()).toBe(0);
});
test('adds an item', () => {
cart.addItem({ id: 'a1', name: 'Keyboard', price: 75 });
expect(cart.getItems()).toHaveLength(1);
expect(cart.getTotal()).toBe(75);
});
test('adds multiple items', () => {
cart.addItem({ id: 'a1', name: 'Keyboard', price: 75 });
cart.addItem({ id: 'a2', name: 'Mouse', price: 40 });
expect(cart.getItems()).toHaveLength(2);
expect(cart.getTotal()).toBe(115);
});
test('increases quantity for duplicate items', () => {
cart.addItem({ id: 'a1', name: 'Keyboard', price: 75 });
cart.addItem({ id: 'a1', name: 'Keyboard', price: 75 });
expect(cart.getItems()).toHaveLength(1);
expect(cart.getItems()[0].quantity).toBe(2);
expect(cart.getTotal()).toBe(150);
});
test('removes an item', () => {
cart.addItem({ id: 'a1', name: 'Keyboard', price: 75 });
cart.addItem({ id: 'a2', name: 'Mouse', price: 40 });
cart.removeItem('a1');
expect(cart.getItems()).toHaveLength(1);
expect(cart.getTotal()).toBe(40);
});
test('applies a percentage discount', () => {
cart.addItem({ id: 'a1', name: 'Keyboard', price: 100 });
cart.applyDiscount(10);
expect(cart.getTotal()).toBe(90);
});
});
Every test above fails because cart.js doesn’t exist. Now we write the implementation — just enough to pass each test:
// cart.js
export function createCart() {
const items = [];
let discountPercent = 0;
return {
addItem(product) {
const existing = items.find(item => item.id === product.id);
if (existing) {
existing.quantity += 1;
} else {
items.push({ ...product, quantity: 1 });
}
},
removeItem(id) {
const index = items.findIndex(item => item.id === id);
if (index !== -1) items.splice(index, 1);
},
getItems() {
return [...items];
},
applyDiscount(percent) {
discountPercent = percent;
},
getTotal() {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
return Math.round(subtotal * (1 - discountPercent / 100) * 100) / 100;
},
};
}
All six tests pass. The TDD approach gave us a clean API, complete with edge case handling for duplicate items, before we wrote a single line of implementation. This is the power of test-driven development — the tests drive your design decisions.
Integration Testing in JavaScript
Integration tests verify that multiple modules work together. Unlike unit tests that mock everything external, integration tests let real modules interact — your controller calls your actual service layer, which calls your actual validation logic.
Here’s an integration test for an Express.js API endpoint using Supertest:
// __tests__/api/users.integration.test.js
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app.js';
import { db } from '../../src/database.js';
describe('POST /api/users', () => {
beforeAll(async () => {
await db.migrate();
});
afterAll(async () => {
await db.close();
});
test('creates a user with valid data', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Ada Lovelace', email: 'ada@example.com' });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
name: 'Ada Lovelace',
email: 'ada@example.com',
});
expect(response.body.id).toBeDefined();
});
test('rejects duplicate email', async () => {
await request(app)
.post('/api/users')
.send({ name: 'First User', email: 'dupe@example.com' });
const response = await request(app)
.post('/api/users')
.send({ name: 'Second User', email: 'dupe@example.com' });
expect(response.status).toBe(409);
expect(response.body.error).toContain('already exists');
});
test('validates required fields', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: '' });
expect(response.status).toBe(400);
});
});
This test exercises the full request lifecycle: HTTP parsing, validation, database insertion, and response formatting. It catches bugs that unit tests miss — like a middleware stripping the request body, or a database constraint not matching your validation rules.
End-to-End Testing With Playwright
Playwright, developed by Microsoft, is the dominant E2E testing framework in 2026. It controls real browsers (Chrome, Firefox, Safari) and simulates user interactions with remarkable accuracy.
npm install --save-dev @playwright/test
npx playwright install
Here’s an E2E test for a login flow:
// e2e/login.spec.js
import { test, expect } from '@playwright/test';
test.describe('Login Page', () => {
test('successful login redirects to dashboard', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'securePass123');
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator('h1')).toContainText('Welcome');
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('[data-testid="email"]', 'wrong@example.com');
await page.fill('[data-testid="password"]', 'badPassword');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="error-message"]'))
.toBeVisible();
await expect(page).toHaveURL(/\/login/);
});
test('login button is disabled while submitting', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'securePass123');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="login-button"]'))
.toBeDisabled();
});
});
E2E tests are powerful but expensive. Each test launches a real browser, navigates real pages, and waits for real network responses. Use them sparingly — cover critical user flows like authentication, checkout, and data submission, but don’t try to E2E test every feature.
JavaScript Testing Assertions and Matchers
Assertions are the backbone of every test. Both Jest and Vitest provide a rich set of matchers that make your assertions readable and precise:
// Equality
expect(result).toBe(42); // strict equality (===)
expect(obj).toEqual({ a: 1, b: 2 }); // deep equality
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeTruthy();
expect(value).toBeFalsy();
// Numbers
expect(price).toBeGreaterThan(0);
expect(score).toBeLessThanOrEqual(100);
expect(0.1 + 0.2).toBeCloseTo(0.3); // floating point safe
// Strings
expect(message).toMatch(/error/i);
expect(greeting).toContain('Hello');
// Arrays and Objects
expect(list).toContain('item');
expect(list).toHaveLength(5);
expect(response).toMatchObject({ status: 'ok' }); // partial match
expect(response).toHaveProperty('data.users');
// Exceptions
expect(() => riskyFunction()).toThrow();
expect(() => validate(-1)).toThrow('must be positive');
// Async
await expect(fetchUser(999)).rejects.toThrow('Not found');
await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
Choosing the right matcher makes your test failures more informative. toBe tells you the expected and received values. toMatchObject shows which properties differ. toThrow confirms both that an error occurred and that it carries the right message. Precise assertions turn cryptic test failures into actionable debugging information.
Mocking and Spying in JavaScript Tests
Real applications depend on databases, APIs, file systems, and third-party services. Unit tests need to isolate from these dependencies using mocks (fake implementations) and spies (observers that record calls).
import { describe, test, expect, vi } from 'vitest';
import { sendWelcomeEmail } from './email-service.js';
import { createUser } from './user-service.js';
// Mock the entire email module
vi.mock('./email-service.js', () => ({
sendWelcomeEmail: vi.fn().mockResolvedValue({ sent: true }),
}));
describe('createUser', () => {
test('sends welcome email after creating user', async () => {
const user = await createUser({ name: 'Ada', email: 'ada@test.com' });
expect(sendWelcomeEmail).toHaveBeenCalledOnce();
expect(sendWelcomeEmail).toHaveBeenCalledWith('ada@test.com', 'Ada');
expect(user.name).toBe('Ada');
});
test('still creates user if email fails', async () => {
sendWelcomeEmail.mockRejectedValueOnce(new Error('SMTP down'));
const user = await createUser({ name: 'Grace', email: 'grace@test.com' });
expect(user.name).toBe('Grace'); // user created despite email failure
});
});
The vi.mock() call replaces the real email service with a fake that records how it was called. This lets you verify that createUser calls the email service with the right arguments — without actually sending emails during tests. Spies are similar but wrap real functions instead of replacing them, letting you observe calls while keeping the original behavior.
A common pattern is mocking fetch for API calls:
test('fetches and transforms user data', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Ada' }),
});
const user = await getUserById(1);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(user.name).toBe('Ada');
});
Code Coverage: Measuring JavaScript Test Quality
Code coverage tells you what percentage of your code is executed during tests. Both Jest and Vitest generate coverage reports using V8’s built-in coverage or Istanbul:
# Jest
npx jest --coverage
# Vitest
npx vitest --coverage
The report shows four metrics for each file: statement coverage (which lines executed), branch coverage (which if/else paths taken), function coverage (which functions called), and line coverage (similar to statements, but per line). Most teams target 80% overall coverage as a practical threshold — high enough to catch regressions, low enough to avoid wasting time testing trivial code.
Coverage is a useful metric, but don’t treat it as a goal in itself. 100% coverage doesn’t mean zero bugs — it means every line ran during tests, not that every line was verified correctly. A test that calls a function without asserting anything achieves coverage without providing value. Focus on testing meaningful behavior, and let coverage be the byproduct.
JavaScript Testing Best Practices for 2026
After years of testing JavaScript at scale, the community has converged on these best practices that form the core JavaScript testing fundamentals every developer should follow:
Test behavior, not implementation. Your tests should describe what the code does, not how it does it. If you refactor a function’s internals without changing its behavior, no tests should break. Tests coupled to implementation details become anchors that prevent refactoring.
Keep tests independent. Each test should set up its own state and clean up after itself. Tests that depend on execution order create mysterious failures when one test is skipped or reordered. Use beforeEach to reset state, not beforeAll with shared mutable state.
Use descriptive test names. A test name should read like a specification: “returns empty array when no items match filter” beats “test filter function.” When a test fails in CI, the name is the first thing you read — make it count.
Follow the Arrange-Act-Assert pattern. Structure every test as three clear phases: arrange the preconditions, act by calling the code under test, assert the expected outcome. This pattern makes tests scannable and consistent. In Jest and Vitest, avoid putting assertions inside loops or callbacks — each test should have clear, linear assertions.
Don’t mock what you don’t own. Mocking third-party libraries creates tests that pass even when the library changes its API. Instead, wrap third-party code in your own adapter and mock the adapter. This gives you a stable interface to test against.
Common JavaScript Testing Mistakes to Avoid
Testing implementation details. If your test checks that a private variable was set to a specific value, or that an internal helper was called three times, you’ve coupled your test to implementation. These tests break during refactoring even when behavior is preserved, generating false failures that erode trust in the test suite.
Excessive mocking. When you mock so much that the test barely exercises real code, you’re testing your mocks — not your application. If a test requires more than two or three mocks, consider whether it should be an integration test instead.
Ignoring async behavior. Forgetting to await an async assertion is a classic JavaScript testing mistake. The test passes because the assertion never actually runs — the Promise resolves after Jest has already marked the test as passed. Always await async operations and use .resolves / .rejects matchers.
Writing tests after the fact. Bolting tests onto finished code leads to tests that mirror the implementation rather than specifying behavior. TDD avoids this by making tests the specification. Even if you don’t practice strict TDD, writing tests close in time to the code — not weeks later — produces better tests.
No test for the bug fix. Every bug you fix should get a test that reproduces the bug first, then verifies the fix. Without this, the same bug can regress silently. A regression test is the cheapest insurance you’ll ever write.
What Comes Next in Your JavaScript Journey
JavaScript testing fundamentals are a foundational skill that compounds over time. Every function you test today is a regression you prevent tomorrow. Start with unit tests for your utility functions, add integration tests for your API endpoints, and use E2E tests for your most critical user flows.
You’ve now completed the entire Tooling & Testing section of our JavaScript curriculum. Combined with the earlier lessons on npm and package.json, bundlers, linting and formatting, and TypeScript basics, you have a complete picture of the modern JavaScript development workflow. You can set up a project, bundle it for production, enforce code quality, add type safety, and verify behavior with automated tests.
The next section of our JavaScript roadmap dives into advanced patterns — design patterns, performance optimization, and real-world architecture. Keep building, keep testing, and keep shipping.