Testing JavaScript: A Practical Guide to TDD with Jest (2026)
Testing isn't optional — it's what separates professional code from hobby projects. Here's how to actually do it.
Why Test?
Without tests:
→ You're scared to change code ("what if it breaks?")
→ Bugs get reintroduced (regressions)
→ Code reviews take forever (manual verification)
→ Onboarding is slow (no living documentation)
→ Deployments are stressful
With tests:
→ Refactor confidently (tests catch breakages)
→ Catches regressions automatically
→ Code reviews focus on design, not correctness
→ Tests document expected behavior
→ deployments are boring (in a good way!)
The Testing Pyramid
╱╲
╱ E2E ╲ ← Few: Critical user flows (Playwright/Cypress)
╱─────────╲
╱ Integration ╲ ← Some: API contracts, DB interactions
╱───────────────╲
╱ Unit Tests ╲ ← MOST: Pure functions, components, utilities
╱───────────────────╲
Rule of thumb:
- 70% unit tests (fast, cheap, isolated)
- 20% integration tests (test connections between parts)
- 10% E2E tests (slow, expensive, but catch real issues)
Unit Testing with Jest
// Setup: npm install --save-dev jest @types/jest ts-jest
// package.json: "test": "jest"
// === Basic Test Structure ===
describe('User utils', () => {
// Arrange → Act → Assert pattern
test('formatName returns capitalized name', () => {
// Arrange: Set up the test data
const input = { first: 'john', last: 'doe' };
// Act: Call the function being tested
const result = formatName(input);
// Assert: Check the result
expect(result).toBe('John Doe');
});
});
// === Matching Functions (Jest's superpower) ===
expect(value).toBe(expected); // Strict equality (===)
expect(value).toEqual(expected); // Deep equality (objects/arrays)
expect(value).toBeTruthy(); // Truthy value
expect(value).toBeFalsy(); // Falsy value
expect(value).toContain(item); // Array/string contains
expect(value).toHaveLength(n); // Array/string length
expect(value).toThrow(); // Throws an error
expect(value).toThrowError(/message/); // Throws with matching message
expect(fn).toHaveBeenCalledTimes(3); // Call count
expect(fn).toHaveBeenCalledWith(arg); // Called with specific args
expect(value).toMatchSnapshot(); // Snapshot testing!
// Object/Array matchers
expect(user).toMatchObject({ name: 'Alice' }); // Partial match
expect(ids).toContainEqual(42); // Contains (deep equality)
expect(obj).toHaveProperty('name'); // Has property
expect(array).toBeInstanceOf(Array); // Type check
// Number matchers
expect(count).toBeGreaterThan(0);
expect(ratio).toBeCloseTo(0.333, 2); // Within 2 decimal places
Testing Async Code
// === Promises ===
test('fetchUser returns user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBeDefined();
});
});
// OR with resolves/rejects:
await expect(fetchUser(1)).resolves.toHaveProperty('name');
await expect(fetchUser(-1)).rejects.toThrow('Not found');
// === Async/Await ===
test('fetchPosts returns array', async () => {
const posts = await fetchPosts();
expect(Array.isArray(posts)).toBe(true);
expect(posts.length).toBeGreaterThan(0);
});
// === Callbacks ===
test('callback receives data', done => { // 'done' parameter!
fetchData((err, data) => {
if (err) { done.fail(err); return; }
expect(data).toBeDefined();
done(); // Tell Jest the async work is complete
});
});
// === Timers ===
jest.useFakeTimers(); // Don't actually wait!
test('calls callback after timeout', () => {
const callback = jest.fn();
timerFunction(callback);
jest.advanceTimersByTime(1000); // Fast-forward 1000ms
expect(callback).toHaveBeenCalled();
expect(callback).toHaveBeenCalledTimes(1);
});
jest.useRealTimers(); // Restore real timers after test
Mocking & Spies
// Mock a module entirely
jest.mock('./api', () => ({
fetchUsers: jest.fn().mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]),
}));
// Spy on existing function (call through to real implementation)
const spy = jest.spyOn(console, 'log');
myFunction();
expect(spy).toHaveBeenCalledWith('hello');
spy.mockRestore(); // Clean up!
// Mock implementation
const mockFn = jest.fn()
.mockReturnValueOnce(42)
.mockReturnValueOnce('hello')
.mockImplementation((x) => x * 2);
mockFn(); // → 42
mockFn(); // → 'hello'
mockFn(5); // → 10
// Mock fetch (for API calls)
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: 'test' }),
});
// Clear mock state between tests
beforeEach(() => {
jest.clearAllMocks(); // Clear call history
jest.resetModules(); // Reset module cache
});
Testing React Components
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
it('shows error for invalid email', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
// Find elements by accessible role/text
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole('button', { name: /submit/i });
// Simulate user interaction
await userEvent.type(emailInput, 'not-an-email');
await userEvent.click(submitButton);
// Assert result
expect(screen.getByText(/valid email/i)).toBeInTheDocument();
});
it('calls onSubmit with form data', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('renders loading state', () => {
render(<UserProfile isLoading={true} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(screen.queryByText('John')).not.toBeInTheDocument();
});
});
Integration Testing
// Test that parts work TOGETHER (not just in isolation)
describe('User API integration', () => {
let server;
beforeAll(async () => {
server = app.listen(0); // Random available port
baseUrl = `http://localhost:${server.address().port}`;
});
afterAll(async () => {
await server.close();
});
test('POST /users creates and GET /users/:id retrieves', async () => {
// Create
const createRes = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@test.com' })
.expect(201);
const userId = createRes.body.id;
expect(createRes.body.name).toBe('Alice');
// Retrieve
const getRes = await request(app)
.get(`/api/users/${userId}`)
.expect(200);
expect(getRes.body).toEqual(createRes.body);
});
test('handles validation errors', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: '', email: 'invalid' })
.expect(400);
expect(res.body.errors).toContainEqual({
field: 'email',
message: 'Invalid email format',
});
});
});
Practical Testing Checklist
Before pushing code:
□ Pure utility functions have unit tests?
□ Complex logic has edge case tests?
- Empty input / null / undefined
- Boundary values (0, -1, max)
- Invalid types
□ API endpoints have integration tests?
- Success path
- Error responses (404, 500)
- Input validation
□ Auth-protected routes reject unauthenticated requests?
□ Database operations handle connection errors?
□ Async code handles timeouts and failures?
□ Component tests use accessible queries (getByRole > getByText)?
□ No console errors/warnings during tests?
□ Coverage report checked? (Aim for 80%+ on critical paths)
⚠️ What NOT to test:
✗ Implementation details (private methods, internal state)
✗ Third-party libraries (they have their own tests)
✗ CSS/styles (use visual regression tools if needed)
✗ Framework internals (React re-renders, etc.)
What's your biggest barrier to writing tests? Time? Complexity? Not knowing where to start?
Follow @armorbreak for more practical developer guides.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.