DEV Community

Alex Chen
Alex Chen

Posted on

Testing JavaScript: A Practical Guide to TDD with Jest (2026)

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!)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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',
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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.)
Enter fullscreen mode Exit fullscreen mode

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.