Testing and debugging · Lesson 71 of 77 · concept

How this maps to Vitest

About 40 minutes.

By the end of this lesson you can

  • Recognise describe, it and expect, and say which hand-written part each one replaces.
  • Choose between toBe and toEqual, and explain why the difference exists.
  • Read a failure report and find the assertion that produced it.
  • State what a framework adds and what it cannot do for you.

Read, not run. The sandbox on this site has no modules, the DOM, the network and the filesystem, so the examples in this lesson are shown rather than executed. Where an example cannot run, it says so and explains why instead of leaving you to find out.

You have already built the parts; here are their real names

Explanation

The loop with the try/catch that you wrote in lesson 2 is the test runner. The array of objects with a name and a body is what describe and it produce: describe groups related tests and gives the group a name, it declares a single test with a sentence for a name and a function for a body. Your assertEquals is expect(actual).toEqual(expected). Your createSpy is vi.fn(). There is no fifth thing you have not met.

That is the honest summary of what a test framework is, and it is worth saying plainly because the first encounter with one is otherwise all mystery: functions you did not import appearing to exist, a command that finds your files by itself, output in colours. Underneath, it is a loop, a try/catch and a comparison - the things you built, with the reporting done properly.

This lesson deliberately stops at recognition. Installing a runner, wiring it into a project and reading its configuration belong to the tooling module and to that tool's own documentation, which is where you should go next - a course that paraphrased Vitest's manual would only be a worse copy of it that goes stale.

toBe and toEqual are the two comparisons from lesson 1

Explanation

The first real decision a framework asks of you is one you already understand. toBe compares the way === does - the documentation says it is equivalent to Object.is - so it answers 'are these the same value or the same object'. It is right for numbers, text, true and false, null and undefined, and it is wrong for two separately built objects, which is exactly the trap from lesson 1.

toEqual compares structures: it walks both values recursively and asks whether they hold the same things. That is the grown-up version of the JSON.stringify trick you have been using, without its weaknesses - it does not care about the order the keys were written in, and it can report which property differs rather than handing you two long strings and letting you find the difference yourself.

There is a third, toStrictEqual, and its existence tells you something the JSON trick was hiding. It differs from toEqual in checking keys whose value is undefined, checking array sparseness, and checking that the two objects are of the same type. In other words, toEqual treats a property that is present-but-undefined as equivalent to a property that is absent, and toStrictEqual does not. When that distinction matters in your data, the framework has a matcher for it - and when you were comparing JSON text, you had no way to say it at all.

What a framework adds, and what it cannot

Why it matters

What you get: it finds the test files for you rather than you maintaining a list; it runs each file in isolation so one file's leftovers cannot reach another; it re-runs only what your last edit affected while you type; it prints a readable difference between two structures instead of two walls of text; it reports which lines of your code were never executed by any test; and it gives you spies, fake clocks and module substitutes that clean themselves up afterwards. Every one of those is something you could build and none of them is something you should.

What it cannot do: decide what is worth testing. A suite of two hundred green tests that all exercise the happy path tells you nothing about the empty basket, and no runner will point that out. The judgement in lesson 3 - zero, one, many, both sides of every boundary - is the part that stays yours, and it is the part that makes the difference between a suite that catches bugs and a suite that only takes time.

It also cannot make a bad test good. A test that asserts on a spy without checking its arguments is just as weak inside a framework as outside it, and a test you have never watched fail is just as untrustworthy. The framework raises the floor on reporting; it does not raise the floor on thinking.

Reading a failure without panicking

A mental model

A framework's failure report has four parts, and they answer four questions in a fixed order. The test name says which claim broke. The file and line say where the assertion lives. The expected and received values say how reality differed. The stack trace says how the code got there.

Read them in that order and stop as soon as you know enough. Most failures are solved by the first three: the name tells you what was supposed to happen, and the two values tell you what happened instead. The stack trace matters when the failure came from inside code you did not write, and reading it top to bottom - your file is usually the first line that mentions your own project - is faster than reading the whole thing.

One habit transfers directly from this module: when a test goes red, your first question is not 'what did I break in the code' but 'which of the two - the code or the claim - is wrong'. A failing test is a disagreement, and sometimes the test is the one that is out of date.

What to hold on to

Recap

describe groups, it declares one test, expect makes a claim, vi.fn() is your spy. toBe is ===, toEqual walks the structure, toStrictEqual also cares about undefined properties and types. A framework buys you discovery, isolation, watch mode, readable diffs and coverage; it does not buy you judgement about what to test. And read a failure in the order it is printed: name, place, expected against received, then the trace.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

Lesson 2's suite, rewritten in Vitest

Compare this line by line with the runner you built. Every part has a counterpart: the array of tests became describe and it, the try/catch became the runner's job, and assertEquals became expect.

1import { describe, it, expect } from "vitest";2 3import { basketTotal, summariseBasket } from "./basket.js";4 5describe("basketTotal", () => {6  it("adds one line", () => {7    const lines = [{ price: 3, quantity: 2 }];8 9    const total = basketTotal(lines);10 11    expect(total).toBe(6);12  });13 14  it("an empty basket costs nothing", () => {15    expect(basketTotal([])).toBe(0);16  });17 18  it("summarises a basket as a plain object", () => {19    const summary = summariseBasket([{ price: 3, quantity: 2 }]);20 21    expect(summary).toEqual({ lines: 1, total: 6 });22  });23});
  • line 5

    describe is the grouping your array of tests provided implicitly. The name is a noun - what is being tested - while the names inside it are sentences about behaviour.

  • line 6

    it declares one test: a name and a body function. This is exactly the { name, body } object you were building by hand.

  • line 7

    Arrange, act, assert - separated by blank lines here so the three steps are visible at a glance. The shape has not changed at all.

  • line 11

    toBe for a number, because === is the right comparison for a primitive. Using toBe on the object below would fail even when every property matched.

  • line 21

    toEqual walks the structure, so a freshly built object with the same properties passes - and unlike comparing JSON text, the order the keys were written in does not matter.

This one does not run hereIt uses import, and the sandbox has no module loader - module syntax is not even parsed there (spec R6/R7). describe, it and expect are supplied by the test runner as it loads the file, so none of them exist here either. Run this with a real Vitest install, in a project, from the command line.

Your spy, as vi.fn()

The double from lesson 4, with the recording and the matchers provided. Note that the assertions ask what the function was called WITH, which is the habit worth carrying over.

1import { it, expect, vi } from "vitest";2 3import { notifyAll } from "./notify.js";4 5it("notifies every customer once, with the shipped message", () => {6  const notify = vi.fn();7 8  notifyAll(["ada", "grace"], notify);9 10  expect(notify).toHaveBeenCalledTimes(2);11  expect(notify).toHaveBeenCalledWith("grace", "your order shipped");12  expect(notify.mock.calls[0]).toEqual(["ada", "your order shipped"]);13});
  • line 6

    vi.fn() is createSpy() with the boilerplate removed - a callable that records what it received.

  • line 10

    toHaveBeenCalledTimes checks the count: exactly two, so a duplicate notification fails the test.

  • line 11

    toHaveBeenCalledWith checks that at least one call had these arguments - the assertion with real content, rather than 'it was called'.

  • line 12

    mock.calls is the same array of argument-arrays you built by hand, so you can still make an exact claim about one specific call.

This one does not run hereSame two reasons as the example above: import cannot be parsed in the sandbox, and vi, it and expect are injected by the runner when it loads a test file. The spy in lesson 4 is the runnable version of this idea.

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

This is the comparison problem toBe and toEqual exist to solve, written in plain JavaScript. Predict the three lines.

const expected = { id: 1, tags: ["new"] };const actual = { id: 1, tags: ["new"] };console.log(actual === expected);console.log(actual.id === expected.id);console.log(JSON.stringify(actual) === JSON.stringify(expected));

A function returns a new object { id: 1, tags: [] }. Which matcher should the test use to check the whole result?

Your project adopts Vitest and the whole suite is green. What has not improved?

No exercise in this lesson

Why there is nothing to run here

Nothing here can run in the sandbox: describe, it, expect and vi come from a runner that loads files from disk, and there is no module loader (spec R6/R7). Grading a fake expect would teach the shape of a lie - the practice for this lesson is running a real suite in a real project.

Worth remembering

  • Which hand-written part does each of describe, it, expect and vi.fn() replace?

    describe groups the tests, it is one { name, body } entry, expect is your assertEquals, and vi.fn() is your createSpy. The runner is your loop with the try/catch.

  • When do you use toBe and when toEqual?

    toBe is Object.is - use it for primitives and for 'the same object'. toEqual walks the structure - use it for objects and arrays compared by contents.

  • What does toStrictEqual check that toEqual does not?

    Keys whose value is undefined, array sparseness, and that the two values are of the same object type.

  • What can no test framework do for you?

    Decide what is worth testing. Discovery, isolation, watch mode, diffs and coverage are all runner work; choosing the cases stays yours.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

Vitest — expect

Check there: toBe is described as equivalent to Object.is, and is for primitives or shared references; toEqual is suggested when the objects differ but their structures should match; toStrictEqual additionally checks keys with undefined values, array sparseness and object types.

Vitest — vi

Check there: vi.fn() creates a spy function that records the calls made to it.

Vitest — Mock Functions

Check there: The mock.calls property holds an array with the arguments of every call made to the mock.

Vitest — Getting Started

Check there: How a project installs the runner and which filenames it treats as test files.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

Consent version 2026-07-31.1

Cookie preferences