Understanding testing
Software testing is the deliberate search for evidence about how a program behaves and where it may fail. A person can test by exploring the running program, trying realistic tasks, and following surprising results. That exploration can discover risks nobody thought to automate.
An automated test is a claim about a program that another program can check. That sentence is the automated half of the subject in miniature. The rest of this guide earns each part of it: what makes a claim useful, what a test result does and does not prove, why an assertion needs a runner around it, and how Deno helps a few tests grow into a trustworthy suite. Exploratory testing discovers new questions. Automated tests preserve selected answers and check them after every change. A serious project uses both kinds of evidence.
The first half is meant to be followed in order. It begins with console.assert, builds the first Deno test without hiding any machinery, and establishes the testing habits used throughout this course. The second half is a field guide. Return to it when your programs acquire promises, files, clocks, external services, snapshots, or enough tests that running them becomes a problem of its own.
Here is the route:
- A test checks one observable claim
console.assertgives the claim a consequenceDeno.testgives assertions a runner- A useful assertion explains a failure
- Synchronous, asynchronous, and grouped tests
- How to choose cases that deserve tests
- How to keep a suite trustworthy
- The runner tools for a growing project
- A practical testing workflow
Create programs/understanding-testing.test.ts for this reference and keep it open. Use it for the runnable examples, adding or replacing code when the page asks you to.
A test checks one observable claim
Suppose delivery costs 5 unless the order subtotal is at least 50. This function expresses that rule:
function deliveryFee(subtotal: number): number {
return subtotal >= 50 ? 0 : 5;
}
Running the function with 60 gives us an example, but an example alone asks a human to inspect the answer:
const subtotal = 60;
const actual = deliveryFee(subtotal);
console.log(actual);
0
You can read 0 and compare it with the rule in your head. The program cannot yet do that comparison for you. Give the expected answer a name and make the comparison explicit:
const actual = deliveryFee(60);
const expected = 0;
actual === expected;
The final expression evaluates to true, but nothing observes that value. We now have the three ingredients of a test, even though they are not yet connected:
- Arrange:
60creates the situation we want to examine. - Act:
deliveryFee(60)runs the behavior and produces the actual result. - Assert:
actual === expectedstates the claim that the observed result is the expected one.
Arrange, act, assert is a reading aid, not syntax. Some tests need all three phases on separate lines. A small test may fit the action inside its assertion. What matters is that the situation, the behavior, and the claim remain identifiable.
This test case says exactly one thing: deliveryFee(60) returns 0. It does not prove that the function is correct for every possible number. Tests provide evidence about the cases they exercise. More cases can strengthen that evidence, but no number of examples turns into a mathematical proof of every input unless the input space itself is finite and fully checked.
That limit is not a defect. A test suite is useful because it preserves selected facts that matter to the program and checks them cheaply after every change.
The source of the expected answer is sometimes called the test oracle. Here, the delivery rule is the oracle: a subtotal of 60 is at least 50, so the expected fee is 0. The assertion is only as trustworthy as that expected answer. deliveryFee(60) === deliveryFee(60) would compare the function with itself and could preserve the same bug on both sides. Derive expectations from a stated rule, a worked example, a trusted reference implementation, or another source independent of the behavior being checked.
console.assert gives the claim a consequence
console.assert observes a condition. When the condition is truthy, it produces no output. When the condition is falsy, it reports an assertion failure.
Add it to programs/delivery-fee.ts:
function deliveryFee(subtotal: number): number {
return subtotal >= 50 ? 0 : 5;
}
const actual = deliveryFee(60);
const expected = 0;
console.assert(actual === expected);
Run the file:
deno run programs/delivery-fee.ts
The terminal stays empty because the condition is true. A silent assertion can be unnerving, so perform a negative control: deliberately make the expected value wrong before trusting the check.
const expected = 5;
console.assert(actual === expected, "expected", expected, "but received", actual);
Assertion failed: expected 5 but received 0
The failure tells us that the assertion is capable of detecting this disagreement. Restore expected to 0 after observing it.
This habit catches a surprisingly common class of bad tests. A test can pass because it never reaches the assertion, because it compares a value with itself, or because its setup cannot produce the condition it claims to examine. When practical, make a new test fail for the intended reason once. A green test means more after you have seen its red state.
console.assert is enough to reveal the core idea, but it is not enough for an automated suite. In Deno, a failed console.assert writes a diagnostic and then allows the program to continue. The process still exits successfully:
console.assert(false, "this claim failed");
console.log("the program continued");
Assertion failed: this claim failed
the program continued
A person sees the first line and knows something failed. A shell, an editor task, or a continuous-integration service sees exit status 0 and concludes that the command succeeded. console.assert also provides no test discovery, names, counts, timing, filtering, or summary. We need two separate tools:
- an assertion that throws when a claim is false;
- a test runner that finds tests, runs them, reports each result, and exits unsuccessfully if any test fails.
Deno.test gives assertions a runner
Deno.test registers a named function with Deno's built-in test runner. Put the earlier experiment inside one in programs/understanding-testing.test.ts, but keep the deliberately wrong expectation for one run:
function deliveryFee(subtotal: number): number {
return subtotal >= 50 ? 0 : 5;
}
Deno.test("orders of 50 or more have free delivery", () => {
const actual = deliveryFee(60);
const expected = 5;
console.assert(actual === expected, "expected a fee of 5, received", actual);
});
Run that file with the test subcommand:
deno test programs/understanding-testing.test.ts
Check programs/understanding-testing.test.ts
running 1 test from ./programs/understanding-testing.test.ts
orders of 50 or more have free delivery ...
------- output -------
Assertion failed: expected a fee of 5, received 0
----- output end -----
orders of 50 or more have free delivery ... ok
ok | 1 passed | 0 failed
The assertion reported a disagreement, yet the runner said ok. Both reports are internally consistent.
Deno.test(...)registered the callback under its descriptive name.- The runner called that callback.
console.assert(...)printed a message, but did not throw an error.- The callback reached its end normally, so the runner marked the test as passed.
Deno.test considers a synchronous test successful when its callback returns without throwing. It considers an asynchronous test successful when the promise returned by its callback fulfills. A thrown error or rejected promise marks the test as failed. The assertion therefore has to throw.
The course project already maps @std/assert to Deno's standard assertion library. Import assertEquals from it, then replace console.assert:
import { assertEquals } from "@std/assert";
function deliveryFee(subtotal: number): number {
return subtotal >= 50 ? 0 : 5;
}
Deno.test("orders of 50 or more have free delivery", () => {
const actual = deliveryFee(60);
const expected = 5;
assertEquals(actual, expected);
});
orders of 50 or more have free delivery ... FAILED
ERRORS
orders of 50 or more have free delivery => ./programs/understanding-testing.test.ts:7:6
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- 0
+ 5
FAILED | 0 passed | 1 failed
error: Test failed
This failure crosses the runner's boundary. assertEquals(actual, expected) compared the two values, found 0 and 5, and threw an AssertionError. The runner caught that error, attached it to the test name and source location, printed a diff, and exited unsuccessfully.
Read the diff labels before reading its colors: actual is 0; expected is 5. The code is following the stated delivery rule, so the expectation is wrong. Restore it to 0:
Deno.test("orders of 50 or more have free delivery", () => {
const actual = deliveryFee(60);
const expected = 0;
assertEquals(actual, expected);
});
orders of 50 or more have free delivery ... ok
ok | 1 passed | 0 failed
The result now has the consequence automation needs. A passing run exits successfully. A failing run exits unsuccessfully.
Test files are ordinary modules with discoverable names
Deno type-checks a test module, evaluates its top-level code to register tests, and then runs the registered callbacks. Deno.test itself needs no import. Assertion helpers are a separate library, which is why assertEquals does need one.
The runner discovers these file-name shapes recursively:
delivery_fee_test.tsdelivery-fee.test.tstest.ts- any supported JavaScript or TypeScript module inside a directory named
__tests__
This course uses topic.test.ts under programs/. In an application, keeping a source file beside its test reduces the distance between them:
delivery-fee.ts
delivery-fee.test.ts
Move the production function into the first file, here programs/delivery-fee.ts, and export it:
export function deliveryFee(subtotal: number): number {
return subtotal >= 50 ? 0 : 5;
}
Import it from the test file:
import { assertEquals } from "@std/assert";
import { deliveryFee } from "./delivery-fee.ts";
Deno.test("orders of 50 or more have free delivery", () => {
const actual = deliveryFee(60);
const expected = 0;
assertEquals(actual, expected);
});
The test now reaches the production code through the same exported boundary another module would use. That makes refactoring the function's internal steps harmless as long as its observable behavior remains the same.
Use the narrowest command that matches the feedback you need:
# One file while working on it
deno test programs/understanding-testing.test.ts
# Every discovered test under one directory
deno test programs/
# Every discovered test under the current directory
deno test
# Re-run affected tests whenever a local dependency changes
deno test --watch programs/understanding-testing.test.ts
By default, deno test type-checks local modules before running them. A type error prevents the affected test module from running, and the diagnostic is a checking result rather than a test failure. --no-check can shorten an emergency diagnostic run, but it removes one of the runner's useful guarantees and should not become the normal command.
A useful assertion explains a failure
Any thrown error can fail a test. The standard assertion helpers are valuable because they state the intended relationship and produce a diagnostic tailored to it.
The helper names divide by the kind of claim:
| Claim | Helper to reach for |
|---|---|
| A condition is truthy or falsy | assert, assertFalse |
| Two values or structures are deeply equal | assertEquals |
| Two operands are the same value or object | assertStrictEquals |
| Two computed numbers are within a tolerance | assertAlmostEquals |
| A string matches a pattern or contains text | assertMatch, assertStringIncludes |
| An array contains selected elements | assertArrayIncludes |
| An object contains a deep subset | assertObjectMatch |
| A value exists or has a runtime class | assertExists, assertInstanceOf |
| Synchronous code throws | assertThrows |
| Asynchronous code rejects | assertRejects |
| Reaching a line is itself the failure | fail, unreachable |
You do not need to memorize the table. Start with assertEquals, assert, assertThrows, and assertRejects. Add a more specific helper when its name or failure message states your claim better.
Deep equality and identity ask different questions
assertEquals walks through arrays, plain objects, maps, sets, dates, and other supported structures. Two separately created objects can therefore be deeply equal. assertStrictEquals compares with Object.is, so two objects pass only when both operands lead to the same object.
import {
assert,
assertEquals,
assertStrictEquals,
} from "@std/assert";
Deno.test("an invoice has the expected contents", () => {
const actual = { currency: "NOK", total: 125 };
const expected = { currency: "NOK", total: 125 };
assertEquals(actual, expected);
assertStrictEquals(actual, actual);
assert(actual.total > 0);
});
All three assertions pass, but each records a different claim:
assertEquals(actual, expected)checks the two objects' contents. Their identities do not matter.assertStrictEquals(actual, actual)checks identity. Both arguments lead to the same object.assert(actual.total > 0)checks a boolean condition for which no more informative relationship helper exists.
assertStrictEquals(actual, expected) would fail because the two object literals created two objects. The equality reference follows every equality algorithm and the edge cases around NaN and signed zero.
Pass the observed value first and the expected value second. That order lets assertEquals label its diff correctly:
assertEquals(actual, expected);
Avoid reducing a rich comparison to a boolean when the helper can show both values:
// The failure can report only false.
assert(actual === expected);
// The failure can report and diff actual and expected.
assertEquals(actual, expected);
Computed decimals need a tolerance
Binary floating-point arithmetic cannot represent every decimal exactly. If a calculation accumulates a tiny representation error, exact equality asks the wrong question. assertAlmostEquals asks whether the difference falls within a tolerance:
import { assertAlmostEquals } from "@std/assert";
Deno.test("three tenths can be assembled from decimal parts", () => {
const actual = 0.1 + 0.2;
assertAlmostEquals(actual, 0.3);
});
Use exact equality for exact values. Use a deliberate tolerance for computed floating-point results. Do not replace every numeric assertion with approximate equality, because that would permit differences the program may be required to reject. The numbers reference develops that boundary in full.
Throwing is observable behavior
If invalid input is supposed to throw, a test should check the error rather than catch it and silently pass.
import { assertEquals, assertThrows } from "@std/assert";
function parsePort(text: string): number {
const port = Number(text);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new RangeError(`Invalid port: ${text}`);
}
return port;
}
Deno.test("parsePort rejects a port outside the valid range", () => {
const error = assertThrows(
() => parsePort("70000"),
RangeError,
"Invalid port",
);
assertEquals(error.message, "Invalid port: 70000");
});
assertThrows calls the function it receives. The assertion fails if that function returns normally, throws the wrong error class, or produces a message without the requested text. It returns the caught error, so the final line can make a more exact claim about it.
Do not write assertThrows(parsePort("70000")). That spelling calls parsePort before assertThrows receives anything, so the assertion never gets control. The wrapper function delays the call until the helper is ready to observe it.
The asynchronous counterpart is assertRejects, covered in Await the behavior and the asynchronous assertion.
Synchronous, asynchronous, and grouped tests
A synchronous Deno.test callback finishes when it returns. An asynchronous callback returns a promise, and the runner waits for that promise. This distinction decides whether the runner can observe work that finishes later.
Await the behavior and the asynchronous assertion
Suppose a repository rejects when a requested user does not exist:
import { assertEquals, assertRejects } from "@std/assert";
async function findUser(id: string): Promise<{ name: string }> {
if (id !== "ada") {
throw new Error(`Unknown user: ${id}`);
}
return { name: "Ada" };
}
Deno.test("findUser rejects an unknown id", async () => {
const error = await assertRejects(
() => findUser("grace"),
Error,
"Unknown user",
);
assertEquals(error.message, "Unknown user: grace");
});
There are two promises to account for.
- Calling
findUser("grace")produces a promise that rejects with the error. assertRejectsobserves that promise and itself returns a promise containing the caught error.await assertRejects(...)keeps the test callback pending until the assertion has reached a result.- Because the test callback is
async, Deno receives its promise and waits for it before reporting the test.
Forgetting either relevant await can let the callback finish before the check. Return or await every promise that belongs to a test. A rejected promise that the runner never receives cannot reliably fail that test.
The positive case follows the same rule:
Deno.test("findUser returns a known user", async () => {
const actual = await findUser("ada");
assertEquals(actual, { name: "Ada" });
});
Steps subdivide one larger test
The test context passed to a callback has a step method. Each awaited step gets its own name and report while contributing to the parent result:
Deno.test("deliveryFee", async (t) => {
await t.step("charges below the threshold", () => {
assertEquals(deliveryFee(49), 5);
});
await t.step("waives the fee at the threshold", () => {
assertEquals(deliveryFee(50), 0);
});
await t.step("waives the fee above the threshold", () => {
assertEquals(deliveryFee(51), 0);
});
});
deliveryFee ...
charges below the threshold ... ok
waives the fee at the threshold ... ok
waives the fee above the threshold ... ok
deliveryFee ... ok
Always await t.step. The returned promise tells the parent when the step has finished and whether it passed.
Steps are useful when the cases share one meaningful lifecycle. The other reference pages in this course use a separate top-level test for every behavioral claim, so each result can stand on its own. In an application suite, prefer separate top-level tests when cases should be independently filtered, retried, repeated, or set up. A step is a subdivision of its parent, not a replacement for test independence.
Deno.test.only is a temporary focus tool
While investigating one top-level test, add .only:
Deno.test.only("deliveryFee", async (t) => {
// Steps under investigation
});
Deno runs only focused tests, then deliberately makes the overall command fail even if they pass:
ok | 1 passed | 0 failed
error: Test failed because the "only" option was used
That unsuccessful exit prevents a focused suite from appearing healthy in automation. Use Deno.test.only only as a temporary local diagnostic, and change it back to Deno.test before running or committing the whole suite. The other reference pages use ordinary Deno.test calls throughout.
Use Deno.test.ignore when a test is intentionally skipped, or the object form for a conditional skip:
Deno.test({
name: "uses a macOS-only system API",
ignore: Deno.build.os !== "darwin",
fn() {
// macOS-specific claim
},
});
An ignored test is visible in the report. Give a long-lived skip a reason in nearby prose or an issue, because a silent permanent skip is no longer evidence.
Parameterized tests give each table row a result
Boundary rules often need the same action and assertion with several inputs. Deno.test.each registers one independently reported test per row:
Deno.test.each([
{ subtotal: 0, expected: 5 },
{ subtotal: 49, expected: 5 },
{ subtotal: 50, expected: 0 },
{ subtotal: 51, expected: 0 },
])(
"deliveryFee($subtotal) returns $expected",
({ subtotal, expected }) => {
assertEquals(deliveryFee(subtotal), expected);
},
);
The template substitutes each object's fields into its test name. If the threshold case fails, the report names deliveryFee(50) returns 0 rather than reporting only that some iteration in a loop failed.
A loop inside one test remains appropriate when the loop itself supports one indivisible claim. Use Deno.test.each when every row deserves its own result.
How to choose cases that deserve tests
The runner can execute any number of tests. It cannot decide which facts matter. That judgment is the central testing skill.
Start from behavior visible at a public boundary:
- a return value;
- a thrown error or rejected promise;
- a deliberate state change;
- written output;
- a call made to an external collaborator;
- an HTTP response;
- a file, database row, or message created as a result.
Avoid assertions about private helper calls, local variable names, or the exact sequence of internal steps unless that sequence is itself part of the contract. A test coupled to implementation fails during a harmless refactor. A test coupled to behavior survives the refactor and fails when a user-visible fact changes.
Partition the input space, then test the borders
Inputs that follow the same rule form a useful group. Choose a representative from each group, then test values at boundaries where the rule changes.
For deliveryFee, the groups and border are:
| Region | Representative case | Reason |
|---|---|---|
Below 50 | 20 | Ordinary paid-delivery order |
| Immediately below | 49 | Nearest value that should still pay |
| At the boundary | 50 | First value that should be free |
| Immediately above | 51 | Confirms the new rule continues |
The values 49, 50, and 51 are more informative than 20, 30, and 40, because the first group can expose an incorrect > or >= at the transition.
For each behavior, ask:
- What is the ordinary valid case?
- Where does the rule change?
- What are the smallest, largest, empty, or missing values?
- What invalid input has a promised response?
- What earlier bug should never return?
A regression test records the smallest case that reproduces a discovered bug. See it fail against the buggy code, fix the code, and keep the test. The suite then preserves the fact learned from that failure.
Some contracts are more naturally stated as a rule over many inputs. A property-based testing tool generates inputs and checks an invariant, such as “the delivery fee is always either 0 or 5.” Generated cases can reach combinations a hand-written table missed. Keep the generator's seed when a case fails so the run can be reproduced, and retain a small regression case after fixing the defect. Generation expands the search; it does not replace ordinary examples that communicate important boundaries.
One test should have one reason to fail
One reason to fail does not require exactly one assertion. These assertions describe one result object and belong together:
function checkout(quantity: number) {
return { status: "confirmed", quantity, total: quantity * 125 };
}
Deno.test("checkout returns the confirmed order", () => {
const order = checkout(2);
assertEquals(order.status, "confirmed");
assertEquals(order.quantity, 2);
assertEquals(order.total, 250);
});
If the test also checked an unrelated password rule, either behavior could fail under the same name. Split unrelated claims so the report identifies what changed.
Name a test as a behavioral sentence. Good names usually contain the condition and the outcome:
an empty cart has a total of zerocheckout rejects an item with no stocksaveUser writes the normalized email
Names such as test checkout, case 2, and should work force the reader to reconstruct the contract from the body.
Test at more than one scale
The same runner can execute tests at different scopes:
- A unit test exercises a small piece of logic with cheap, controlled collaborators.
- An integration test checks that real components agree at a boundary, such as an application and a database.
- An end-to-end test follows a complete user or system path through the assembled application.
These labels describe scope, not separate Deno APIs. A broad test can find wiring failures that a unit test cannot see. A focused test can pinpoint a rule quickly and cover awkward boundaries cheaply. A healthy project usually has many fast focused tests and a smaller number of slower broad tests, but the useful mix follows the risks of that project rather than a universal ratio.
Do not mock every dependency until only your own assumptions remain. Do not route every edge case through a real network and database when a controlled unit boundary can state it more precisely. Use the narrowest scope that can provide credible evidence for the behavior, then keep enough broader tests to prove the pieces connect.
At this point, you can turn a rule into a named Deno test, choose informative cases, read a failure, and test synchronous or asynchronous outcomes. That is enough for the next course program. The remaining sections explain the problems that appear when those individual tests become a project suite.
How to keep a suite trustworthy
A trustworthy test gives the same answer whenever the relevant behavior is unchanged. It owns its setup, waits for its work, cleans up what it opens, and does not inherit a hidden result from the test that happened to run before it.
Each test owns fresh state
Shared mutable state makes test order part of the setup. These two tests disagree depending on which one runs first:
const sharedItems: string[] = [];
Deno.test("adding an item records it", () => {
sharedItems.push("book");
assertEquals(sharedItems, ["book"]);
});
Deno.test("a new cart starts empty", () => {
assertEquals(sharedItems, []);
});
When the first test runs first, it leaves "book" behind and the second test fails. If the second runs first, both pass. The tests are not measuring only the cart behavior; they are also measuring execution order.
Create state inside each test instead:
Deno.test("adding an item records it", () => {
const items: string[] = [];
items.push("book");
assertEquals(items, ["book"]);
});
Deno.test("a new cart starts empty", () => {
const items: string[] = [];
assertEquals(items, []);
});
Application tests often move that creation into a fixture function:
function makeCart() {
return { items: [] as string[] };
}
Deno.test("a new cart starts empty", () => {
const cart = makeCart();
assertEquals(cart.items, []);
});
The fixture removes repetition without sharing the returned object. Every call to makeCart() creates new state.
Run deno test --shuffle occasionally. The runner randomizes test order and reports the seed, which helps expose a dependency that the usual order hides. Re-run with the reported seed to reproduce the same order. A shuffled failure is evidence of shared state, incomplete cleanup, or another undeclared dependency. The repair is to remove that dependency, not to restore the preferred order.
Prefer explicit setup; use hooks for genuine shared lifecycle
Setup written inside a test is visible beside the assertion that relies on it. Keep it there until repetition obscures the behavior.
Deno also provides module-scoped hooks:
Deno.test.beforeAllruns once before the module's tests.Deno.test.beforeEachruns before every test.Deno.test.afterEachruns after every test.Deno.test.afterAllruns once after the module's tests.
Here a fresh array is assigned before every test:
let items: string[];
Deno.test.beforeEach(() => {
items = [];
});
Deno.test("one test can add an item", () => {
items.push("book");
assertEquals(items, ["book"]);
});
Deno.test("the next test starts empty again", () => {
assertEquals(items, []);
});
beforeEach preserves independence because it replaces the array for every test. By contrast, an object created in beforeAll is shared. Use beforeAll for an expensive resource only when every test resets the relevant state or treats it as immutable.
Multiple before hooks run in registration order. Multiple after hooks run in reverse registration order, which unwinds nested setup. If one hook throws, Deno reports the affected test or suite as failed.
Hooks can make a body shorter, but they also move its prerequisites away from the assertion. A fixture function is usually the first extraction to try. Reach for hooks when the lifecycle truly applies to the whole module.
Cleanup belongs to the test that acquires the resource
A test that opens a file, socket, server, database transaction, timer, spy, or temporary directory must release it even when an assertion throws. This small session makes the lifecycle visible:
class TestSession {
closed = false;
async request(_path: string) {
return { status: 200 };
}
async close() {
this.closed = true;
}
}
Deno.test("the test session is released", async () => {
const session = new TestSession();
try {
const result = await session.request("/health");
assertEquals(result.status, 200);
} finally {
await session.close();
}
assertEquals(session.closed, true);
});
The finally block runs after a passing assertion and after a thrown failure. The last assertion also shows that the normal path closed the session. If a library returns a disposable object, using expresses the same ownership and calls its disposal method when the surrounding block ends. Give the next examples something to open: create a fixtures directory beside programs, holding a fixtures/message.txt with any short line and a fixtures/greeting.txt containing exactly the line hello:
mkdir fixtures
echo "a message" > fixtures/message.txt
echo "hello" > fixtures/greeting.txt
Deno.test("the fixture path points to a file", async () => {
using file = await Deno.open("fixtures/message.txt");
const information = await file.stat();
assertEquals(information.isFile, true);
});
This test runs with deno test --allow-read=fixtures. Permissions are part of the test environment explains how that command limits the callback. The important claim here is ownership: the test opens the file, so the test also closes it.
Control time, randomness, and external systems
The current clock, a random value, a public web service, and a developer's machine are uncontrolled inputs. A test that depends on them can fail without a behavior change. That is a flaky test, and repeated false alarms teach a team to ignore the suite.
Make volatile inputs explicit when the design permits it:
function isExpired(expiresAt: number, now: number): boolean {
return now >= expiresAt;
}
Deno.test("a token expires at its deadline", () => {
const deadline = Date.parse("2030-01-01T00:00:00Z");
assertEquals(isExpired(deadline, deadline), true);
});
The test passes the clock reading as data. Midnight, the local time zone, and the machine's current date cannot change its answer.
When existing code calls Date, setTimeout, or setInterval directly, the standard testing library provides FakeTime:
import { FakeTime } from "jsr:@std/testing@1/time";
Deno.test("the clock can advance without waiting", () => {
using time = new FakeTime(new Date("2030-01-01T00:00:00Z"));
let finished = false;
setTimeout(() => {
finished = true;
}, 1_000);
time.tick(1_000);
assertEquals(finished, true);
});
The disposable fake clock restores the real clock when the test ends. Use a seeded generator or inject a random-number function for randomness. Use a local controlled server or a test double for a remote service unless reaching the real service is the integration behavior under test.
A test double replaces one uncontrolled collaborator
Test double is the umbrella term for a controlled stand-in. The familiar categories describe what the stand-in contributes:
- A stub supplies a planned result.
- A spy records calls for later assertions.
- A fake implements a lightweight version, such as an in-memory repository.
- A mock often means a double with pre-programmed interaction expectations, though libraries use the word differently.
Suppose sending a receipt belongs to an external mail service. Pass that collaborator into the function so the unit test can supply a controlled implementation:
interface Mailer {
send(to: string, body: string): Promise<void>;
}
async function sendReceipt(
mailer: Mailer,
address: string,
total: number,
): Promise<void> {
await mailer.send(address, `Paid: ${total}`);
}
Deno.test("sendReceipt sends the total to the customer", async () => {
const calls: Array<{ to: string; body: string }> = [];
const mailer: Mailer = {
async send(to, body) {
calls.push({ to, body });
},
};
await sendReceipt(mailer, "ada@example.com", 250);
assertEquals(calls, [
{ to: "ada@example.com", body: "Paid: 250" },
]);
});
No email leaves the machine. The double records the observable interaction at the mailer boundary, and the assertion describes that interaction.
The standard @std/testing/mock module provides spy, stub, assertSpyCall, and related helpers when hand-written call recording becomes repetitive. Prefer the smallest double that makes the dependency controlled. A larger mock does not make a test more rigorous by itself, and assertions about every internal call make refactoring needlessly expensive.
Whenever a return value or final state can express the contract, prefer it over an interaction assertion. Inspect calls when the call itself is the outcome, as it is for sending an email or publishing a message.
Permissions are part of the test environment
Deno tests start under the same permission model as other Deno programs. Module loading does not give tested code general access to files, the network, environment variables, subprocesses, system information, or native libraries.
Grant the suite only the capability it requires, scoped when possible:
# Read only the fixture directory
deno test --allow-read=fixtures
# Reach only a local test server
deno test --allow-net=127.0.0.1:8080
# Read one named environment variable
deno test --allow-env=TEST_DATABASE_URL
Avoid -A or --allow-all as a reflex. Broad permission can hide an accidental dependency on a home-directory file, a live service, or a developer's environment.
The object form of Deno.test can narrow permissions for one callback:
import { assertRejects } from "@std/assert";
Deno.test({
name: "configuration loading reports denied file access",
permissions: { read: false },
async fn() {
await assertRejects(
() => Deno.readTextFile("secret.txt"),
Deno.errors.NotCapable,
);
},
});
Per-test permissions cannot exceed what the deno test command was granted. They can inherit that ceiling, reduce it, or deny capabilities. This lets a suite with scoped read permission still verify the application's no-permission path.
A test that legitimately reads fixtures can state its narrower boundary too:
Deno.test({
name: "loads the greeting fixture",
permissions: { read: ["fixtures"] },
async fn() {
const text = await Deno.readTextFile("fixtures/greeting.txt");
assertEquals(text, "hello\n");
},
});
The command must still include --allow-read=fixtures. The command establishes the maximum; the test definition states what this test receives within that maximum.
Sanitizers catch leaks that value assertions miss
An assertion can pass while the code leaves an async operation unfinished or an I/O resource open. Deno's test sanitizers compare the operations and resources present before and after a test to catch those leaks. The exit sanitizer also prevents tested code from making a false success by calling Deno.exit(0).
In Deno 2.8 and later, the async-operation and resource sanitizers are opt-in. Enable them for a whole project in deno.json:
{
"test": {
"sanitizeOps": true,
"sanitizeResources": true
}
}
Or enable them for one run:
deno test --sanitize-ops --sanitize-resources
One module can set its default before registering tests:
Deno.test.sanitizer({ ops: true, resources: true });
Deno.test("all work and resources are accounted for", async () => {
// Test body
});
The exit sanitizer is enabled by default. Leave it enabled unless the process exit is deliberately isolated in a subprocess test.
A sanitizer failure is not an assertion mismatch. It says the callback ended with work or resources still alive. Check for a missing await, an unread or uncancelled response body, an open file or connection, or a timer that was never cleared. deno test --trace-leaks adds creation traces when the owner is difficult to find.
Enable these sanitizers by default for new Deno projects. Disable one around a specific test only when that test intentionally owns a longer-lived operation and a dedicated integration test covers its lifecycle. Document the reason beside the exception.
Snapshots preserve large, reviewable output
A snapshot assertion serializes a value and compares it with a stored reference. Deno provides snapshots through the test context without another import:
Deno.test("the receipt has stable display output", async (t) => {
const receipt = {
heading: "Receipt",
lines: [
"2 × Book",
"Total: NOK 250",
],
};
await t.assertSnapshot(receipt);
});
Create or deliberately update the stored snapshot with:
deno test --update-snapshots programs/understanding-testing.test.ts
Deno writes a .snap TypeScript file inside __snapshots__ beside the test. Commit that file. On ordinary runs, omit --update-snapshots; the test fails with a diff when the serialized value changes.
The update flag records current output, not correct output. Inspect the snapshot diff before committing it. An unreviewed update can preserve a bug as the new expectation.
Snapshots fit output that is large yet meaningful as a whole: rendered markup, command output, a syntax tree, a formatted report. Prefer focused assertions for a small business rule. assertEquals(order.status, "confirmed") tells the reader what matters; a snapshot of a hundred-property order can bury that claim among unrelated changes.
Remove volatile data such as random identifiers and current timestamps before snapshotting, or supply deterministic values. A snapshot that changes on every run cannot preserve a stable contract.
Coverage measures execution, not correctness
Coverage records which lines, branches, and functions ran during the tests. Collect it with Deno's built-in V8 coverage support:
deno test --clean --coverage
The default coverage/ directory receives the data and reports. To render a terminal report from collected profiles, run:
deno coverage coverage/
You can ask a run to fail below a project threshold:
deno test --coverage --coverage-threshold=90
Coverage can reveal an unvisited error branch or a function no test calls. It cannot tell whether the assertions were meaningful. A test that calls every line and asserts true can produce excellent coverage and no confidence. A critical rule may deserve several boundary assertions even after its lines are covered once.
Use coverage as a map for investigation. Treat a threshold as a guard against accidental erosion, not as the definition of test quality. Exclude generated or platform-specific code deliberately and keep the reason visible.
Mutation testing asks a stronger question about the tests themselves. A mutation tool deliberately changes production expressions, such as replacing >= with >, and expects at least one test to fail. A mutation that survives points to a claim the suite did not protect. The negative control near the start of this guide is the smallest manual version of that idea. Deno does not need mutation testing for ordinary work, but it can reveal weak assertions in a mature, high-risk suite when coverage has stopped providing useful direction.
The runner tools for a growing project
The first command to learn is still deno test. The rest solve particular feedback and scale problems. Use them deliberately rather than placing every flag in every run.
Select the smallest relevant set locally
| Need | Command or definition |
|---|---|
| One test file | deno test path/to/cart.test.ts |
| One directory | deno test src/cart/ |
| Names containing text | deno test --filter "checkout" |
| Names matching a regular expression | deno test --filter "/checkout.*declines/" |
| Tests affected by working-tree changes | deno test --changed |
Tests affected since the branch left main | deno test --changed=origin/main |
| Tests importing a named module, directly or transitively | deno test --related=src/cart.ts |
| Continuous local feedback | deno test --watch |
| Temporary source-level focus | Deno.test.only(...) |
| Visible intentional skip | Deno.test.ignore(...) |
--filter matches top-level test names. When a parent name matches, all of its steps run; the filter does not independently select step names.
--changed consults Git changes. --related starts from modules you name. Both build the collected tests' import graphs and keep test modules that reach an affected source module, even through intermediate imports. They are useful for a fast local loop or pre-commit check. Continuous integration should still run the full suite, because dynamic dependencies, external state, and configuration can exist outside the static module graph.
Use test.include and test.exclude in deno.json when file discovery itself needs a project-wide boundary:
{
"test": {
"include": ["src/**/*.test.ts", "tests/**/*.test.ts"],
"exclude": ["tests/fixtures/**"],
"sanitizeOps": true,
"sanitizeResources": true
}
}
This prevents support files under tests/fixtures from being collected as tests. A suite that needs a different discovery boundary, such as end-to-end tests run only in a deployment job, can use a dedicated Deno config for that task.
Diagnose ordering, concurrency, and flakiness
| Tool | What it changes | What its result can reveal |
|---|---|---|
--shuffle=1234 | Randomizes order with a reproducible seed | Hidden order dependencies |
--parallel | Runs test modules in worker threads | Unsafe shared external resources and slow serial suites |
DENO_JOBS=4 | Limits --parallel worker count | Resource contention at higher concurrency |
--repeats=4 | Runs every test four additional times | Intermittent behavior |
--retry=2 | Re-runs a failure up to two times | A temporary allowance for known flaky dependencies |
--trace-leaks | Records origins for leaked operations and resources | Missing cleanup or await |
Tests within one module run sequentially. --parallel distributes modules across workers, so separate files may run at the same time. Give parallel integration tests separate databases, ports, directories, or tenant identifiers. A test that requires exclusive global state must arrange that exclusivity explicitly.
--repeats is an investigative pressure tool: every execution must pass. --retry is an allowance: a later successful attempt makes the test pass. A retry can keep a known unreliable remote dependency from blocking all work, but it also hides the frequency and cause of failure. Prefer removing nondeterminism. If a retry is unavoidable, track why it exists and keep the retry count bounded.
A timeout prevents a hung test from holding the suite forever:
Deno.test({
name: "the local server becomes ready",
timeout: 5_000,
async fn() {
await waitUntilReady();
},
});
An omitted timeout or 0 means no deadline. A timeout should be comfortably above normal runtime and below the point where waiting stops providing useful information. It is a containment boundary, not proof that code is fast. Use Deno.bench when performance itself is the subject.
Control failure volume and reports
deno test --fail-fast stops after the first failure. It shortens the feedback loop when later results would be noise. A normal run reports all failures, which is better when you want the complete repair list.
Deno's default pretty reporter is for humans. Other reporters serve compact terminals and automation:
deno test --reporter=dot
deno test --reporter=tap
deno test --junit-path=reports/tests.xml
Use --shard=1/3, --shard=2/3, and --shard=3/3 to divide discovered test files across three CI workers. Sharding reduces wall-clock time; it does not make tests independent, so run --shuffle and --parallel locally enough to expose shared assumptions first.
Type tests and documentation tests check different contracts
A runtime assertion demonstrates what JavaScript did after the program ran. It cannot prove that TypeScript rejects code. A negative type contract can use @ts-expect-error and deno check:
type Port = number & { readonly __brand: "Port" };
declare function connect(port: Port): void;
// @ts-expect-error A plain number has not been validated as a Port.
connect(8080);
deno check tests/connect.type-test.ts
The directive says the next line must produce a type error. If a future change makes connect(8080) acceptable, TypeScript reports the now-unused directive and the type test fails. Keep runtime behavior tests and type-contract tests conceptually separate even if the same project runs both.
deno test --doc checks executable code fences in JSDoc and Markdown. Documentation tests are useful for public APIs whose examples should continue to type-check and run. They complement ordinary tests; an example optimized for teaching rarely covers all boundaries of the underlying behavior.
Deno.test is one supported API, not the only one
Deno's runner treats Deno.test and Node's built-in node:test API as first-class test definitions. Choose node:test when the same suite must run unchanged on Node and Deno. Choose Deno.test for Deno-native permissions, sanitizers, steps, and snapshots. This course uses Deno.test so those runtime boundaries remain visible.
The standard library also offers expect matchers and a describe/it BDD spelling. Those APIs change how a claim is written and grouped, not what makes it trustworthy. Learn one assertion vocabulary well, then translate when a project has an established style.
A practical testing workflow
Tests provide the shortest feedback when they participate in the change rather than arriving after it.
For a new behavior or repaired bug:
- State the observable contract in one sentence.
- Arrange the smallest case that distinguishes the desired behavior from the current behavior.
- Write the assertion and run it. Confirm that it fails for the intended reason.
- Change the production code until that test passes.
- Add nearby boundaries and promised error cases that carry new information.
- Refactor names and structure while the tests remain green.
- Run the broader suite, then formatting, linting, and type checks before handing off the change.
This is often called red, green, refactor. Writing the test first can improve an API by forcing you to use it before implementing it, but test-first development is a technique rather than a moral rule. The non-negotiable part is evidence: see the new check detect the missing behavior, then see it protect the behavior you added.
Read a failure before editing it
When a run fails, classify the evidence first:
- A type diagnostic means checking prevented the module from running.
- An assertion failure means actual and expected behavior disagreed.
- An unexpected thrown error or rejection means the callback could not reach a normal end.
- A sanitizer failure means work or a resource outlived the callback.
- A timeout means the test did not finish before its deadline.
Then read the test name, the first relevant application stack frame, and the actual/expected diff. Reduce the run with a file path or --filter, but do not change the expectation merely to make the report green. Decide whether the production behavior changed incorrectly, the contract intentionally changed, or the test never represented the contract accurately.
For a snapshot failure, inspect the diff before updating. For a flaky failure, reproduce it with the shuffle seed, repeats, or the relevant concurrency. A failure is a new observation to explain, not an obstacle to erase.
Keep the local loop fast and the final gate complete
During an edit, run one file in watch mode:
deno test --watch --no-clear-screen programs/understanding-testing.test.ts
Before committing, remove every .only and run the project gate. Add a tasks property like this alongside the existing properties in deno.json:
{
"tasks": {
"check": "deno fmt --check && deno lint && deno test --frozen"
}
}
deno task check
fmt --check checks the files' layout. lint checks suspicious patterns. test type-checks local test graphs and runs the runtime claims. --frozen refuses an unexpected lockfile change. Projects with separate type-only entry points may add an explicit deno check command. Projects with integration or end-to-end suites may give each one a separate task whose services and permissions are visible in its name and definition.
In continuous integration, start from a clean checkout, use the committed lockfile and snapshots, grant only declared permissions, and run the full suite. Never pass --update-snapshots in the verification job. If coverage is a gate, collect it from the same representative test commands the project relies on.
The working standard
You now have enough machinery to test a Deno program without treating the runner as magic. Use this as the compact standard when writing the next test:
- State observable behavior, not an implementation step.
- Give the test a name containing the condition and outcome.
- Keep arrange, act, and assert identifiable.
- Pass actual first and expected second to relationship assertions.
- Use the helper that produces the most informative failure.
- See a new test fail for the intended reason before trusting its passing state.
- Cover the ordinary case, the rule's boundaries, and promised invalid-input behavior.
- Await every promise that belongs to the test.
- Give every test fresh state and release every resource it acquires.
- Control the clock, randomness, external services, environment, and permissions.
- Use doubles at real dependency boundaries, and prefer final outcomes over internal call counts.
- Enable operation and resource sanitizers.
- Treat snapshots as reviewed files and coverage as an investigation map.
- Use focused local runs for speed, then remove
.onlyand run the full gate.
The shortest honest definition still holds: a test runs a program in a controlled situation and checks one observable claim. Deno supplies discovery, execution, isolation controls, and reports. Assertion helpers turn disagreements into failures worth reading. The quality of the suite comes from the claims you choose and from whether each test remains capable of detecting the behavior it promises to protect.
For a version-specific option, ask the installed runtime with deno help test. Keep the Deno testing guide, the deno test command reference, and the @std/assert API nearby when the suite needs a tool not covered here.