Testing Overview
Testing is how you prove your code works—and more importantly, how you prove it keeps working as your codebase evolves. A well-tested application gives you confidence to refactor, ship features faster, and sleep better at night. But testing poorly—or testing the wrong things—wastes time and creates a false sense of security.
This guide covers the mental models, terminology, and strategies you need to build an effective testing approach for JavaScript and TypeScript projects.
Testing Models: Pyramid vs. Trophy
Two models dominate conversations about how to distribute your testing effort. Neither is universally correct—context determines which serves you better.
The Testing Pyramid
The traditional pyramid, popularized by Mike Cohn, suggests:
/\
/ \ E2E (few)
/----\
/ \ Integration (some)
/--------\
/ \ Unit (many)
--------------
The argument: Unit tests are fast, cheap, and isolated. Write many of them. Integration tests verify components work together—write fewer. E2E tests are slow, flaky, and expensive—use sparingly for critical paths.
When the pyramid fits:
- Backend services with complex business logic
- Libraries and utilities where functions have clear inputs/outputs
- Systems where unit boundaries are well-defined
- Teams that need fast CI feedback loops
The Testing Trophy
Kent C. Dodds proposed the "testing trophy" as an alternative, particularly for frontend applications:
🏆
E2E (few)
---------------
| Integration | (most)
---------------
| Unit | (some)
-----------
Static (TypeScript, ESLint)
The argument: Integration tests give you the most confidence per line of test code. They test components as users experience them, catch issues that unit tests miss, and are more resilient to refactoring than E2E tests.
When the trophy fits:
- Frontend applications (React, Vue, Svelte)
- Applications where user interactions span multiple components
- Codebases with TypeScript (static analysis catches many unit-level bugs)
- Teams prioritizing confidence over speed
Choosing Your Model
Don't treat these as religions. Most real-world projects blend both:
| Project Type | Recommended Distribution |
|---|---|
| UI-heavy frontend | Trophy (integration-heavy) |
| Business logic library | Pyramid (unit-heavy) |
| API service | Balanced (unit + integration) |
| Full-stack app | Both: unit for logic, integration for UI, E2E for critical paths |
Types of Tests
Unit Tests
Unit tests verify a single "unit" of code in isolation—typically a function, method, or class. They mock or stub external dependencies.
// src/utils/price.ts
export function calculateDiscount(price: number, percentage: number): number {
if (percentage < 0 || percentage > 100) {
throw new Error('Percentage must be between 0 and 100');
}
return price * (1 - percentage / 100);
}
// src/utils/price.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './price';
describe('calculateDiscount', () => {
it('applies percentage discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
it('handles zero discount', () => {
expect(calculateDiscount(50, 0)).toBe(50);
});
it('throws for invalid percentage', () => {
expect(() => calculateDiscount(100, 150)).toThrow();
});
});
Characteristics:
- Fast (milliseconds per test)
- Deterministic (no external dependencies)
- Pinpoint failures to specific functions
- Can become tightly coupled to implementation details
Integration Tests
Integration tests verify that multiple units work together correctly. In frontend contexts, this often means rendering a component and testing user interactions.
// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits credentials and shows success message', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue({ success: true });
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
});
expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
});
});
Characteristics:
- Test real user workflows
- More confidence than unit tests
- Slower than unit tests (but still fast)
- May require more setup (providers, mocking network calls)
End-to-End (E2E) Tests
E2E tests verify entire application flows through a real browser, interacting with your actual deployed (or locally running) application.
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test('complete checkout flow', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="product-1"] >> text=Add to Cart');
await page.click('text=View Cart');
await expect(page.locator('.cart-item')).toHaveCount(1);
await page.click('text=Checkout');
await page.fill('#email', '[email protected]');
await page.fill('#card-number', '4242424242424242');
await page.click('text=Complete Purchase');
await expect(page).toHaveURL(/\/confirmation/);
await expect(page.locator('h1')).toContainText('Thank you');
});
Characteristics:
- Highest confidence (tests what users actually experience)
- Slowest to run (seconds to minutes per test)
- Most prone to flakiness
- Requires running application infrastructure
What Belongs Where?
Use this decision tree:
Is it pure logic with clear inputs/outputs?
└─ Yes → Unit test
Does it involve multiple components working together?
└─ Yes → Integration test
Is it a critical user journey that must never break?
└─ Yes → E2E test
Is it testing implementation details (private methods, internal state)?
└─ Yes → Don't test it directly
Examples by Category
| Test This With... | Examples |
|---|---|
| Unit tests | Utility functions, data transformers, validation logic, state reducers, custom hooks (logic only) |
| Integration tests | Form submission, component interactions, API integration (with mocked network), context providers |
| E2E tests | Checkout flow, authentication, payment processing, critical business workflows |
When NOT to Test
Testing has diminishing returns. Skip testing when:
Testing Implementation Details
If refactoring your code (without changing behavior) breaks your tests, you're testing implementation details.
// ❌ Bad: tests implementation
it('calls the internal _validateEmail method', () => {
const spy = vi.spyOn(form, '_validateEmail');
form.submit();
expect(spy).toHaveBeenCalled();
});
// ✅ Good: tests behavior
it('shows error for invalid email', async () => {
await user.type(emailInput, 'not-an-email');
await user.click(submitButton);
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
Trivial Code
Don't test simple pass-through functions or code that's obviously correct:
// Don't bother testing this
export function getUserId(user: User): string {
return user.id;
}
Third-Party Code
Don't test that libraries work as documented. Trust that React renders components, that Zod validates schemas, that Lodash sorts arrays.
// ❌ Don't do this
it('sorts array correctly', () => {
expect(_.sortBy([3, 1, 2])).toEqual([1, 2, 3]);
});
Rapidly Changing UI
During early development or heavy iteration, E2E tests become maintenance burdens. Wait until interfaces stabilize.
100% Coverage Obsession
Coverage metrics measure lines executed, not correctness. 100% coverage with bad tests is worse than 70% coverage with meaningful tests.
Testing Tools Covered in This Section
This section covers the major testing tools in the JavaScript ecosystem:
| Tool | Type | Best For |
|---|---|---|
| Vitest | Unit/Integration | Vite projects, modern ESM codebases |
| Jest | Unit/Integration | Established projects, broad ecosystem |
| Playwright | E2E | Cross-browser testing, complex workflows |
| Cypress | E2E | Developer experience, component testing |
Additional topics:
- Playwright vs. Cypress - Choosing your E2E framework
- Test-Driven Development - Writing tests first
- Mocking Strategies - Isolating code under test
- Snapshot Testing - Capturing output for comparison
Getting Started
If you're new to testing:
- Start with integration tests for your most important user flows
- Add unit tests for complex business logic and utilities
- Add E2E tests for critical paths (checkout, auth, main features)
- Use TypeScript as your first line of defense—it catches many bugs before tests run
If you're choosing tools for a new project:
- Vite-based project? Start with Vitest
- Existing Jest setup? Stick with Jest unless you have pain points
- Need E2E? Read Playwright vs. Cypress to decide
See Also
- Vitest - Modern, Vite-native testing
- Jest - The established standard
- Playwright - Cross-browser E2E testing
- Cypress - Developer-friendly E2E testing
- Testing Library docs - Testing that resembles how users interact with your app