Testing Contracts

The Course
•
5 min read

A useful test can explain its failure

Keep the beginner assertion pattern: compare actual with expected and throw on mismatch. Add a label that identifies the case. A test should say what the caller needs, not merely repeat the implementation in a second spelling.

For an update, verify changed output, unchanged input, a missing id, and an empty list. For a decoder, verify malformed fields and duplicates. When a test claims to protect a rule, temporarily break that rule and watch the check fail. This is a small mutation experiment: it proves sensitivity to one particular defect, not complete correctness.

Array and object equality compares identity. To check a specific field, assert that field. For a whole structured result you can use a test framework’s deep comparison once you move to the downloaded Node project. JSON string comparisons are acceptable only when your test deliberately assumes JSON-compatible values and property ordering; they are not universal equality.

Keep tests repeatable. They should create their own inputs rather than depend on a previous run’s globals. We will use deferred promises later to control async order without guessing how many milliseconds a machine needs.

Predict and trace

Write the output before running. Follow the values through each call; if your prediction differs, identify the first step that surprised you.

Predict, then run JavaScript
function assertEqual(actual, expected, label) {
  if (actual !== expected) {
    throw new Error(label);
  }
}

function rename(book, title) {
  return { ...book, title };
}

const before = { id: "js", title: "Old" };
const after = rename(before, "New");

assertEqual(after.title, "New", "new title");
assertEqual(before.title, "Old", "input unchanged");
console.log("2 checks passed");
Check your prediction
2 checks passed

A mutating rename can produce the right new title but fails the unchanged-input assertion.

Keep the assertion outside the operation catch

A test can accidentally catch its own failure and report success. This happens when an assertion expecting rejection is placed in the same try block as the operation. Capture whether the operation failed, then make the assertion after the catch.

Use distinguishable fixtures. For nonmutation, an original with two records and different values can reveal accidental replacement or mutation that a single record would hide. A regression test should fail for a deliberate implementation defect, not just for a syntax error.

Keep the assertion outside the operation catch: trace JavaScript
function didThrow(work) {
  let rejected = false;

  try {
    work();
  } catch {
    rejected = true;
  }

  return rejected;
}

console.log(
  didThrow(() => {
    throw new Error("expected");
  })
);
console.log(didThrow(() => 1));
Check your prediction
true
false

The helper reports only the operation outcome. Throwing an assertion inside that try would mix test failure with the failure the test was trying to detect.

Keep the assertion outside the operation catch: contrast JavaScript
const before = [
  { id: "a", title: "Old" },
  { id: "b", title: "Keep" }
];
const after = before.map(book =>
  book.id === "a" ? { ...book, title: "New" } : book
);

if (before[0].title !== "Old" || after[1].title !== "Keep") {
  throw new Error("Contract broken");
}

console.log("input and unrelated row preserved");
Check your prediction
input and unrelated row preserved

A returned New title alone would not prove preservation. This assertion also checks the previous state and a row outside the requested update.

Try the rule on new inputs

Change the update to mutate the matching source row and confirm the preservation assertion fails. For an expected-throw test, first run a callback that does not throw to prove your test cannot accept success by accident.

Build it yourself

Exercise

Testing Contracts: practise

Write didThrow(action), passing it a function that you want to inspect. Return true when it throws and false otherwise. This is a predicate, not yet an assertion: a test must check that the returned boolean is what it expected. Print the outcomes for a throwing function and a successful function.

Your working space JavaScript
// Call action inside try/catch and report whether it threw.
Hint 1: the idea

Distinguish the action throwing from it returning an ordinary value.

Hint 2: a first step

Return true in catch and false after the try block.

Show one solution
function didThrow(action) {
  try {
    action();
  } catch {
    return true;
  }

  return false;
}

console.log(
  didThrow(() => {
    throw new Error("bad");
  })
);
console.log(didThrow(() => 42));
true
false

This helper does not verify the error type or message; add that requirement only if it is part of the contract. The callback must be synchronous here—an async rejection needs await, introduced tomorrow.

Test a changed requirement

This helper does not verify the error type or message; add that requirement only if it is part of the contract. The callback must be synchronous here—an async rejection needs await, introduced tomorrow.

Explain before moving on

Why is a function passed to didThrow instead of its result? How would you turn an unexpected false answer into a failing assertion instead of just printing it?

Was this article helpful?

Your response is saved on this device.