Skip to main content
JavaScript · lesson 20 of 24
JavaScript intermediate Lesson 20 of 24

Also part of: Software Testing

Testing JavaScript with Jest and Vitest

Learn how to write reliable unit tests in JavaScript using Jest and Vitest — from basic assertions to mocking, async tests, and code coverage.

By SkillByExample · Updated

A test is a small program that runs your code and checks the result, so a machine — not your memory — notices when behaviour changes. The examples use Vitest, whose API matches Jest’s almost exactly (vi.fn() ↔ jest.fn()). Every output block below is a real npx vitest run in a project where npm install --save-dev vitest has been run.

The testing pyramid is a useful budget: many fast unit tests, fewer integration tests, very few end-to-end tests. This lesson is about the unit-test layer.

describe / it / expect

describe groups tests, it names one behaviour, expect(...) asserts.

// math.js
export function add(a, b) { return a + b; }
export function divide(a, b) {
  if (b === 0) throw new Error("Division by zero");
  return a / b;
}
// math.test.js
import { describe, it, expect } from "vitest";
import { add, divide } from "./math.js";

describe("add", () => {
  it("adds two positive numbers", () => expect(add(2, 3)).toBe(5));
  it("handles negative numbers", () => expect(add(-1, -1)).toBe(-2));
});

describe("divide", () => {
  it("divides", () => expect(divide(10, 4)).toBe(2.5));
  it("throws on division by zero", () => {
    expect(() => divide(10, 0)).toThrow("Division by zero");
  });
});
$ npx vitest run basics --reporter=verbose

 RUN  v5.0.3 /root/my-project

 ✓ basics/math.test.js > add > adds two positive numbers 2ms
 ✓ basics/math.test.js > add > handles negative numbers 0ms
 ✓ basics/math.test.js > divide > divides 0ms
 ✓ basics/math.test.js > divide > throws on division by zero 1ms

 Test Files  1 passed (1)
      Tests  4 passed (4)
   Start at  18:29:40
   Duration  211ms (transform 47%, import 37%, tests 9%, worker 7%)

--reporter=verbose lists every test by its describe › it path, which is why test names should read as sentences about behaviour. Note toThrow takes a function — expect(divide(10, 0)) would throw before expect ever ran.

Matchers, and What a Failure Looks Like

Picking the right matcher gives a precise failure message. This file has one deliberately wrong assertion.

// matchers.test.js
import { it, expect } from "vitest";

it("passes with the right matchers", () => {
  expect({ a: 1, b: [2] }).toEqual({ a: 1, b: [2] });
  expect(0.1 + 0.2).toBeCloseTo(0.3);
  expect("hello world").toMatch(/world/);
  expect([1, 2, 3]).toContain(2);
  expect([1, 2, 3]).toHaveLength(3);
  expect({ id: 1, name: "Alice", role: "admin" }).toMatchObject({ name: "Alice" });
  expect(null).toBeNull();
  expect(() => JSON.parse("{")).toThrow(SyntaxError);
});

it("fails: toBe on objects compares identity", () => {
  expect({ a: 1 }).toBe({ a: 1 });
});
$ npx vitest run matchers

 RUN  v5.0.3 /root/my-project

 ❯ matchers/matchers.test.js (2 tests | 1 failed) 13ms
   × fails: toBe on objects compares identity 6ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  matchers/matchers.test.js > fails: toBe on objects compares identity
AssertionError: expected { a: 1 } to be { a: 1 } // Object.is equality

If it should pass with deep equality, replace "toBe" with "toStrictEqual"

Expected: { a: 1 }
Received: serializes to the same string


Compared values have no visual difference.

 ❯ matchers/matchers.test.js:16:20
     14|
     15| it("fails: toBe on objects compares identity", () => {
     16|   expect({ a: 1 }).toBe({ a: 1 });
       |                    ^
     17| });
     18|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
   Start at  18:29:41
   Duration  231ms (transform 40%, import 25%, tests 24%, worker 11%)

toBe is Object.is — identity for objects — so two separate { a: 1 } objects fail even though they look equal; Vitest even suggests toStrictEqual. Use toBe for primitives and toEqual/toStrictEqual for structures. toBeCloseTo exists because 0.1 + 0.2 is not exactly 0.3. The failure report shows the assertion, a diff and the source line — this is why specific matchers beat expect(x === y).toBe(true), whose failure would only say “expected false to be true”.

Mocking a Module

To test code in isolation, replace its dependencies. vi.mock swaps a whole module for auto-mocked functions.

// service.js
export async function fetchUser(id) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  return res.json();
}
// greeter.js
import { fetchUser } from "./service.js";
export async function greetUser(id) {
  const user = await fetchUser(id);
  return `Hello, ${user.name}!`;
}
// greeter.test.js
import { describe, it, expect, vi, beforeEach } from "vitest";
import { greetUser } from "./greeter.js";
import { fetchUser } from "./service.js";

vi.mock("./service.js");
beforeEach(() => vi.resetAllMocks());

describe("greetUser", () => {
  it("greets the user by name", async () => {
    fetchUser.mockResolvedValue({ id: 1, name: "Alice" });
    await expect(greetUser(1)).resolves.toBe("Hello, Alice!");
    expect(fetchUser).toHaveBeenCalledWith(1);
    expect(fetchUser).toHaveBeenCalledTimes(1);
  });

  it("propagates fetch errors", async () => {
    fetchUser.mockRejectedValue(new Error("Network error"));
    await expect(greetUser(1)).rejects.toThrow("Network error");
  });
});
$ npx vitest run mocking --reporter=verbose

 RUN  v5.0.3 /root/my-project

 ✓ mocking/greeter.test.js > greetUser > greets the user by name 4ms
 ✓ mocking/greeter.test.js > greetUser > propagates fetch errors 2ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:29:42
   Duration  241ms (transform 56%, import 28%, tests 11%, worker 5%)

No network request was made: vi.mock is hoisted above the imports, so greeter.js received the mocked fetchUser. Each test sets its own behaviour, and vi.resetAllMocks() in beforeEach stops call counts and return values leaking from one test into the next. Mock at boundaries you don’t control — network, filesystem, clock — and let your own code run for real.

vi.fn and vi.spyOn

vi.fn() makes a standalone function that records its calls; vi.spyOn wraps a method on an existing object.

// fns.test.js
import { it, expect, vi } from "vitest";
import fs from "node:fs";

const processItems = (items, keep) => items.filter((item) => keep(item));
const loadConfig = (path) => JSON.parse(fs.readFileSync(path, "utf8"));

it("records every call to a callback", () => {
  const keep = vi.fn((item) => item > 2);
  expect(processItems([1, 2, 3, 4], keep)).toEqual([3, 4]);
  expect(keep).toHaveBeenCalledTimes(4);
  expect(keep).toHaveBeenNthCalledWith(1, 1);
  expect(keep.mock.results.map((r) => r.value)).toEqual([false, false, true, true]);
});

it("replaces fs.readFileSync for one test", () => {
  const spy = vi.spyOn(fs, "readFileSync").mockReturnValue('{ "port": 3000 }');
  expect(loadConfig("/etc/app/config.json").port).toBe(3000);
  expect(spy).toHaveBeenCalledWith("/etc/app/config.json", "utf8");
  spy.mockRestore();
  expect(() => loadConfig("/etc/app/config.json")).toThrow("ENOENT");
});
$ npx vitest run fns --reporter=verbose

 RUN  v5.0.3 /root/my-project

 ✓ fns/fns.test.js > records every call to a callback 4ms
 ✓ fns/fns.test.js > replaces fs.readFileSync for one test 1ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:29:44
   Duration  235ms (import 40%, transform 37%, tests 12%, worker 10%)

keep.mock.results shows what every call returned — handy when a callback misbehaves. The spy test never touched the disk: /etc/app/config.json doesn’t exist, as the last assertion proves once mockRestore() puts the real readFileSync back. Forgetting mockRestore would leave the fake in place for later tests in the same file.

Async Code and Fake Timers

Return or await every promise you assert on. For time-based code, fake timers let a test “wait” 300 ms instantly.

// async.test.js
import { it, expect, vi } from "vitest";

const validateEmail = async (s) => { if (!s.includes("@")) throw new Error("Invalid email"); return s; };
function debounce(fn, delay) {
  let t;
  return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); };
}

it("awaits resolves/rejects", async () => {
  await expect(validateEmail("a@b.co")).resolves.toBe("a@b.co");
  await expect(validateEmail("nope")).rejects.toThrow("Invalid email");
});

it("debounce fires once, after the delay", () => {
  vi.useFakeTimers();
  const fn = vi.fn();
  const debounced = debounce(fn, 300);
  debounced("a"); debounced("b"); debounced("c");
  expect(fn).not.toHaveBeenCalled();
  vi.advanceTimersByTime(299);
  expect(fn).not.toHaveBeenCalled();
  vi.advanceTimersByTime(1);
  expect(fn).toHaveBeenCalledExactlyOnceWith("c");
  vi.useRealTimers();
});
$ npx vitest run async --reporter=verbose

 RUN  v5.0.3 /root/my-project

 ✓ async/async.test.js > awaits resolves/rejects 3ms
 ✓ async/async.test.js > debounce fires once, after the delay 4ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:29:45
   Duration  198ms (transform 45%, import 28%, tests 18%, worker 9%)

The fake-timer test checks the boundary exactly: nothing at 299 ms, one call with the last argument at 300 ms — and it ran in milliseconds instead of waiting.

The un-awaited assertion

Forgetting await is the classic async-test bug. Here are two versions of it:

// forgot-await.test.js
import { it, expect } from "vitest";

const saveUser = (name) =>
  new Promise((resolve) => setTimeout(() => resolve({ name: name.toUpperCase() }), 50));

it("BUG 1: un-awaited .resolves", () => {
  expect(saveUser("bob")).resolves.toEqual({ name: "bob" });
});

it("BUG 2: assertion inside an un-awaited .then", () => {
  saveUser("bob").then((user) => expect(user.name).toBe("bob"));
});
$ npx vitest run pitfall --reporter=verbose

 RUN  v5.0.3 /root/my-project

 × pitfall/forgot-await.test.js > BUG 1: un-awaited .resolves 63ms
   → expected { name: 'BOB' } to deeply equal { name: 'bob' }
   → Promise returned by `expect(actual).resolves.toEqual(expected)` was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes:

await expect(actual).resolves.toEqual(expected)

 ✓ pitfall/forgot-await.test.js > BUG 2: assertion inside an un-awaited .then 0ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  pitfall/forgot-await.test.js > BUG 1: un-awaited .resolves
AssertionError: expected { name: 'BOB' } to deeply equal { name: 'bob' }

- Expected
+ Received

  {
-   "name": "bob",
+   "name": "BOB",
  }

 ❯ pitfall/forgot-await.test.js:8:26
      6|
      7| it("BUG 1: un-awaited .resolves", () => {
      8|   expect(saveUser("bob")).resolves.toEqual({ name: "bob" });
       |                          ^
      9| });
     10|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL  pitfall/forgot-await.test.js > BUG 1: un-awaited .resolves
Error: Promise returned by `expect(actual).resolves.toEqual(expected)` was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes:

await expect(actual).resolves.toEqual(expected)

 ❯ pitfall/forgot-await.test.js:8:26
      6|
      7| it("BUG 1: un-awaited .resolves", () => {
      8|   expect(saveUser("bob")).resolves.toEqual({ name: "bob" });
       |                          ^
      9| });
     10|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
   Start at  18:29:46
   Duration  347ms (tests 58%, transform 24%, import 12%, worker 6%)

Both assertions are wrong — saveUser upper-cases the name — but only the first test failed. Vitest 5 tracks expect(...).resolves/.rejects promises and fails a test that returns without awaiting one. The second test passed: the test function returned immediately, and by the time the .then callback ran its failing expect, nothing was listening. Any assertion inside a callback must be reached through an await (or a returned promise): const user = await saveUser("bob"); expect(user.name).toBe("bob");.

Setup and Teardown

beforeAll/afterAll run once per describe block; beforeEach/afterEach wrap every test.

// hooks.test.js
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";

const log = [];
describe("users table", () => {
  let db;
  beforeAll(() => { db = new Map(); log.push("open db"); });
  afterAll(() => { log.push("close db"); console.log(log.join(" -> ")); });
  beforeEach(() => { db.set("seed", { name: "Seed" }); log.push("seed"); });
  afterEach(() => { db.clear(); log.push("truncate"); });

  it("inserts a user", () => {
    db.set("alice", { name: "Alice" });
    expect(db.size).toBe(2);
  });
  it("starts from a clean table", () => {
    expect([...db.keys()]).toEqual(["seed"]);
  });
});
$ npx vitest run hooks --reporter=verbose

 RUN  v5.0.3 /root/my-project

stdout | hooks/hooks.test.js > users table
open db -> seed -> truncate -> seed -> truncate -> close db

 ✓ hooks/hooks.test.js > users table > inserts a user 2ms
 ✓ hooks/hooks.test.js > users table > starts from a clean table 2ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:29:47
   Duration  209ms (transform 47%, import 28%, tests 15%, worker 10%, environment 1%)

The log shows the order: one open, then seed → test → truncate around each test, then one close. The second test sees only the seed row even though the first test inserted Alice — that isolation is what lets tests run in any order. (Vitest shows console.log output from tests under a stdout | label.)

Snapshot Tests

A snapshot saves a value’s serialised form on the first run and fails if it later changes.

// card.js
export const renderUserCard = ({ name, role }) =>
  `<div class="card">\n  <h2>${name}</h2>\n  <span class="badge">${role}</span>\n</div>`;
// card.test.js
import { it, expect } from "vitest";
import { renderUserCard } from "./card.js";

it("renders the user card", () => {
  expect(renderUserCard({ name: "Alice", role: "admin" })).toMatchSnapshot();
});
$ npx vitest run snap && cat snap/__snapshots__/card.test.js.snap

 RUN  v5.0.3 /root/my-project


  Snapshots  1 written
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  18:29:48
   Duration  253ms (transform 51%, import 28%, worker 13%, tests 8%)

// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`renders the user card 1`] = `
"<div class="card">
  <h2>Alice</h2>
  <span class="badge">admin</span>
</div>"
`;

The first run wrote the snapshot file instead of comparing. Commit it with the test. Now change the markup and run again:

$ sed -i 's/class="badge"/class="pill"/' snap/card.js && npx vitest run snap

 RUN  v5.0.3 /root/my-project

 ❯ snap/card.test.js (1 test | 1 failed) 11ms
   × renders the user card 9ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  snap/card.test.js > renders the user card
Error: Snapshot `renders the user card 1` mismatched

- Expected
+ Received

  "<div class="card">
    <h2>Alice</h2>
-   <span class="badge">admin</span>
+   <span class="pill">admin</span>
  </div>"

 ❯ snap/card.test.js:6:60
      4|
      5| it("renders the user card", () => {
      6|   expect(renderUserCard({ name: "Alice", role: "admin" })).toMatchSnap…
       |                                                            ^
      7| });
      8|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


  Snapshots  1 failed
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  18:29:49
   Duration  255ms (transform 42%, import 34%, tests 14%, worker 9%)

The diff pinpoints the change. If it was intended, npx vitest run -u rewrites the snapshot. That’s also the weakness: a snapshot says something changed, not whether the change is correct, and it’s tempting to press -u without reading. Keep snapshots small and review their diffs like code.

Coverage

Coverage reports which lines and branches your tests executed. It finds untested code; it can’t tell you the tests are good. (npm install --save-dev @vitest/coverage-v8 first.)

// shipping.js
export function shippingCost(total, country) {
  if (total >= 100) return 0;
  if (country === "US") return 5;
  if (country === "CA") return 8;
  return 15;
}
// shipping.test.js
import { it, expect } from "vitest";
import { shippingCost } from "./shipping.js";

it("free over 100", () => expect(shippingCost(150, "US")).toBe(0));
it("US rate", () => expect(shippingCost(20, "US")).toBe(5));
$ npx vitest run cov --coverage --coverage.include=cov/shipping.js

 RUN  v5.0.3 /root/my-project
      Coverage enabled with v8


 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:29:50
   Duration  358ms (transform 43%, worker 32%, import 20%, tests 5%)

 % Coverage report from v8
-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files    |   57.14 |       50 |     100 |      50 |
 shipping.js |   57.14 |       50 |     100 |      50 | 5-6
-------------|---------|----------|---------|---------|-------------------

=============================== Coverage summary ===============================
Statements   : 57.14% ( 4/7 )
Branches     : 50% ( 3/6 )
Functions    : 100% ( 1/1 )
Lines        : 50% ( 2/4 )
================================================================================

Two tests, and the report names exactly what they missed: lines 5–6 of the file (counting the // shipping.js comment as line 1) — the Canada rate and the international fallback. Coverage is a map of blind spots, not a score to maximise — a test that calls shippingCost(20, "CA") without an expect would turn those lines green while checking nothing.

Jest vs Vitest

JestVitest
Mock APIjest.fn(), jest.mock()vi.fn(), vi.mock()
ESMneeds configurationnative
Configjest.config.jsreuses vite.config.*
Globalson by defaultimport from "vitest" (or globals: true)

The matcher APIs are nearly identical; moving between them is mostly jest. → vi.. Node also ships a zero-dependency runner, node --test with node:assert, which is fine for small libraries.

Common Pitfalls

  • Un-awaited async assertions can pass when they should fail — see above.
  • Shared state between tests — reset mocks and data in beforeEach.
  • Testing implementation details — assert on outputs and side effects, not on private helpers; tests tied to internals break on every refactor.
  • Over-mocking — mock the boundary (network, disk, time), not your own modules, or you end up testing the mocks.

Practice

1. Write tests for clamp(n, min, max) covering below, inside and above the range.
// clamp.test.js
import { it, expect } from "vitest";
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);

it.each([
  [-5, 0, 10, 0],
  [5, 0, 10, 5],
  [50, 0, 10, 10],
])("clamp(%i, %i, %i) is %i", (n, min, max, expected) => {
  expect(clamp(n, min, max)).toBe(expected);
});
$ npx vitest run p1 --reporter=verbose

 RUN  v5.0.3 /root/my-project

 ✓ p1/clamp.test.js > clamp(-5, 0, 10) is 0 3ms
 ✓ p1/clamp.test.js > clamp(5, 0, 10) is 5 0ms
 ✓ p1/clamp.test.js > clamp(50, 0, 10) is 10 0ms

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  18:29:51
   Duration  216ms (import 49%, transform 35%, tests 8%, worker 7%)

it.each generates one named test per row, so a failure tells you exactly which case broke.

2. Mock Date so a greeting function can be tested at 9 am and 9 pm.
// greet.test.js
import { it, expect, vi, afterEach } from "vitest";
const greeting = () => (new Date().getHours() < 12 ? "Good morning" : "Good evening");
afterEach(() => vi.useRealTimers());

it("morning", () => {
  vi.useFakeTimers({ now: new Date(2026, 0, 1, 9, 0) });
  expect(greeting()).toBe("Good morning");
});
it("evening", () => {
  vi.useFakeTimers({ now: new Date(2026, 0, 1, 21, 0) });
  expect(greeting()).toBe("Good evening");
});
$ npx vitest run p2 --reporter=verbose

 RUN  v5.0.3 /root/my-project

 ✓ p2/greet.test.js > morning 5ms
 ✓ p2/greet.test.js > evening 1ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  18:29:52
   Duration  248ms (import 43%, transform 40%, tests 10%, worker 7%)

Fake timers also freeze Date, so the clock becomes an input you control instead of a source of flaky tests.

3. Fix BUG 2 from the async section and confirm it now fails, as it should.
// fixed.test.js
import { it, expect } from "vitest";
const saveUser = (name) =>
  new Promise((resolve) => setTimeout(() => resolve({ name: name.toUpperCase() }), 50));

it("assertion reached through await", async () => {
  const user = await saveUser("bob");
  expect(user.name).toBe("bob");
});
$ npx vitest run p3

 RUN  v5.0.3 /root/my-project

 ❯ p3/fixed.test.js (1 test | 1 failed) 65ms
   × assertion reached through await 63ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  p3/fixed.test.js > assertion reached through await
AssertionError: expected 'BOB' to be 'bob' // Object.is equality

Expected: "bob"
Received: "BOB"

 ❯ p3/fixed.test.js:8:21
      6| it("assertion reached through await", async () => {
      7|   const user = await saveUser("bob");
      8|   expect(user.name).toBe("bob");
       |                     ^
      9| });
     10|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  18:29:54
   Duration  302ms (tests 48%, import 26%, transform 21%, worker 4%)

With await, the wrong assertion finally fails — proving the test can detect the bug it was written for. Always watch a new test fail once.

4. Add the missing tests so shipping.js reaches 100% coverage.
// shipping.test.js
import { it, expect } from "vitest";
import { shippingCost } from "../cov/shipping.js";

it.each([
  [150, "US", 0], [20, "US", 5], [20, "CA", 8], [20, "FR", 15],
])("shippingCost(%i, %s) = %i", (total, country, cost) => {
  expect(shippingCost(total, country)).toBe(cost);
});
$ npx vitest run p4 --coverage --coverage.include=cov/shipping.js

 RUN  v5.0.3 /root/my-project
      Coverage enabled with v8


 Test Files  1 passed (1)
      Tests  4 passed (4)
   Start at  18:29:55
   Duration  402ms (transform 48%, worker 26%, import 22%, tests 4%)

 % Coverage report from v8
No files with missing coverage.
1 file fully covered.

=============================== Coverage summary ===============================
Statements   : 100% ( 7/7 )
Branches     : 100% ( 6/6 )
Functions    : 100% ( 1/1 )
Lines        : 100% ( 4/4 )
================================================================================

Every line and branch executed — and, more importantly, every result was asserted.

Frequently Asked Questions

What is the difference between Jest and Vitest?
Jest is the long-standing standard, built by Meta, and works well in Node projects and React apps (via Create React App / Next.js). Vitest is a newer test runner built on top of Vite — it reuses your Vite config, runs faster due to native ESM support, and has a nearly identical API to Jest. For Vite-based projects (SvelteKit, Vue, Astro), prefer Vitest. For Create React App or non-Vite projects, Jest is still common.
What is the difference between a mock and a stub?
A stub replaces a function with a fixed return value — you're controlling input/output. A mock also records how the function was called — arguments, call count, order — so you can assert behavior. In Jest and Vitest, jest.fn() / vi.fn() creates a mock that can also be configured to return specific values, making it serve both roles.
Should I aim for 100% code coverage?
No. Coverage tells you which lines ran, not whether they were tested meaningfully. A better target is 80-90% with high-confidence tests on critical paths. Chasing 100% often leads to trivial tests that just inflate numbers without catching real bugs.