Skip to content
← Back

Lesson 2 of 6

Write unit and integration tests that can fail

Split pure rules from DOM and storage, write Vitest checks that fail when the behaviour breaks, and prove a failure path instead of only the happy path.

Put rules in a module the DOM does not own

Tests only make an agent safer if they fail when the behaviour is wrong. The cheapest tests run against pure functions: given input, they return output, with no document, no localStorage, and no clock.

Keep validateTitle, addTask, toggleStatus, filterTasks, and summarise in src/model.ts. That file must not import the DOM. Storage belongs in src/storage.ts behind a tiny adapter (getItem / setItem) so tests can pass a Map instead of the real browser store. The page wiring in src/app.ts mounts into an element you provide.

If add still lives in src/main.ts, extract mountTaskTracker into src/app.ts so main.ts only boots the page. An agent can do the extract; you read the diff: same behaviour you already clicked, not a rewrite.

Unit-test the boundary, not only the happy path

Open the runnable tests/model.test.ts in the starter; it covers trim, empty input, newest-first ordering, and non-mutation. This additional file is a complete small example, not a fragment with missing imports. Save it as tests/title-boundary.test.ts:

import { expect, it } from 'vitest';
import { TITLE_MAX_LENGTH, validateTitle } from '../src/model';

it('accepts 160 characters and rejects 161', () => {
  expect(validateTitle('a'.repeat(TITLE_MAX_LENGTH))).toHaveLength(160);
  expect(() => validateTitle('a'.repeat(TITLE_MAX_LENGTH + 1))).toThrow(/160/);
});

Run npm test. Temporarily change the implementation to accept 161 characters and run again. The boundary test must turn red. Restore the rule and rerun. Keep both outputs; do not weaken the assertion to make the implementation pass.

Integration tests wire DOM, model, and storage

A unit test of validateTitle does not prove a form saved. Open the starter's complete, runnable tests/app.integration.test.ts: its first 50 lines define memoryAdapter, mount, submit, and titles. These are local helpers, not library APIs. The test injects a deterministic id and clock into the real mountTaskTracker.

Run only that file:

npx vitest run tests/app.integration.test.ts

In the first test, inspect both assertions:

expect(titles(root)).toEqual(['Review the acceptance criteria']);
expect(adapter.data.get(STORAGE_KEY)).toContain('Review the acceptance criteria');

The first proves rendering; the second proves stored bytes. Change the expected title in a temporary copy of that test, run it, then restore it. Use the supplied vitest.config.ts (jsdom environment). afterEach clears the body so one test cannot inherit another test's nodes. The helper names in this lesson come from that starter file.

Replace weak assertions and add a failure case

These assertions can stay green while the product is wrong: toBeTruthy(), not.toThrow(), toBeGreaterThan(0), or a snapshot of the whole page. Prefer the exact title, the exact status text, and the exact stored JSON.

Add one failure case at the storage boundary: an adapter whose setItem throws. The store should raise a named error (for example StorageUnavailableError) instead of pretending the save worked. Add another for unreadable JSON: load() throws, and adapter.data still holds the original string. The app must not “fix” corrupt data by overwriting it.

Run npm test and copy the summary line (files and test count). Then delete one assertion’s expected title, run again, and confirm that test fails for the reason you expect. Restore it. Coverage percentage is a signal about what ran, not a goal.

Sources

How did this lesson go? Give feedback →