Skip to main content

Command Palette

Search for a command to run...

Jest Testing Tutorial: Write Unit Tests That Actually Help

Learn: Jest Testing Tutorial: Write Unit Tests That Actually Help

Updated
7 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Jest Testing Tutorial: Write Unit Tests That Actually Help

Testing is not optional in modern JavaScript development. It's the difference between shipping code with confidence and shipping code with prayers. Jest, Facebook's testing framework, has become the industry standard for good reason: it's fast, intuitive, and requires minimal configuration.

This guide will take you from "What is a test?" to writing comprehensive test suites that actually catch bugs.

Why Testing Matters

Before diving into Jest syntax, understand why testing matters beyond checking boxes on a requirements list.

Tests are documentation. When a new developer joins your team, they can read your tests to understand how your code is supposed to work. A well-written test is often clearer than comments.

Tests catch regressions. You refactor code, and suddenly something breaks in production. Tests catch this before deployment. The cost of fixing a bug in production is 10-100x higher than catching it during development.

Tests enable confidence. When you have comprehensive tests, you can refactor aggressively, upgrade dependencies, and make architectural changes without fear. This confidence compounds over time.

Tests reduce debugging time. Instead of firing up a debugger and stepping through code, a failing test tells you exactly what's broken and where.

The ROI on testing is enormous. A 2019 study found that teams with comprehensive test coverage shipped 40% fewer bugs to production.

Getting Started with Jest

Installation

npm install --save-dev jest

Add to your package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Your First Test

Create a file sum.js:

function sum(a, b) {
  return a + b;
}

module.exports = sum;

Create sum.test.js:

const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

Run npm test. That's it. You've written your first test.

Jest Configuration

For most projects, Jest works out of the box. For advanced setups, create jest.config.js:

module.exports = {
  testEnvironment: 'node',
  collectCoverageFrom: ['src/**/*.js', '!src/**/*.test.js'],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70,
    },
  },
  testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
};

Writing Effective Tests

Test Structure: Arrange, Act, Assert

Every good test follows this pattern:

test('calculates discount correctly', () => {
  // Arrange: Set up test data
  const price = 100;
  const discountPercent = 20;

  // Act: Execute the function
  const result = calculateDiscount(price, discountPercent);

  // Assert: Verify the result
  expect(result).toBe(80);
});

This structure makes tests readable and maintainable.

Describe Blocks for Organization

Group related tests with describe:

describe('User Authentication', () => {
  describe('login', () => {
    test('returns token on valid credentials', () => {
      // test code
    });

    test('throws error on invalid credentials', () => {
      // test code
    });
  });

  describe('logout', () => {
    test('clears user session', () => {
      // test code
    });
  });
});

This creates a logical hierarchy that mirrors your code structure.

Matchers: The Heart of Assertions

Jest provides dozens of matchers. Here are the most useful:

// Equality
expect(value).toBe(5);                    // Strict equality (===)
expect(value).toEqual({ name: 'John' }); // Deep equality

// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Numbers
expect(value).toBeGreaterThan(5);
expect(value).toBeLessThan(10);
expect(value).toBeCloseTo(0.1 + 0.2);

// Strings
expect(message).toMatch(/hello/i);
expect(message).toContain('world');

// Arrays
expect(array).toContain('item');
expect(array).toHaveLength(3);

// Objects
expect(user).toHaveProperty('name');
expect(user).toMatchObject({ name: 'John' });

// Exceptions
expect(() => riskyFunction()).toThrow();
expect(() => riskyFunction()).toThrow(TypeError);
expect(() => riskyFunction()).toThrow('specific message');

Testing Async Code

Modern JavaScript is asynchronous. Jest handles this elegantly:

// Using async/await (recommended)
test('fetches user data', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('John');
});

// Using promises
test('fetches user data', () => {
  return fetchUser(1).then(user => {
    expect(user.name).toBe('John');
  });
});

// Using done callback (avoid this)
test('fetches user data', (done) => {
  fetchUser(1).then(user => {
    expect(user.name).toBe('John');
    done();
  });
});

Mocking: Isolating Your Code

Mocks replace real implementations with test doubles. This isolates the code you're testing:

// Mock a module
jest.mock('./database');

test('saves user to database', () => {
  const saveUser = require('./database').saveUser;
  saveUser.mockResolvedValue({ id: 1 });

  return userService.createUser('John').then(user => {
    expect(user.id).toBe(1);
    expect(saveUser).toHaveBeenCalledWith('John');
  });
});

// Mock a function
const mockCallback = jest.fn();
mockCallback('arg1', 'arg2');

expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockCallback).toHaveBeenCalledTimes(1);

Setup and Teardown

Use beforeEach and afterEach for test initialization:

describe('Database operations', () => {
  let db;

  beforeEach(() => {
    db = new Database();
    db.connect();
  });

  afterEach(() => {
    db.disconnect();
  });

  test('inserts record', () => {
    db.insert({ name: 'John' });
    expect(db.count()).toBe(1);
  });
});

Real Examples

Testing a React Component

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button Component', () => {
  test('renders button with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  test('calls onClick handler when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('disables button when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByText('Click me')).toBeDisabled();
  });
});

Testing an API Service

describe('UserService', () => {
  let userService;

  beforeEach(() => {
    userService = new UserService();
    global.fetch = jest.fn();
  });

  test('fetches user by ID', async () => {
    const mockUser = { id: 1, name: 'John' };
    global.fetch.mockResolvedValueOnce({
      json: async () => mockUser,
    });

    const user = await userService.getUser(1);

    expect(user).toEqual(mockUser);
    expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
  });

  test('handles fetch errors', async () => {
    global.fetch.mockRejectedValueOnce(new Error('Network error'));

    await expect(userService.getUser(1)).rejects.toThrow('Network error');
  });
});

Testing Utility Functions

describe('String utilities', () => {
  test('capitalizes first letter', () => {
    expect(capitalize('hello')).toBe('Hello');
    expect(capitalize('HELLO')).toBe('HELLO');
    expect(capitalize('')).toBe('');
  });

  test('slugifies strings', () => {
    expect(slugify('Hello World')).toBe('hello-world');
    expect(slugify('Multiple  Spaces')).toBe('multiple-spaces');
    expect(slugify('Special!@#$Characters')).toBe('specialcharacters');
  });

  test('truncates long strings', () => {
    const long = 'a'.repeat(100);
    expect(truncate(long, 10)).toBe('a'.repeat(10) + '...');
    expect(truncate('short', 10)).toBe('short');
  });
});

Best Practices

1. Test Behavior, Not Implementation

Bad:

test('creates user object', () => {
  const user = new User('John');
  expect(user.firstName).toBe('John');
  expect(user.lastName).toBeUndefined();
});

Good:

test('user can be created with a name', () => {
  const user = new User('John');
  expect(user.getFullName()).toBe('John');
});

2. One Assertion Per Test (Usually)

// Bad: Multiple unrelated assertions
test('user operations', () => {
  const user = createUser('John');
  expect(user.name).toBe('John');
  expect(user.email).toBe('john@example.com');
  expect(user.age).toBe(30);
});

// Good: Focused tests
test('creates user with correct name', () => {
  const user = createUser('John');
  expect(user.name).toBe('John');
});

test('creates user with correct email', () => {
  const user = createUser('John');
  expect(user.email).toBe('john@example.com');
});

3. Use Descriptive Test Names

// Bad
test('works', () => {});
test('test1', () => {});

// Good
test('returns user data when API call succeeds', () => {});
test('throws error when API call fails with 500 status', () => {});

4. Test Edge Cases

describe('divide function', () => {
  test('divides positive numbers', () => {
    expect(divide(10, 2)).toBe(5);
  });

  test('handles negative numbers', () => {
    expect(divide(-10, 2)).toBe(-5);
  });

  test('throws error when dividing by zero', () => {
    expect(() => divide(10, 0)).toThrow();
  });

  test('handles decimal results', () => {
    expect(divide(10, 3)).toBeCloseTo(3.333, 2);
  });
});

5. Keep Tests DRY with Factories

// Factory function
function createMockUser(overrides = {}) {
  return {
    id: 1,
    name: 'John',
    email: 'john@example.com',
    ...overrides,
  };
}

test('user with custom email', () => {
  const user = createMockUser({ email: 'custom@example.com' });
  expect(user.email).toBe('custom@example.com');
});

Common Pitfalls

1. Forgetting to Return Promises

// Bad: Test completes before promise resolves
test('fetches data', () => {
  fetchData().then(data => {
    expect(data).toBeDefined();
  });
});

// Good
test('fetches data', () => {
  return fetchData().then(data => {
    expect(data).toBeDefined();
  });
});

// Better
test('fetches data', async () => {
  const data = await fetchData();
  expect(data).toBeDefined();
});

2. Testing Implementation Details

// Bad: Tests internal state
test('counter increments', () => {
  const counter = new Counter();
  counter.increment();
  expect(counter.count).toBe(1); // Testing private property
});

// Good: Tests public behavior
test('counter increments', () => {
  const counter = new Counter();
  counter.increment();
  expect(counter.getValue()).toBe(1);
});

3. Not Cleaning Up Mocks

// Bad: Mocks persist between tests
jest.mock('./api');

test('test 1', () => {
  // uses mock
});

test('test 2', () => {
  // mock still active from test 1
});

// Good: Clear mocks between tests
afterEach(() => {
  jest.clearAllMocks();
});

4. Overmocking

// Bad: Mocking too much
jest.mock('./database');
jest.mock('./cache');
jest.mock('./logger');
jest.mock('./validator');

// Good: Only mock external dependencies
jest.mock('./externalAPI');

Integration with CI/CD

GitHub Actions

Create .github/workflows/test.yml:

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16'
      - run: npm install
      - run: npm test -- --coverage
      - uses: codecov/codecov-action@v2
        with:
          files: ./coverage/lcov.info

Pre-commit Hooks with Husky

npm install --save-dev husky lint-staged
npx husky install
npx husky add .husky/pre-commit "npm test"

Coverage Thresholds

Enforce minimum coverage in jest.config.js:

coverageThreshold: {
  global: {
    branches: 80,
    functions: 80,
    lines: 80,
    statements: 80,
  },
}

Summary

Jest is powerful, but its real value comes from writing tests that matter. Focus on:

  • Testing behavior, not implementation
  • Writing clear, descriptive tests that serve as documentation
  • Testing edge cases and error conditions
  • Keeping tests isolated with proper mocking
  • Maintaining tests as your code evolves

Start with unit tests for utility functions and business logic. Add integration tests for critical workflows. Use end-to-end tests sparingly for user journeys.

The best test suite is one that catches bugs, enables refactoring, and documents your code. Jest gives you the tools. The rest is discipline.

Begin today. Your future self will thank you when a test catches a bug before it reaches production.