JavaScript · Syntax · Advanced

Testing in JavaScript

10 tasks

Write and structure unit tests using Jest/Vitest. Covers describe/it/expect, mocking, async tests, and fake timers.

Why testing, Jest vs Vitest, test structure, lifecycle, key matchers

#
**Why testing matters** Tests are executable documentation. They prove your code does what you think it does, and they catch regressions when you change something later. Without tests, every refactor is a gamble. With tests, you can change internals confidently because the test suite tells you immediately if something broke. **Jest vs Vitest** Both use the same API (`describe`, `it`/`test`, `expect`). Jest is the classic choice (React ecosystem). Vitest is faster (runs in Vite's pipeline, native ESM, no transform step) and is the default for Vite-based projects. All examples below work in both. **Test structure** ```js describe('MyModule', () => { beforeAll(() => { /* runs once before all tests in this describe */ }); afterAll(() => { /* runs once after all tests */ }); beforeEach(() => { /* runs before each test — reset state here */ }); afterEach(() => { /* runs after each test — clean up */ }); it('does X', () => { expect(fn(input)).toBe(expectedValue); }); it('throws on bad input', () => { expect(() => fn(bad)).toThrow('error message'); }); it('async test', async () => { const result = await asyncFn(); expect(result).toEqual({ ok: true }); }); }); ``` `test` and `it` are aliases — use whichever reads better. `describe` groups tests and scopes lifecycle hooks. **Key matchers** | Matcher | Use for | |---|---| | `toBe(x)` | Primitives, same reference (`Object.is`) | | `toEqual(x)` | Deep object/array comparison | | `toStrictEqual(x)` | Like `toEqual` but checks `undefined` properties | | `toContain(x)` | Array includes x; string includes substring | | `toHaveLength(n)` | Array/string length | | `toThrow(msg)` | Function throws (must wrap: `() => fn()`) | | `toBeNull()` / `toBeUndefined()` | Exact null/undefined check | | `toBeCloseTo(n)` | Float comparison (avoids 0.1+0.2 issues) | | `resolves.toBe(x)` | Async resolves to x | | `rejects.toThrow(msg)` | Async rejects |

jest.fn(), spyOn, module mocking, fake timers, test.each

#
**Mocking with `jest.fn()` and `jest.spyOn()`** ```js // Create a standalone mock function const mock = jest.fn(); mock.mockReturnValue(42); // sync return mock.mockResolvedValue({ ok: true }); // Promise.resolve mock.mockRejectedValue(new Error()); // Promise.reject mock.mockImplementation(x => x * 2); // custom logic // Spy on an existing method (keeps original unless overridden) const spy = jest.spyOn(obj, 'method'); spy.mockImplementation(() => 'fake'); spy.mockRestore(); // restore original implementation // Assertions on mocks expect(mock).toHaveBeenCalled(); expect(mock).toHaveBeenCalledTimes(2); expect(mock).toHaveBeenCalledWith(arg1, arg2); expect(mock).toHaveBeenNthCalledWith(1, firstArg); expect(mock).toHaveReturnedWith(value); ``` **Module mocking with `jest.mock()`** ```js // Mock an entire module — all exports become jest.fn() jest.mock('./api'); import { fetchUser } from './api'; // Now configure the mock: fetchUser.mockResolvedValue({ id: 1, name: 'Alice' }); it('loads user', async () => { const user = await loadUserComponent(1); expect(user.name).toBe('Alice'); expect(fetchUser).toHaveBeenCalledWith(1); }); // Partial mock — keep some exports real: jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), // keep real implementations dangerousOp: jest.fn(), // only mock this one })); ``` **Fake timers** ```js beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); it('calls after delay', () => { const fn = jest.fn(); setTimeout(fn, 1000); expect(fn).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); expect(fn).toHaveBeenCalledTimes(1); }); // jest.runAllTimers() — fire all pending timers at once // jest.runAllTimersAsync() — same, for async timers ``` **`test.each` — parametrised tests** ```js test.each([ [1, 1, 2], [2, 3, 5], [-1, 1, 0], ])('sum(%i, %i) = %i', (a, b, expected) => { expect(sum(a, b)).toBe(expected); }); // Table syntax (more readable for many fields): test.each` a | b | expected ${1} | ${1} | ${2} ${2} | ${3} | ${5} `('sum($a, $b) = $expected', ({ a, b, expected }) => { expect(sum(a, b)).toBe(expected); }); ```

AAA pattern, testable code, isolation, common mistakes, CLI reference

#
**AAA — Arrange, Act, Assert** Structure every test in three clear phases: ```js it('returns doubled value', () => { // Arrange — set up data and mocks const input = 21; const double = jest.fn().mockReturnValue(42); // Act — run the code under test const result = double(input); // Assert — check the output expect(result).toBe(42); expect(double).toHaveBeenCalledWith(21); }); ``` **What makes code testable** - **Inject dependencies** — accept them as arguments, not hardcoded `import` - **Pure functions first** — same input → same output, easy to unit-test - **Small, focused functions** — one responsibility, easier to isolate - **Avoid global state** — shared mutable state causes test order sensitivity ```js // Hard to test — hardcoded dependency async function getUser(id) { return fetch(`/api/users/${id}`).then(r => r.json()); } // Easy to test — injected dependency async function getUser(id, fetcher = fetch) { return fetcher(`/api/users/${id}`).then(r => r.json()); } // In test: const mockFetch = jest.fn().mockResolvedValue({ json: () => ({ id: 1 }) }); await getUser(1, mockFetch); ``` **Common mistakes** - Forgetting `await` in async tests — test passes vacuously, never checks anything - Using `toBe` for objects — always fails (reference check); use `toEqual` - Not calling `spy.mockRestore()` — pollutes later tests in the same suite - Not wrapping `toThrow` target in `() =>`: `expect(fn(x)).toThrow()` evaluates `fn(x)` before expect sees it — wrap as `expect(() => fn(x)).toThrow()` - Testing implementation details (internal function calls) instead of behaviour (inputs → outputs) — makes tests brittle to refactors **Test isolation — test.skip, test.only** ```js test.skip('not ready yet', () => { /* skipped */ }); test.only('focus on this', () => { /* only this runs in the file */ }); describe.only('block', () => { /* only this describe block runs */ }); ``` **Jest/Vitest CLI quick reference** | Command | What it does | |---|---| | `jest` / `vitest` | Run all tests | | `jest path/to/file` | Run one file | | `jest -t 'pattern'` | Run tests matching name | | `jest --watch` | Re-run on file change | | `jest --coverage` | Show coverage report | | `vitest --ui` | Open browser UI |
01

describe / it / expect: basic test structure

#

Write a `sum(a, b)` function and a full test suite for it. Use `describe` to group tests, `it` (or `test`) for each case, and `expect(...).toBe(...)` for assertions. Cover: positive numbers, zero, negative numbers.

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

// sum.test.js
describe('sum', () => {
  it('adds two positive numbers', () => {
    // write expect
  });

  it('returns the number when adding zero', () => {
    // write expect
  });

  it('handles negative numbers', () => {
    // write expect
  });
});
Solution
function sum(a, b) {
  return a + b;
}

describe('sum', () => {
  it('adds two positive numbers', () => {
    expect(sum(2, 3)).toBe(5);
  });

  it('returns the number when adding zero', () => {
    expect(sum(5, 0)).toBe(5);
    expect(sum(0, 5)).toBe(5);
  });

  it('handles negative numbers', () => {
    expect(sum(-1, -2)).toBe(-3);
    expect(sum(-1, 1)).toBe(0);
  });
});
02

expect matchers: toBe, toEqual, toContain, toThrow

#

Write tests demonstrating four matchers: `toBe` (primitive equality), `toEqual` (deep object equality), `toContain` (array/string inclusion), `toThrow` (function throws). Use a `getUser()` function that returns an object and a `divide(a, b)` that throws on zero.

function getUser() {
  return { name: 'Alice', age: 30, tags: ['admin', 'user'] };
}

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

describe('matchers', () => {
  it('toBe: strict equality', () => { /* */ });
  it('toEqual: deep object equality', () => { /* */ });
  it('toContain: array inclusion', () => { /* */ });
  it('toThrow: function throws', () => { /* */ });
});
Solution
function getUser() {
  return { name: 'Alice', age: 30, tags: ['admin', 'user'] };
}

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

describe('matchers', () => {
  it('toBe: strict equality', () => {
    expect(2 + 2).toBe(4);
    expect('hello').toBe('hello');
  });

  it('toEqual: deep object equality', () => {
    expect(getUser()).toEqual({ name: 'Alice', age: 30, tags: ['admin', 'user'] });
  });

  it('toContain: array inclusion', () => {
    expect(getUser().tags).toContain('admin');
    expect('hello world').toContain('world');
  });

  it('toThrow: function throws', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });
});
03

jest.fn(): mock functions

#

Create a `UserService` class with a `notify(user)` method. In the test, mock the `notify` method with `jest.fn()`. Call `notify` twice and assert: `toHaveBeenCalled`, `toHaveBeenCalledTimes(2)`, `toHaveBeenCalledWith` on specific args.

class UserService {
  notify(user) {
    // sends an email (side-effect we want to mock)
    console.log(`Notifying ${user.name}`);
  }

  register(user) {
    this.notify(user);
    return { ...user, registered: true };
  }
}

describe('UserService', () => {
  it('calls notify when registering a user', () => {
    const service = new UserService();
    service.notify = jest.fn();

    service.register({ name: 'Alice' });
    service.register({ name: 'Bob' });

    // assert: notify was called
    // assert: notify was called twice
    // assert: first call had { name: 'Alice' }
  });
});
Solution
class UserService {
  notify(user) { console.log(`Notifying ${user.name}`); }

  register(user) {
    this.notify(user);
    return { ...user, registered: true };
  }
}

describe('UserService', () => {
  it('calls notify when registering a user', () => {
    const service = new UserService();
    service.notify = jest.fn();

    service.register({ name: 'Alice' });
    service.register({ name: 'Bob' });

    expect(service.notify).toHaveBeenCalled();
    expect(service.notify).toHaveBeenCalledTimes(2);
    expect(service.notify).toHaveBeenCalledWith({ name: 'Alice' });
    expect(service.notify).toHaveBeenNthCalledWith(2, { name: 'Bob' });
  });
});
04

Mock return values: mockReturnValue and mockResolvedValue

#

Write a `fetchPrice(productId)` async function that calls an `api.getProduct` method. In the test, replace `api.getProduct` with a `jest.fn()` that returns a resolved promise. Assert the function returns the correct price from the mocked response.

// price.js
async function fetchPrice(api, productId) {
  const product = await api.getProduct(productId);
  return product.price;
}

// price.test.js
describe('fetchPrice', () => {
  it('returns the price from the API response', async () => {
    const mockApi = {
      getProduct: jest.fn().mockResolvedValue({ id: 1, price: 9.99 }),
    };

    const price = await fetchPrice(mockApi, 1);
    // assert price
    // assert getProduct was called with 1
  });
});
Solution
async function fetchPrice(api, productId) {
  const product = await api.getProduct(productId);
  return product.price;
}

describe('fetchPrice', () => {
  it('returns the price from the API response', async () => {
    const mockApi = {
      getProduct: jest.fn().mockResolvedValue({ id: 1, price: 9.99 }),
    };

    const price = await fetchPrice(mockApi, 1);

    expect(price).toBe(9.99);
    expect(mockApi.getProduct).toHaveBeenCalledWith(1);
  });
});
05

Async tests: testing async functions

#

Write an async function `loadUser(id)` that fetches from a mock API. Write an async test (using `async/await`) that mocks the fetch, calls `loadUser`, and asserts the result. Also write a test that asserts it throws on a bad ID.

async function loadUser(id, fetch) {
  const res = await fetch(`/users/${id}`);
  if (!res.ok) throw new Error('User not found');
  return res.json();
}

describe('loadUser', () => {
  it('returns user data on success', async () => {
    const mockFetch = jest.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ id: 1, name: 'Alice' }),
    });
    // call loadUser, assert result
  });

  it('throws when response is not ok', async () => {
    const mockFetch = jest.fn().mockResolvedValue({ ok: false });
    // assert it rejects/throws
  });
});
Solution
async function loadUser(id, fetch) {
  const res = await fetch(`/users/${id}`);
  if (!res.ok) throw new Error('User not found');
  return res.json();
}

describe('loadUser', () => {
  it('returns user data on success', async () => {
    const mockFetch = jest.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ id: 1, name: 'Alice' }),
    });
    const user = await loadUser(1, mockFetch);
    expect(user).toEqual({ id: 1, name: 'Alice' });
  });

  it('throws when response is not ok', async () => {
    const mockFetch = jest.fn().mockResolvedValue({ ok: false });
    await expect(loadUser(999, mockFetch)).rejects.toThrow('User not found');
  });
});
06

Fake timers: test setTimeout/setInterval

#

Write a `delay(ms)` function that returns a Promise resolving after `ms` milliseconds. Write a test using `jest.useFakeTimers()` and `jest.runAllTimers()` that resolves the delay without actually waiting.

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

describe('delay', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  it('resolves after the given delay', async () => {
    let resolved = false;
    delay(1000).then(() => { resolved = true; });

    expect(resolved).toBe(false);
    // advance timers
    // now resolved should be true
  });
});
Solution
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

describe('delay', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  it('resolves after the given delay', async () => {
    let resolved = false;
    const p = delay(1000).then(() => { resolved = true; });

    expect(resolved).toBe(false);
    jest.runAllTimers();     // fast-forward all pending timers
    await p;                 // wait for microtasks to flush
    expect(resolved).toBe(true);
  });
});
07

beforeEach / afterEach: test setup and teardown

#

Write a `Counter` class with `increment()`, `decrement()`, and `value` getter. Use `beforeEach` to create a fresh counter before each test. Write three tests: increment, decrement, and reset. Show that tests are isolated — each starts from zero.

class Counter {
  constructor() { this._value = 0; }
  increment() { this._value++; }
  decrement() { this._value--; }
  reset() { this._value = 0; }
  get value() { return this._value; }
}

describe('Counter', () => {
  let counter;

  beforeEach(() => {
    // create fresh counter
  });

  it('increments', () => { /* */ });
  it('decrements', () => { /* */ });
  it('resets', () => { /* */ });
});
Solution
class Counter {
  constructor() { this._value = 0; }
  increment() { this._value++; }
  decrement() { this._value--; }
  reset() { this._value = 0; }
  get value() { return this._value; }
}

describe('Counter', () => {
  let counter;

  beforeEach(() => {
    counter = new Counter();
  });

  it('increments', () => {
    counter.increment();
    counter.increment();
    expect(counter.value).toBe(2);
  });

  it('decrements', () => {
    counter.increment();
    counter.decrement();
    expect(counter.value).toBe(0);
  });

  it('resets to zero', () => {
    counter.increment();
    counter.increment();
    counter.reset();
    expect(counter.value).toBe(0);
  });
});
08

jest.spyOn: spy without replacing

#

Write a `Logger` class with a `log(msg)` method that also calls `console.log`. Use `jest.spyOn(console, 'log')` to spy on `console.log` without replacing it. Assert it was called with the right message. Restore the original in `afterEach`.

class Logger {
  log(msg) {
    console.log(`[LOG] ${msg}`);
  }
}

describe('Logger', () => {
  let spy;

  beforeEach(() => {
    spy = jest.spyOn(console, 'log');
  });

  afterEach(() => {
    spy.mockRestore();
  });

  it('logs with the [LOG] prefix', () => {
    const logger = new Logger();
    logger.log('hello');
    // assert console.log was called with '[LOG] hello'
  });
});
Solution
class Logger {
  log(msg) { console.log(`[LOG] ${msg}`); }
}

describe('Logger', () => {
  let spy;

  beforeEach(() => {
    spy = jest.spyOn(console, 'log').mockImplementation(() => {});
  });

  afterEach(() => {
    spy.mockRestore();
  });

  it('logs with the [LOG] prefix', () => {
    const logger = new Logger();
    logger.log('hello');
    expect(spy).toHaveBeenCalledWith('[LOG] hello');
  });
});
09

test.each: parametrised tests

#

Write a `isPalindrome(str)` function. Use `test.each` to run the same assertion for multiple inputs: `'racecar'`, `'hello'`, `'level'`, `'world'`, `'noon'`. The test table should include the input string and expected boolean result.

function isPalindrome(str) {
  const clean = str.toLowerCase().replace(/[^a-z0-9]/g, '');
  return clean === clean.split('').reverse().join('');
}

describe('isPalindrome', () => {
  test.each([
    // [input, expected]
  ])('%s => %s', (input, expected) => {
    expect(isPalindrome(input)).toBe(expected);
  });
});
Solution
function isPalindrome(str) {
  const clean = str.toLowerCase().replace(/[^a-z0-9]/g, '');
  return clean === clean.split('').reverse().join('');
}

describe('isPalindrome', () => {
  test.each([
    ['racecar', true],
    ['hello',   false],
    ['level',   true],
    ['world',   false],
    ['noon',    true],
  ])('%s => %s', (input, expected) => {
    expect(isPalindrome(input)).toBe(expected);
  });
});
10

Dependency injection: testable code architecture

#

Refactor a `sendEmail(to, subject)` function to be testable WITHOUT mocking globals. Instead of calling `nodemailer.sendMail` directly, accept a `mailer` dependency. Write a test that passes a mock mailer and asserts it was called correctly. This is the key pattern that makes code testable.

// hard to test — calls global nodemailer directly:
// async function sendEmail(to, subject) {
//   await nodemailer.sendMail({ to, subject });
// }

// testable version: accept mailer as dependency
async function sendEmail(mailer, to, subject) {
  await mailer.sendMail({ to, subject });
}

describe('sendEmail', () => {
  it('calls sendMail with correct params', async () => {
    const mockMailer = { sendMail: jest.fn().mockResolvedValue(undefined) };

    await sendEmail(mockMailer, '[email protected]', 'Hello');

    // assert mockMailer.sendMail was called with the right object
  });
});
Solution
async function sendEmail(mailer, to, subject) {
  await mailer.sendMail({ to, subject });
}

describe('sendEmail', () => {
  it('calls sendMail with correct params', async () => {
    const mockMailer = { sendMail: jest.fn().mockResolvedValue(undefined) };

    await sendEmail(mockMailer, '[email protected]', 'Hello');

    expect(mockMailer.sendMail).toHaveBeenCalledWith({
      to: '[email protected]',
      subject: 'Hello',
    });
  });
});