Skip to main content

๐Ÿงช Introduction to Jest

Every professional codebase you'll ever touch is held together by an invisible safety net: its tests. Jest is the framework that spins that net for you in the JavaScript world โ€” batteries included, almost zero setup. In this lesson you'll install it, write your very first passing test, and learn the small vocabulary of functions that powers thousands of real-world test suites.

Week 3 · Day 5 (Friday: Testing Fundamentals) · Lecture 1

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a test framework does and why Jest is the default choice for JavaScript projects
  • Install and configure Jest in a Node project with npm scripts
  • Write a test using describe, test, and expect
  • Choose the right matcher (toBe, toEqual, toThrow, and friends) for each assertion
  • Structure a test with the Arrange-Act-Assert pattern
  • Run tests once, in watch mode, and with a coverage report

Estimated Time: 60 minutes

Practice: Test a small math module and a password strength checker from scratch.

In This Lesson

The Safety Net

Picture a trapeze artist working high above the crowd. The daring flips only look effortless because there is a net below โ€” one that turns a potential disaster into a harmless bounce. In software, automated tests are that net. They let you refactor boldly, ship on Friday, and sleep on the weekend, because the moment a change breaks something, a test goes red and tells you exactly where.

Testing is not one single activity. It comes in layers, each answering a different question about your code. Today's lesson lives in the bottom, widest layer โ€” unit tests โ€” but it helps to see the whole pyramid first.

graph TD A[Automated Testing] --> B[Unit Tests] A --> C[Integration Tests] A --> D[End-to-End Tests] B --> B1[Test one function
in isolation] B --> B2[Fast โ€” run in milliseconds] B --> B3[Many of them] C --> C1[Test modules working together] C --> C2[e.g. service + database] D --> D1[Test a full user journey] D --> D2[Slow โ€” run in a real browser]

A healthy project has many fast unit tests at the base, fewer integration tests in the middle, and a handful of slow end-to-end tests at the top. Jest is the tool we'll use for the base, and the same skills carry upward into the other layers.

Why Jest?

Jest is an open-source testing framework maintained by Meta and now stewarded by the OpenJS Foundation. Think of it as a Swiss Army knife: instead of gluing together a test runner, an assertion library, and a mocking tool, you get all three in one install that "just works" for most JavaScript projects.

๐Ÿ“– What a test framework actually gives you

Three jobs, one tool. A runner finds your test files and executes them. An assertion library (Jest's expect) lets you state "this should equal that" and fails loudly when it doesn't. A mocking library lets you swap out slow or unpredictable dependencies for controlled fakes.

Key features

  • Zero configuration: point it at a project and it runs โ€” no config file required to start
  • Fast & parallel: test files run in parallel worker processes
  • Rich matchers: a large, readable vocabulary of assertions
  • Built-in mocking: jest.fn(), module mocks, and fake timers, no add-ons
  • Code coverage: a coverage report with a single flag
  • Watch mode: re-runs only the tests affected by your last edit
  • Helpful failures: colored diffs that point straight at the mismatch

๐Ÿ’ก Jest vs. Vitest

You'll also hear about Vitest, a newer runner built for Vite projects. Its API is intentionally almost identical to Jest's โ€” describe, test, expect all behave the same. Learn Jest well and Vitest is a five-minute switch.

Installing & Configuring Jest

Jest is a development-only dependency โ€” you need it while building, not when your app runs in production. Install it with the --save-dev flag inside any project that already has a package.json.

# Install Jest as a dev dependency
npm install --save-dev jest

# Verify the install
npx jest --version

Add npm scripts

Typing npx jest works, but a script is friendlier for your team. Add these to package.json:

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

Now npm test runs the whole suite once, npm run test:watch keeps it running as you code, and npm run test:coverage tells you which lines your tests actually touched.

A config file (only when you need one)

Jest runs with sensible defaults, so you often need no config at all. When you do โ€” for example, to test browser-style code that expects a document โ€” add a jest.config.js file:

// jest.config.js
/** @type {import('jest').Config} */
module.exports = {
  // 'node' for backend code; 'jsdom' simulates a browser DOM.
  // (For jsdom you must also: npm install --save-dev jest-environment-jsdom)
  testEnvironment: 'node',

  // Which files count as tests. This is the default โ€” shown for clarity.
  testMatch: ['**/__tests__/**/*.[jt]s', '**/?(*.)+(spec|test).[jt]s'],

  // Collect coverage from your source, but ignore entry points.
  collectCoverageFrom: ['src/**/*.js', '!src/index.js'],
};

โš ๏ธ ESM vs. CommonJS

Older Jest examples use module.exports/require (CommonJS). Modern projects often use import/export (ES modules). If you write import in your source, add Babel (babel-jest plus @babel/preset-env) or set "type": "module" and run Jest with the experimental VM flag. In this lesson we use ESM-style import/export in examples and assume a Babel transform is present, which is the norm in real projects.

Your First Test

Let's test something tiny and real. Create a module with a few pure math functions, then a test file that proves they behave. Jest automatically finds any file ending in .test.js.

The code under test

// math.js
export function add(a, b) {
  return a + b;
}

export function divide(a, b) {
  if (b === 0) {
    throw new Error('Cannot divide by zero');
  }
  return a / b;
}

The test file

// math.test.js
import { add, divide } from './math';

// describe() groups related tests and names the "unit" under test.
describe('math', () => {
  // Each test() (alias: it()) is one specific expectation.
  test('adds two positive numbers', () => {
    expect(add(1, 2)).toBe(3);
  });

  test('adds negative numbers correctly', () => {
    expect(add(-1, -1)).toBe(-2);
  });

  test('divides evenly', () => {
    expect(divide(6, 2)).toBe(3);
  });

  test('throws when dividing by zero', () => {
    // Note: pass a FUNCTION to expect, so Jest can call it and catch the throw.
    expect(() => divide(5, 0)).toThrow('Cannot divide by zero');
  });
});

Run it with npm test and you'll see something like:

Output

PASS  ./math.test.js
  math
    โœ“ adds two positive numbers (2 ms)
    โœ“ adds negative numbers correctly
    โœ“ divides evenly
    โœ“ throws when dividing by zero (1 ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total

Read the anatomy of that first test out loud: "expect the result of add(1, 2) toBe 3." That readability is the whole point โ€” a good test doubles as documentation.

Test Structure & the AAA Pattern

Well-written tests share a shape, like chapters in a book. Inside every test body, most people follow Arrange, Act, Assert โ€” set up the world, do the thing, then check the result. Separating those three phases (even with blank lines) makes a test instantly scannable.

The Arrange, Act, Assert pattern as three sequential stages 1. Arrange build data, create instances 2. Act call the function being tested 3. Assert expect(...) the outcome
Every unit test tells the same three-act story: set the stage, take the action, verify the result.
test('updating a name leaves the email untouched', () => {
  // Arrange โ€” set up the test data and conditions
  const user = { id: 1, name: 'John Doe', email: 'john@example.com' };

  // Act โ€” execute the code being tested
  const updated = updateProfile(user, { name: 'John Smith' });

  // Assert โ€” check the results
  expect(updated.name).toBe('John Smith');
  expect(updated.email).toBe('john@example.com');
});

๐Ÿ’ก test vs it

Jest gives you both test('...') and it('...') โ€” they are literally the same function. it reads nicely as a sentence: it('returns 0 for an empty string'). Pick one style per project and stay consistent.

Matchers

A matcher is the part after expect(value) that describes what you expect. Each one is like a different lens for inspecting a result. Here are the ones you'll use constantly.

Equality: toBe vs toEqual

// toBe uses Object.is โ€” great for numbers, strings, booleans (primitives).
expect(2 + 2).toBe(4);

// toEqual recursively compares the CONTENTS of objects and arrays.
expect({ name: 'John' }).toEqual({ name: 'John' });
expect([1, 2, 3]).toEqual([1, 2, 3]);

// This is the classic beginner bug:
// expect({ name: 'John' }).toBe({ name: 'John' }); // โŒ FAILS โ€” different objects in memory

โš ๏ธ The #1 matcher mistake

Two objects with identical contents are not the same object. toBe checks identity, so it fails on separate objects even when they look equal. For anything that isn't a primitive, reach for toEqual.

Truthiness & numbers

expect(true).toBeTruthy();
expect(0).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('hi').toBeDefined();

expect(10).toBeGreaterThan(5);
expect(5).toBeLessThanOrEqual(5);
// Floating point? Use toBeCloseTo, never toBe:
expect(0.1 + 0.2).toBeCloseTo(0.3); // โœ… (0.1 + 0.2 is 0.30000000000000004)

Strings, arrays & errors

expect('Hello World').toMatch(/World/);       // regex or substring
expect(['apple', 'banana']).toContain('banana');
expect([1, 2, 3]).toHaveLength(3);

// Testing that code throws โ€” pass a function wrapper:
expect(() => {
  throw new Error('Invalid input');
}).toThrow('Invalid input');

Negation with .not

expect('team').not.toMatch(/I/);
expect([1, 2]).not.toContain(3);

โœ… Custom matchers

When you write the same assertion over and over, teach Jest a new matcher with expect.extend:

expect.extend({
  toBeWithinRange(received, floor, ceiling) {
    const pass = received >= floor && received <= ceiling;
    return {
      pass,
      message: () =>
        `expected ${received} ${pass ? 'not ' : ''}to be within ${floor}โ€“${ceiling}`,
    };
  },
});

test('score is in range', () => {
  expect(100).toBeWithinRange(90, 110);
  expect(101).not.toBeWithinRange(0, 100);
});

Testing Asynchronous Code

Most real code waits on something โ€” a network request, a database, a timer. Testing async code is like ordering a pizza: you have to actually wait for delivery before you check the order. The golden rule is to return or await the promise so Jest knows to wait too.

// A function that resolves or rejects a promise:
function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      id === 1
        ? resolve({ id: 1, name: 'John Doe' })
        : reject(new Error('User not found'));
    }, 50);
  });
}

// Cleanest style: async/await
test('fetches a user by id', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('John Doe');
});

// The .resolves / .rejects helpers read nicely too:
test('resolves to the right object', async () => {
  await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'John Doe' });
});

test('rejects for a missing user', async () => {
  await expect(fetchUser(999)).rejects.toThrow('User not found');
});

โš ๏ธ The silent-pass trap

Forget the await (or return) and Jest finishes the test before the promise settles. The assertion never runs, and the test passes green even though it checked nothing. Always await your async expectations.

Running Tests & Watch Mode

Once tests exist, you'll run them constantly. Three commands cover almost everything.

CommandWhat it doesWhen to use it
npm testRuns every test onceBefore committing; in CI
npm run test:watchRe-runs affected tests on saveWhile actively coding
npm run test:coverageRuns all tests + a coverage tableChecking how much is tested

Watch mode is your day-to-day companion. It watches for file changes and, by default, only re-runs tests related to what you edited. Inside watch mode you can press keys to filter:

Watch Usage

 โ€บ Press a to run all tests.
 โ€บ Press f to run only failed tests.
 โ€บ Press p to filter by a filename regex pattern.
 โ€บ Press t to filter by a test-name regex pattern.
 โ€บ Press q to quit watch mode.
 โ€บ Press Enter to trigger a test run.

A coverage report shows the percentage of statements, branches, functions, and lines your tests exercised โ€” and, crucially, the line numbers they missed:

--------------------|---------|----------|---------|---------|-------------------
File                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files           |   85.71 |      100 |   83.33 |   85.71 |
 math.js            |     100 |      100 |     100 |     100 |
 userService.js     |   71.43 |      100 |      50 |   71.43 | 7-9
--------------------|---------|----------|---------|---------|-------------------

๐Ÿ’ก Coverage is a map, not a grade

100% coverage means every line ran during tests โ€” not that every line is correct. Chase meaningful assertions, not a vanity number. That said, uncovered lines (like 7-9 above) are an honest to-do list.

Practice & Quiz

๐Ÿ‹๏ธ Exercise 1: Test a math module

Goal: Given the module below, write a test suite that covers the happy path and the error case.

// calc.js
export function multiply(a, b) {
  return a * b;
}
export function percentOf(part, whole) {
  if (whole === 0) throw new Error('whole cannot be zero');
  return (part / whole) * 100;
}
๐Ÿ’ก Hint

Group the tests with describe('calc', ...). Use toBe for the numeric results, toBeCloseTo if a result isn't a clean integer, and expect(() => percentOf(1, 0)).toThrow(...) for the error.

โœ… Solution
import { multiply, percentOf } from './calc';

describe('calc', () => {
  test('multiplies two numbers', () => {
    expect(multiply(3, 4)).toBe(12);
  });

  test('computes a percentage', () => {
    expect(percentOf(25, 200)).toBe(12.5);
  });

  test('throws when whole is zero', () => {
    expect(() => percentOf(1, 0)).toThrow('whole cannot be zero');
  });
});

๐Ÿ‹๏ธ Exercise 2: Test an async fetch

Goal: Write two tests for getTitle โ€” one for success, one for a rejected promise. Use a mock function so no real network call happens.

// title.js
export async function getTitle(api, id) {
  const post = await api.fetchPost(id);
  return post.title.toUpperCase();
}
โœ… Solution
import { getTitle } from './title';

test('uppercases the fetched title', async () => {
  const api = { fetchPost: jest.fn().mockResolvedValue({ title: 'hello' }) };
  await expect(getTitle(api, 1)).resolves.toBe('HELLO');
  expect(api.fetchPost).toHaveBeenCalledWith(1);
});

test('propagates a fetch error', async () => {
  const api = { fetchPost: jest.fn().mockRejectedValue(new Error('boom')) };
  await expect(getTitle(api, 1)).rejects.toThrow('boom');
});

๐ŸŽฏ Quick Quiz

Question 1: Which matcher should you use to compare two objects by their contents?

Question 2: How do you assert that a function throws an error?

Question 3: In watch mode, what does an async test with a forgotten await most often do?

Best Practices & Pitfalls

โœ… Do

  • Name tests as behavior sentences: 'throws when dividing by zero'
  • Keep each test focused on one assertion of behavior
  • Use toEqual for objects/arrays, toBe for primitives
  • Always await (or return) async expectations
  • Run watch mode while coding so feedback is instant

โŒ Don't

  • Write test('test 1', ...) โ€” a failing name should explain itself
  • Compare floats with toBe โ€” use toBeCloseTo
  • Call a throwing function directly inside expect()
  • Chase 100% coverage at the cost of meaningful assertions

โš ๏ธ Tests that pass no matter what

The most dangerous test is one that can never fail. A forgotten await, an expect with no matcher, or an assertion inside an if that never runs โ€” all pass green while checking nothing. When you write a test, briefly break the code on purpose and confirm the test goes red. In the next lesson, and with TDD, we make that "see it fail first" step a habit.

Summary

๐ŸŽ‰ Key Takeaways

  • Automated tests are a safety net โ€” unit tests form the fast, plentiful base of the pyramid
  • Jest bundles a runner, assertions, and mocking with near-zero config
  • The core trio is describe / test / expect, structured as Arrange-Act-Assert
  • Pick matchers deliberately: toBe for primitives, toEqual for objects, toThrow for errors, toBeCloseTo for floats
  • Async tests must await the promise, or they pass without checking
  • Live in watch mode while coding; use coverage as a map of what's untested

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

You can now write and run a test. Next we go deeper into the craft: what makes a unit test good โ€” fast, isolated, repeatable โ€” and how to test pure functions, classes, and error paths with confidence. That's Writing Unit Tests.

๐ŸŽ‰ Your safety net is up!

You wrote your first passing tests. From here, every feature you build can be checked automatically โ€” that's the confidence great developers rely on.