bastianplsfix

Assertions

This entry expects you to code along. Every claim in it is observable, and the observations are the point. Work in the examples project with a terminal open at the project root. This entry's files go straight into the programs folder, beside the ones earlier entries put there; every file in this series carries a name no other entry uses, so nothing collides.

the comparison that lives in your head

The console left you checking programs by printing: run, read the value, compare it against the value in your head. The comparing is the fragile part. Eyes skim, the expected value lives only in your memory, and nothing complains when you glance past a wrong digit. Ten printed lines into a session you are not checking anymore, you are scrolling.

An assertion moves the comparison into the program. It states a fact about a value, and it has exactly two behaviors: if the fact holds, nothing happens, and if it does not, it throws an error that stops the program. The idea is old. C has shipped an assert since the 1970s that ends a program when a stated fact turns out false, most languages carry a descendant, and Deno's lives in the standard library as @std/assert. The examples project already maps that name in its deno.json; in a fresh project, deno add jsr:@std/assert does it once.

Two functions carry most of the weight. assert(condition, message?) is the primitive: it throws unless the condition is truthy, in the truthiness page's sense of the word. assertEquals(actual, expected, message?) compares contents: it walks arrays and objects, however nested, and asks whether the two sides hold the same data. Create programs/opening.ts:

import { assertEquals } from "@std/assert";

assertEquals(3 + 5, 8);
assertEquals([1, 2, 3].map((n) => n * 2), [2, 4, 6]);
assertEquals({ name: "ada" }, { name: "ada" });

Before running it, answer this: a program of three passing assertions prints what?

deno run programs/opening.ts

Nothing at all. A passing assertion is silent, so a program full of them runs to the end without a word, and the silence is the success. That takes getting used to after an entry spent printing. The console showed you values so you could judge them; an assertion has already judged, and only speaks up to object. When it objects, it looks like this. Create programs/diff.ts with a wrong claim:

import { assertEquals } from "@std/assert";

assertEquals({ name: "ada", age: 36 }, { name: "ada", age: 37 });
deno run programs/diff.ts
error: Uncaught (in promise) AssertionError: Values are not equal.

[Diff] Actual / Expected

{
- age: 36,
+ age: 37,
name: "ada",
}

at file:///programs/diff.ts:3:1

The argument order is a contract: actual result first, expected value second. The - line is the actual value, what the code produced, and the + line is the expected value, what you claimed. Swap the arguments and every diff you read afterwards reads backwards.

The habit worth forming: state results as assertions, even in documentation and throwaway code. assertEquals(3 + 5, 8) says everything the comment // prints 8 says, and unlike the comment it is checked on every run. An example that states its result as an assertion cannot silently rot. The long-term home for assertions is a test, where a runner finds them and reports; that machinery is the understanding testing page. Inline in a script they behave identically. The only difference is who calls them.

The model so far: an assertion states a fact: silence when the fact holds, a thrown AssertionError when it does not. assertEquals(actual, expected) is the workhorse, actual first, and a wrong claim shows up as a labeled diff.

same contents, or the same object?

Create programs/contents.ts with a program that looks obviously correct:

import { assertStrictEquals } from "@std/assert";

const actual = ["apple", "bread"];
const expected = ["apple", "bread"];

assertStrictEquals(actual, expected);

Run it:

deno run programs/contents.ts
error: Uncaught (in promise) AssertionError: Values have the same structure but are not reference-equal.

[
"apple",
"bread",
]

at file:///programs/contents.ts:6:1

The two arrays are written identically, character for character, and the assertion still objects, and this failure message is unusually honest about why. There are two different facts you can state about a pair of values, and this program states the wrong one. assertEquals asks: do these hold the same data? That is a claim about contents. assertStrictEquals asks: are these the very same object? That is a claim about identity. Two arrays built by two [...] expressions hold the same data but are two separate objects, the way two printed copies of a form are two pieces of paper no matter how identically they are filled in. Why the language works this way is the subject of the values and references page. Fix the program by stating the claim actually intended, that the arrays have the same contents:

import { assertEquals } from "@std/assert";

const actual = ["apple", "bread"];
const expected = ["apple", "bread"];

assertEquals(actual, expected);

It now finishes in silence. The executed summary, plus two details that sharpen the strict form, goes in programs/identity.ts:

import {
assertEquals,
assertNotStrictEquals,
assertStrictEquals,
assertThrows,
} from "@std/assert";

const a = [1, 2, 3];
const b = [1, 2, 3];

assertEquals(a, b); // same contents
assertNotStrictEquals(a, b); // different objects
assertStrictEquals(a, a); // the very same object

assertStrictEquals(NaN, NaN); // passes; === would say false
assertThrows(() => assertStrictEquals(-0, 0)); // the zeros differ
assertEquals(NaN, NaN);
assertEquals(-0, 0);

The strict form compares with Object.is, which is === with two repairs, the story the equality page tells in full: NaN equals itself and the two zeros differ. The last two lines show assertEquals is looser on both counts, treating NaN as equal to NaN and not distinguishing -0 from 0. When the sign of a zero is the claim, only the strict form can state it.

One boundary here is a naming trap. If you arrive from Node, the names betray you: the two ecosystems gave their short, default-looking name to opposite meanings, and both are frozen by compatibility now.

The claimnode:assert/strict@std/assert
same objectassert.equalassertStrictEquals
same contentsassert.deepEqualassertEquals

When porting tests, re-read every equal.

The model so far: contents and identity are different facts. assertEquals compares the data inside; assertStrictEquals compares with Object.is and passes only for the very same object, keeping NaN equal to itself and the two zeros apart. Contents is almost always the claim you mean.

the decimal that is almost 0.3

Create programs/decimal.ts:

import { assertEquals } from "@std/assert";

const total = 0.1 + 0.2;

assertEquals(total, 0.3);

Run it once and read the two numbers in the failure:

deno run programs/decimal.ts
error: Uncaught (in promise) AssertionError: Values are not equal.

[Diff] Actual / Expected

- 0.30000000000000004
+ 0.3

at file:///programs/decimal.ts:5:1

The program did nothing wrong; this is floating-point arithmetic doing what it always does. Computers store numbers in binary, neither 0.1 nor 0.2 has an exact binary form, and the tiny rounding inside each survives into the sum, the story the numbers page tells whole. The sum will never be exactly 0.3, so exact equality is the wrong fact to state about it. The right fact allows for nearness: assertAlmostEquals(actual, expected) passes when the two sides are within a hair of each other, and takes the size of the hair as an optional third argument when the default is not what you mean. Fix the program, keeping the calculation and the expected value unchanged:

import { assertAlmostEquals } from "@std/assert";

const total = 0.1 + 0.2;

assertAlmostEquals(total, 0.3);

The model so far: computed decimals miss exact targets by design, so state nearness rather than equality: assertAlmostEquals, with an explicit tolerance when the default is not the claim.

proving it throws

Create programs/withdraw.ts:

function withdraw(balance: number, amount: number): number {
if (amount > balance) {
throw new RangeError(`cannot withdraw ${amount} from ${balance}`);
}
return balance - amount;
}

withdraw(10, 25);
deno run programs/withdraw.ts
error: Uncaught (in promise) RangeError: cannot withdraw 25 from 10
throw new RangeError(`cannot withdraw ${amount} from ${balance}`);
^
at withdraw (file:///programs/withdraw.ts:3:11)
at file:///programs/withdraw.ts:8:1

Here is the twist: that throw is the function working as designed. Refusing an overdraft is withdraw's job. So the fact worth stating is not "this call returns such-and-such" but "given this input, this call throws." How do you assert that, when the throw kills the program before any line after it can run?

You hand the call, unstarted, to the assertion, and let the assertion do the calling. Before reading on, work out why the wrapper is there: why () => withdraw(10, 25) rather than plain withdraw(10, 25)? Because the plain form calls withdraw yourself, on the spot, and the throw escapes before assertThrows ever receives an argument. The () => wraps the call in a function that has not run yet. assertThrows invokes it under its own supervision, catches whatever comes out, and objects only if nothing does. The wrapper is the whole mechanism. Turn the dying program into a passing one, stating the claim in tightening steps: the call throws, it throws this class, the message includes this text, and, through the returned error, the message is exactly this:

import { assertEquals, assertThrows } from "@std/assert";

function withdraw(balance: number, amount: number): number {
if (amount > balance) {
throw new RangeError(`cannot withdraw ${amount} from ${balance}`);
}
return balance - amount;
}

assertThrows(() => withdraw(10, 25));
assertThrows(() => withdraw(10, 25), RangeError);
assertThrows(() => withdraw(10, 25), RangeError, "cannot withdraw 25");

const err = assertThrows(() => withdraw(10, 25), RangeError);
assertEquals(err.message, "cannot withdraw 25 from 10");

Silence again, and five facts stated. assertThrows returning the caught error is what makes the last pair possible, and its beginnings, a hand-rolled try/catch around a call that should fail, are what the errors and exceptions page builds before this entry replaces them. For functions that return promises, assertRejects is the same tool with an await in front.

The model so far: an expected throw is a fact like any other. Hand assertThrows a function to call, tighten the claim with an error class and message text, and use the returned error when the claim must be tighter still.

relatives, and one impostor

fail(message?) throws unconditionally. Its modern use is marking a branch that must never run, and unreachable() is the same statement with a better name. Create programs/never.ts:

import { unreachable } from "@std/assert";

function label(kind: "a" | "b"): string {
switch (kind) {
case "a":
return "first";
case "b":
return "second";
default:
unreachable();
}
}

console.log(label("a"), label("b"));
first second

The pattern the pair once served, calling a function inside try and invoking fail() if it did not throw, is exactly what assertThrows replaced.

console.assert looks like family and is not. It belongs to the console standard rather than to any assertion library, and it behaves true to the model from the console entry. Create programs/impostor.ts:

console.assert(1 === 2, "the impostor only reports");
console.log("still running");
deno run programs/impostor.ts
Assertion failed: the impostor only reports
still running

On a false condition it prints a report and lets the program continue: nothing throws, so no test fails, and the exit code is zero. The report travels the diagnostics stream, which the first section's experiment can prove: run it again with 2>/dev/null and only still running survives. It is a fine tripwire in a script you are watching; in a test it is a silent hole.

Everything in @std/assert throws AssertionError, which the module also exports, for the rare case where you assert about assertions. Last, messages: every assertion accepts a trailing message. The diff usually explains itself, so write a message when the failure needs context the values do not carry: which case broke, which input produced it. A message that restates the diff is noise; a message that names the scenario is the difference between reading a failure and re-running it under a debugger.

The model so far: fail and unreachable state that a line must never run. console.assert reports without throwing and has no place in a test. Messages are for context the values cannot carry.

the model to carry

An assertion states a fact and stays silent until the fact breaks. Reach for assertEquals by default, since contents is almost always the fact you mean, assertStrictEquals when identity itself is the claim, and assertAlmostEquals for computed decimals. Keep the argument order sacred: actual, then expected, so every diff reads honestly. State expected failures with assertThrows and its () => wrapper rather than a hand-rolled try/catch, and keep console.assert out of tests. State results as assertions wherever you write examples, because checked beats commented every time, and give assertions a long-term home in a test, where the runner from the understanding testing page finds and reports them.

Run the finished programs together from the project root:

deno run programs/opening.ts
deno run programs/contents.ts
deno run programs/identity.ts
deno run programs/decimal.ts
deno run programs/withdraw.ts
deno run programs/never.ts

The first five finish in silence with a zero exit code, which is exactly what success sounds like, and never.ts prints its two labels. If a file drifted along the way, the versions shown in each section above are the answers, and one last check confirms they are formatted:

deno fmt --check programs/opening.ts programs/diff.ts programs/contents.ts programs/identity.ts programs/decimal.ts programs/withdraw.ts programs/never.ts programs/impostor.ts
Checked 8 files