Skip to content
Educora
Advanced22 min26 / 27

Testing JavaScript

Prove that your code works: unit tests with Vitest and Jest, `describe`/`it`/`expect`, the main matchers, testing async code and errors, mocks and spies, and what coverage really means.

Check yourself
In this lesson you will learn
  • Write unit tests with describe, it and expect and choose the right matcher (toBe, toEqual, toThrow)
  • Test async code and errors, and isolate code from the outside world with mocks, spies and fake timers
  • Follow good practices: Arrange–Act–Assert, the test pyramid, coverage and TDD

You fix a bug in the discount function — and the checkout page breaks somewhere else. Checking the site by hand after every change takes hours. Automated tests check hundreds of cases in a second, every time you save a file. The most popular test runners for JavaScript are Vitest and Jest; their APIs are almost identical, so what you learn here works in both.

The first unit test

A unit test checks a small piece — usually one function — in isolation. The test file sits next to the code: price.test.js for price.js. describe groups tests, it (or test) describes one behaviour, and expect(value).toBe(expected) makes the check. npx vitest runs the tests and keeps watching the files.

JavaScript
// price.js
export function applyDiscount(price, percent) {
  if (percent < 0 || percent > 100) throw new RangeError('Percent must be 0-100');
  return Math.round(price * (100 - percent)) / 100;
}

// price.test.js
import { describe, it, expect } from 'vitest';
import { applyDiscount } from './price.js';

describe('applyDiscount', () => {
  it('reduces the price by the given percent', () => {
    expect(applyDiscount(200, 10)).toBe(180);
  });

  it('rounds to cents', () => {
    expect(applyDiscount(9.99, 15)).toBe(8.49);
  });

  it('rejects a percent above 100', () => {
    expect(() => applyDiscount(50, 120)).toThrow(RangeError);
  });
});
Expected output
 ✓ price.test.js (3 tests) 3ms

 Test Files  1 passed (1)
      Tests  3 passed (3)
Vitest's output, shortened; the duration differs on every run. When a test fails, the runner shows the expected and received values side by side. For toThrow you must wrap the call in an arrow function — otherwise the error is thrown before expect runs.

How expect works

A test runner is not magic: it calls your function inside try...catch, and expect throws an error when the condition fails. The mini version below shows this and reveals two important differences: toBe compares with Object.is (the same reference), while toEqual compares values; and checking decimals with strict equality is risky.

JavaScript
const results = [];
function it(name, fn) {
  try {
    fn();
    results.push(`✓ ${name}`);
  } catch (error) {
    results.push(`✗ ${name}: ${error.message}`);
  }
}
function expect(actual) {
  const show = (v) => JSON.stringify(v);
  return {
    toBe(expected) {
      if (!Object.is(actual, expected)) throw new Error(`expected ${show(expected)}, got ${show(actual)}`);
    },
    toEqual(expected) {
      if (show(actual) !== show(expected)) throw new Error(`expected ${show(expected)}, got ${show(actual)}`);
    },
  };
}

it('adds numbers', () => expect(2 + 3).toBe(5));
it('compares arrays by value', () => expect([1, 2]).toEqual([1, 2]));
it('toBe needs the same array', () => expect([1, 2]).toBe([1, 2]));
it('decimals are tricky', () => expect(0.1 + 0.2).toBe(0.3));
console.log(results.join('\n'));
▸ Expected output
✓ adds numbers
✓ compares arrays by value
✗ toBe needs the same array: expected [1,2], got [1,2]
✗ decimals are tricky: expected 0.3, got 0.30000000000000004
Real runners have toBeCloseTo(0.3) for decimals. Our toEqual is simplified — Vitest and Jest do deep comparison more carefully.
MatcherWhat it checks
toBe(x)equality by Object.is: primitives, the same reference
toEqual(x)deep equality of objects and arrays
toBeCloseTo(x)approximate equality of decimals
toContain(x), toHaveLength(n)an item in an array or a part of a string; the length
toThrow(Type)that a function throws
resolves / rejectsa promise's result or error
.notnegates any check: expect(x).not.toBe(0)

Async code, mocks and spies

An async test is just an async function: wait for the result with await, or write await expect(promise).rejects.toThrow(). A unit test must not send real emails, call a real server or wait for real time. For this there are mocks (fake functions, vi.fn()), spies (they watch the calls of an existing method, vi.spyOn) and fake timers (vi.useFakeTimers()). The simplest approach is to pass the dependency as a parameter: then in the test you hand in a mock instead of the real function.

JavaScript
// notify.js
export async function notifyUser(user, sendEmail) {
  return sendEmail(user.email, `Welcome, ${user.name}!`);
}

// notify.test.js
import { describe, it, expect, vi } from 'vitest';
import { notifyUser } from './notify.js';

describe('notifyUser', () => {
  it('sends one welcome email', async () => {
    const sendEmail = vi.fn().mockResolvedValue({ ok: true });

    await notifyUser({ email: 'leyla@mail.az', name: 'Leyla' }, sendEmail);

    expect(sendEmail).toHaveBeenCalledTimes(1);
    expect(sendEmail).toHaveBeenCalledWith('leyla@mail.az', 'Welcome, Leyla!');
  });

  it('fails when the mail service is down', async () => {
    const sendEmail = vi.fn().mockRejectedValue(new Error('SMTP down'));
    await expect(notifyUser({ email: 'a@mail.az', name: 'Elvin' }, sendEmail)).rejects.toThrow('SMTP down');
  });
});
Expected output
 ✓ notify.test.js (2 tests) 4ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
In Jest the same code uses jest instead of vi: jest.fn(), jest.spyOn(), jest.useFakeTimers().

Rules of good tests

  • Arrange–Act–Assert: prepare, run, check — every test has these three parts.
  • One test, one behaviour, with a name that says what is expected: “rejects a percent above 100”.
  • Test behaviour, not internal details — then the tests survive a refactoring.
  • The test pyramid: many fast unit tests, fewer integration tests and a few end-to-end tests.
Exercise

The tests are red: isLeapYear has a bug. Without touching the tests, fix the function so all four tests turn green. The rule: a year divisible by 4 is a leap year, but years divisible by 100 are leap years only if they are also divisible by 400.

Exercise · JavaScript
function isLeapYear(year) {
  return year % 4 === 0;
}

const results = [];
function it(name, fn) {
  try {
    fn();
    results.push(`✓ ${name}`);
  } catch (error) {
    results.push(`✗ ${name}: ${error.message}`);
  }
}
const expect = (actual) => ({
  toBe(expected) {
    if (actual !== expected) throw new Error(`expected ${expected}, got ${actual}`);
  },
});

it('2024 is a leap year', () => expect(isLeapYear(2024)).toBe(true));
it('2023 is not a leap year', () => expect(isLeapYear(2023)).toBe(false));
it('1900 is not a leap year', () => expect(isLeapYear(1900)).toBe(false));
it('2000 is a leap year', () => expect(isLeapYear(2000)).toBe(true));
console.log(results.join('\n'));
▸ Expected output
✓ 2024 is a leap year
✓ 2023 is not a leap year
✓ 1900 is not a leap year
✓ 2000 is a leap year
Exercise

Write your own spy: createSpy() returns a function that records its arguments in the spy.calls array whenever it is called. As the check shows, notifyAll must email only subscribers — the number of calls and the arguments of the second call are printed.

Exercise · JavaScript
function createSpy() {
  const spy = (...args) => {
    // record args in spy.calls
  };
  spy.calls = [];
  return spy;
}

function notifyAll(users, send) {
  for (const user of users) {
    if (user.subscribed) send(user.email, 'New lesson is out!');
  }
}

const send = createSpy();
notifyAll([
  { email: 'aysel@mail.az', subscribed: true },
  { email: 'murad@mail.az', subscribed: false },
  { email: 'leyla@mail.az', subscribed: true },
], send);

console.log('calls:', send.calls.length);
console.log(send.calls[1]);
▸ Expected output
calls: 2
[ 'leyla@mail.az', 'New lesson is out!' ]

Key points

  • A unit test checks one small behaviour: describe groups, it describes and expect(...).matcher() checks.
  • toBe compares with Object.is, toEqual by value; use toBeCloseTo for decimals and toThrow for errors.
  • Async tests use await or resolves/rejects; mocks, spies and fake timers isolate code from the network, time and randomness.
  • Arrange–Act–Assert, one behaviour per test, independent tests; many unit tests, fewer integration and end-to-end tests.
  • Coverage only shows which lines ran; TDD means red → green → refactor.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
What is the result of expect([1, 2]).toBe([1, 2])?