C.W.K.
Stream
Lesson 02 of 04 · published

Assertions That Read Like English

~18 min · vitest-setup, assertions, matchers

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"The test name is the spec. The assertion is the proof."

The Vocabulary You'll Use 90% of the Time

Vitest ships with the Chai-style expect() API plus Jest-compatible extensions. There are dozens of matchers; you'll reach for about a dozen of them daily, and the rest live in the docs for when you need them. The handful below are the ones to internalize first.

  • toBe(value) — strict equality (===). For primitives and reference equality of objects.
  • toEqual(value) — deep structural equality. For objects and arrays where you care about values, not identity.
  • toStrictEqual(value) — like toEqual, but also rejects extra undefined properties and prototype mismatches. Use when shape matters.
  • toContain(item) — arrays and strings only. Cheaper to read than indexing or includes() checks.
  • toMatch(regex) — string matches a regex. Better than two separate assertions for length and presence.
  • toThrow() / toThrow(message) — wrap the throwing call in an arrow function. Without the arrow, the throw happens before expect sees it.
  • toBeCloseTo(value, precision) — for floats. Use this any time you're comparing computed decimals; 0.1 + 0.2 !== 0.3 is real.
  • resolves / rejects — Promise modifiers. await expect(promise).resolves.toEqual(...) reads cleanly without try/catch.

describe / it / expect — The Skeleton

describe groups related tests. it (or test) registers one. expect asserts. The skeleton is uniform across Vitest, Jest, and most modern runners; the differences are in matcher availability and config, not the shape.

describe can nest. A common pattern is the outer describe naming the unit under test (a function, a component), inner describes grouping by behavior or input class, and it blocks naming concrete behaviors.

The test name is a sentence; the matcher is the verb. Read your test as: "it <test name>." If "it tests that the function works" reads like vapor, the name is wrong. "It rejects negative inputs by throwing RangeError" reads like spec because it is one.

Message Hygiene

Every test will fail eventually — usually in CI, with someone trying to understand what broke from terminal output alone. Three habits buy back hours of debugging:

  • Name tests as sentences. Not test('userService'). Not it('works'). Try it('returns null when the user is not found').
  • Use the second arg to expect() for context the matcher can't carry: expect(result, 'after second fetch').toEqual(...). This shows up in the failure message.
  • Assert one thing per test when possible. Multiple assertions are fine when they describe the same behavior, but when they're testing different facets, split. A test that fails halfway through hides the other failures.

Negative Assertions Need Care

expect(x).not.toBe(y) reads fine but proves a weaker statement than positive matches. "x is not 5" doesn't tell you what x actually is. Prefer positive assertions where you can — expect(x).toBeLessThan(5) over expect(x).not.toBeGreaterThan(4).

Code

The daily matchers in one test file·typescript
import { describe, it, expect } from 'vitest';
import { divide } from './math';

describe('divide', () => {
  it('returns the quotient for positive integers', () => {
    expect(divide(10, 2)).toBe(5);
  });

  it('returns a float for non-divisible integers', () => {
    expect(divide(7, 2)).toBeCloseTo(3.5, 5);
  });

  it('throws on division by zero', () => {
    // Arrow function so toThrow can call it and catch the throw.
    expect(() => divide(5, 0)).toThrow('Division by zero');
  });

  it('returns 0 when the numerator is 0', () => {
    expect(divide(0, 10), 'zero numerator should yield zero').toBe(0);
  });
});
Matcher cheat-sheet — the 90% kit·typescript
// Common matcher quick-reference
expect(value).toBe(5);                    // ===
expect(value).toEqual({ a: 1 });          // deep equality
expect(value).toStrictEqual({ a: 1 });    // deep + shape
expect(arr).toContain('item');            // membership
expect(arr).toHaveLength(3);
expect(str).toMatch(/^Hello/);            // regex
expect(value).toBeCloseTo(0.3, 5);        // float-safe
expect(value).toBeTruthy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeGreaterThan(0);
expect(() => boom()).toThrow();           // exception
expect(() => boom()).toThrow('details');  // exception with message

await expect(promise).resolves.toEqual({ ok: true });
await expect(promise).rejects.toThrow(NetworkError);
Async assertions — pick the shape that reads·typescript
// Async assertions — three valid shapes

// 1. Modifier — cleanest for one assertion
it('fetches the user', async () => {
  await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
});

// 2. Await then assert — verbose but flexible
it('fetches the user', async () => {
  const user = await fetchUser(1);
  expect(user.id).toBe(1);
  expect(user.email).toMatch(/@/);
});

// 3. Rejection — easier with the modifier
it('rejects unknown ids', async () => {
  await expect(fetchUser(-1)).rejects.toThrow('not found');
});

External links

Exercise

Write a divide(a, b) function that returns the quotient, throws on division by zero, and floors when the result would have an infinite decimal expansion. Then write the test file — one describe, four its, using toBe, toBeCloseTo, toThrow, and the expect(value, 'reason').toBe(...) form for one of them. Make each test name a complete sentence. Run with npm test.
Hint
Resist it('test 1', ...). The test name will outlive the implementation. Write it as if a future maintainer is reading the suite to understand what divide is supposed to do.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.