---
title: Assertions
source: 'https://academia.sh/en/courses/unit-testing/assertions'
course: 'Unit Testing and Test-Driven Development'
language: en
updated: '2026-08-23T14:25:21+00:00'
license: 'CC BY-SA 4.0'
---

# Assertions

The distinction between equality and deep equality, the signal strength of an assertion measured by the number of corruptions it catches, partial matching cutting an unnecessary bond, and narrowing an error expectation with a matcher.

The previous lesson used a single assertion: the equality of two numbers. That was enough
because the function under test returned a number. Issuing a loan, though, does not return
a number — it returns a record: member id, book id, checkout day, due day, and status. The
same assertion behaves completely differently on this record, and how much of the test is
actually tested changes with the shape of the assertion chosen.

This lesson's question has two layers. The first is formal: which assertion is the right
tool for which value type? The second is measurable: how many different corruptions of the
tested code does an assertion catch? The second question makes the first exact, because
"good assertion" stops being an intuitive judgment and becomes a countable quantity.

## Equality and Deep Equality

The function under test is `issueLoan`, which produces a loan record. The member type
determines the loan duration and the maximum number of open loans; the operation is
rejected when the limit is exceeded.

```js
// loan.mjs — the function that produces a loan record
export const RULES = {
  student: { loanDays: 28, maxLoans: 10 },
  member: { loanDays: 14, maxLoans: 5 },
};

export function issueLoan(member, bookId, day) {
  const rule = RULES[member.type];
  if (rule === undefined) throw new Error(`unknown member type: ${member.type}`);
  if (member.openLoans >= rule.maxLoans) {
    throw new Error(`loan limit exceeded: ${rule.maxLoans}`);
  }
  return {
    memberId: member.id,
    bookId,
    checkoutDay: day,
    dueDay: day + rule.loanDays,
    status: 'checked-out',
  };
}
```

Writing the same expectation in two assertion forms makes the difference visible.

```js
// equality.test.mjs — equality and deep equality on a function that returns an object
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { issueLoan } from './loan.mjs';

const MEMBER = { id: 'U-17', type: 'student', openLoans: 2 };

test('equality compares objects by identity', () => {
  const record = issueLoan(MEMBER, 'K-903', 1000);

  assert.equal(record, {
    memberId: 'U-17', bookId: 'K-903', checkoutDay: 1000, dueDay: 1028, status: 'checked-out',
  });
});

test('deep equality compares structure', () => {
  const record = issueLoan(MEMBER, 'K-903', 1000);

  assert.deepEqual(record, {
    memberId: 'U-17', bookId: 'K-903', checkoutDay: 1000, dueDay: 1028, status: 'checked-out',
  });
});
```

```sh
node --test --test-reporter=tap equality.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
```

```
not ok 1 - equality compares objects by identity
ok 2 - deep equality compares structure
# tests 2
# pass 1
# fail 1
```

A strict equality assertion compares objects by **identity**: two separate objects are not
considered equal even if their fields match exactly. Deep equality, by contrast, walks the
structure and compares the corresponding fields. The rule is plain: equality for numbers,
strings, and booleans; deep equality for objects, arrays, and nested structures.

## An Assertion's Signal Strength

An assertion's value shows up not when it passes but when it **fails**. Its measure is: how
many different corruptions of the tested code does it catch? This question can be answered
numerically. The script below produces five separately corrupted versions of the correct
record and applies three assertion forms to each.

```js
// assertion-strength.mjs — three assertion forms tried against five corrupted implementations
import assert from 'node:assert/strict';
import { issueLoan } from './loan.mjs';

const MEMBER = { id: 'U-17', type: 'student', openLoans: 2 };
const correct = () => issueLoan(MEMBER, 'K-903', 1000);

const CORRUPTIONS = {
  'due-day-hardcoded': () => ({ ...correct(), dueDay: 1014 }),
  'status-wrong': () => ({ ...correct(), status: 'reserved' }),
  'checkout-day-shifted': () => ({ ...correct(), checkoutDay: 1001 }),
  'book-id-dropped': () => { const r = correct(); delete r.bookId; return r; },
  'member-id-dropped': () => ({ ...correct(), memberId: null }),
};

const EXPECTED = {
  memberId: 'U-17', bookId: 'K-903', checkoutDay: 1000, dueDay: 1028, status: 'checked-out',
};

const ASSERTIONS = {
  'ok(record)': (r) => assert.ok(r),
  'equal(record.dueDay)': (r) => assert.equal(r.dueDay, 1028),
  'deepEqual(record)': (r) => assert.deepEqual(r, EXPECTED),
};

for (const [assertionName, assertion] of Object.entries(ASSERTIONS)) {
  const caught = [];
  for (const [corruptionName, corrupt] of Object.entries(CORRUPTIONS)) {
    try {
      assertion(corrupt());
    } catch {
      caught.push(corruptionName);
    }
  }
  const count = `${caught.length}/${Object.keys(CORRUPTIONS).length}`;
  console.log(`${assertionName.padEnd(21)} ${count}  ${caught.join(', ') || 'none'}`);
}
```

```
ok(record)            0/5  none
equal(record.dueDay)  1/5  due-day-hardcoded
deepEqual(record)     5/5  due-day-hardcoded, status-wrong, checkout-day-shifted, book-id-dropped, member-id-dropped
```

The first line shows the cost of an overly broad assertion. `assert.ok(record)` says only
"an object was returned"; it stays green no matter what the record contains. Such a test
takes up a slot in the run list, spends time, and reports no corruption at all. This is the
mirror image of the false positive concept introduced in the Quality and Testing
Fundamentals course: here the signal is not wrong, it is **absent**.

The second line shows a narrow assertion's honest limit: when only one field is tested,
only that field's corruption is caught. This is not a defect; if the test's name also names
a single field, it is the expected behavior.

The third line gives full coverage. But this measurement cuts only one way: as the number
of caught corruptions grows, the test's risk of **flakiness** grows with it. The next
section shows that cost.

## Partial Matching

Suppose a transaction id used to track the operation is added to the record. None of the
behavior rules change; the record just has a new field.

```js
// loan.mjs — version 2: a transaction id was added to the record
export const RULES = {
  student: { loanDays: 28, maxLoans: 10 },
  member: { loanDays: 14, maxLoans: 5 },
};

let counter = 0;

export function issueLoan(member, bookId, day) {
  const rule = RULES[member.type];
  if (rule === undefined) throw new Error(`unknown member type: ${member.type}`);
  if (member.openLoans >= rule.maxLoans) {
    throw new Error(`loan limit exceeded: ${rule.maxLoans}`);
  }
  counter += 1;
  return {
    transactionId: `T-${String(counter).padStart(4, '0')}`,
    memberId: member.id,
    bookId,
    checkoutDay: day,
    dueDay: day + rule.loanDays,
    status: 'checked-out',
  };
}
```

```sh
node --test --test-reporter=tap equality.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
```

```
not ok 1 - equality compares objects by identity
not ok 2 - deep equality compares structure
# tests 2
# pass 0
# fail 2
```

The deep equality assertion failed, because a full match means **full**: an unexpected
field in the record is also a mismatch. Yet the rule under test — a student loan lasting
twenty-eight days — did not change. The test failed because it was bound to a field it did
not care about.

The fix is not to weaken the assertion but to **narrow** it: the presence and value of the
expected fields are tested, and the rest is left free.

```js
// partial-match.test.mjs — partial matching tests the presence of the expected fields
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { issueLoan } from './loan.mjs';

function contains(actual, expected, path = 'record') {
  for (const [field, value] of Object.entries(expected)) {
    const subPath = `${path}.${field}`;
    assert.ok(field in actual, `${subPath} field missing`);
    if (value !== null && typeof value === 'object') contains(actual[field], value, subPath);
    else assert.equal(actual[field], value, `${subPath} mismatch`);
  }
}

const MEMBER = { id: 'U-17', type: 'student', openLoans: 2 };

test('a student loan is due twenty-eight days later', () => {
  const record = issueLoan(MEMBER, 'K-903', 1000);

  contains(record, { memberId: 'U-17', bookId: 'K-903', dueDay: 1028, status: 'checked-out' });
});

test('partial matching reports the wrong field', () => {
  const record = issueLoan(MEMBER, 'K-903', 1000);

  assert.throws(() => contains(record, { dueDay: 1014 }), /record.dueDay mismatch/);
});
```

```sh
node --test --test-reporter=tap partial-match.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - a student loan is due twenty-eight days later
ok 2 - partial matching reports the wrong field
# tests 2
# pass 2
# fail 0
```

The `contains` helper walks the expected object's fields and reports a missing field and a
mismatched value with separate messages. The second test tests this helper itself: given a
wrong expectation, it must say which field mismatched, inside the message. Testing the
helper may look unnecessary, but if an assertion helper passes silently, every test bound
to it loses its signal.

The dose of partial matching is a choice: left too loose, it is as weak as `assert.ok`;
held too tight, it is as bound as full deep equality. The criterion is this: every field
named in the assertion must be part of the rule named in the test's name.

## Expecting an Error

The operation being rejected on a rule violation is also a behavior, and it needs to be
tested. An error-expecting assertion's unmatched form, though, is a common source of false
positives.

```js
// error-expectation.test.mjs — an unmatched and a matched error expectation
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { issueLoan } from './loan.mjs';

const AT_LIMIT = { id: 'U-17', openLoans: 10 };

test('unmatched expectation: throws on limit exceeded', () => {
  assert.throws(() => issueLoan(AT_LIMIT, 'K-903', 1000));
});

test('matched expectation: throws on limit exceeded', () => {
  assert.throws(() => issueLoan(AT_LIMIT, 'K-903', 1000), /loan limit exceeded: 10/);
});
```

```sh
node --test --test-reporter=tap error-expectation.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - unmatched expectation: throws on limit exceeded
not ok 2 - matched expectation: throws on limit exceeded
# tests 2
# pass 1
# fail 1
```

Same call, same fixture, two different outcomes. Looking at the error actually thrown
explains the difference.

```js
// thrown.mjs — the error the test actually catches
import { issueLoan } from './loan.mjs';

const AT_LIMIT = { id: 'U-17', openLoans: 10 };
try {
  issueLoan(AT_LIMIT, 'K-903', 1000);
} catch (error) {
  console.log(`${error.constructor.name}: ${error.message}`);
}
```

```
Error: unknown member type: undefined
```

The `type` field was forgotten on the member object in the fixture. The function never even
looks at the loan limit; it throws a different error in an earlier check. The unmatched
expectation stayed green because it saw "some error", and the rule it meant to test never
ran. The matched expectation, by failing, reported that the fixture was broken.

This is the most expensive mistake in assertion choice: a green test may never have run the
rule it thought it was testing. An error expectation must always be bound to a criterion —
an error class, a message pattern, or a field on the error object.

## Summary

- Strict equality compares objects by identity; functions that return objects and arrays
  need deep equality or partial matching.
- An assertion's signal strength is measured by how many corruptions of the tested code it
  catches; in the measurement, `assert.ok(record)` caught none of the five corruptions, and
  deep equality caught all five.
- Full deep equality also binds to fields unrelated to the behavior; when a new field was
  added to the record, the test failed even though the rule had not changed.
- Partial matching tests the expected fields and leaves the rest free; every field named in
  the assertion must be part of the rule named in the test's name.
- An unmatched error expectation also accepts the wrong error, and can stay green without
  the rule it meant to test ever running.

## Next Step

This lesson measured what assertions say, but test names stayed quietly in the background.
Yet the first piece of information a failing run gives is not the assertion, it is the
**name**: the sentence next to the `not ok 2` line. If that sentence says something like
"record is correct", the reader has to go to the source; if it states the rule, most of the
time it does not have to. The next lesson takes up test naming and turns a test suite's
names into a readable list of rules.
