Skip to content
academia.sh

Lesson 02 / 13

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.

Contents

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.

// 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.

// 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',
  });
});
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.

// 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.

// 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',
  };
}
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.

// 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/);
});
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.

// 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/);
});
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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close