The previous lesson ended with a clear boundary: ESLint can tell you that a variable is unused, but it cannot know that the backlog summary must come to 45 open hours out of 48. That is behavior, and behavior can only be checked one way: by running the code with known inputs and comparing the output with what you expect. Automated, repeatable, in seconds. This lesson is where Module 8 settles its debts: the three tests you wrote down while debugging, and the promise made back in 03-03 about why pure functions were easy to test. You are going to set Jest up over Nómada Tasks, learn the anatomy of a test and the Arrange-Act-Assert pattern, master the matchers that actually get used, cover the R6 transition table with parameterized tests, test asynchronous code, measure coverage without falling into its trap, and write your first rule with the red-green-refactor cycle. And all of it without opening the browser once.
Contents
- What an automated test is
- What the team gains: three benefits, and none of them is "finding bugs"
- The testing pyramid
- Jest: installing and first run
- Configuration for ES modules
- Anatomy of a test:
describe,testand the AAA pattern - The matchers that actually get used
toBe,toEqualandtoStrictEqual: comparison by reference- Checking that something throws
- Hooks:
beforeEachand friends - Shared state, the source of flaky tests
- Parameterized tests with
test.each - Testing asynchronous code
- What makes a module testable
- Coverage: what it really measures
- Naming a test and the single-reason rule
- TDD: red, green, refactor
- Nómada Tasks: the complete model test suite
- Common Mistakes and Tips
- Exercises
- Conclusion
- What an automated test is
An automated test is a program that runs another program and checks that it does what it should. That is all. Everything else —Jest, matchers, coverage— is infrastructure around that idea.
In fact, you have already written tests without knowing it. This, in the console, is a manual test:
const board = new Board('Taller Nómada', createBacklog());
board.summary('2026-09-20');
// { total: 6, open: 5, totalHours: 48, openHours: 45, overdue: 1, effort: 124 }You look at the result, check that it says 45, and move on. The problem is not the check: it is that you are the one who runs it, remembers it and decides whether the number is right. A week later, nobody looks at it again. The automated version says the same thing, but it says it on its own:
test('the canonical backlog summary gives 45 open hours out of 48', () => {
const board = new Board('Taller Nómada', createBacklog());
expect(board.summary('2026-09-20').openHours).toBe(45);
});Those three lines, run on every commit, would have caught case 3 from 08-01 —the openHours that added up to 48— the very instant it was introduced, not weeks later through a report from Lucía.
flowchart LR
A["Arrange<br/>a known input"] --> B["Act<br/>run the code"]
B --> C["Assert<br/>the expected output"]
C -->|they match| D["✓ green"]
C -->|they differ| E["✗ red<br/>with the details"]
- What the team gains: three benefits, and none of them is "finding bugs"
It sounds counterintuitive, but tests are not especially good at finding new bugs. A bug you never thought of will have no test. What tests do extraordinarily well is something else:
1 · A safety net for refactoring. This is the big one. In 08-01 somebody "optimized" a getter and broke the hours count without anybody noticing. With a test suite, that change turns red in two seconds. The consequence is enormous: you can change the code without fear. Without tests, every refactor is a gamble, and the rational reaction is to touch nothing; the code rots because nobody dares improve it.
2 · Executable documentation. A document saying "you cannot go from done to in progress" can go stale and nobody notices. A test that asserts it fails the day it stops being true. Read this block:
describe('Task · status transitions (R6)', () => {
test('pending → in-progress is allowed', () => { … });
test('in-progress → done is allowed', () => { … });
test('in-progress → pending is allowed (a task can be handed back)', () => { … });
test('done → in-progress throws ValidationError: a finished task is not reopened', () => { … });
test('pending → done throws ValidationError: it has to be started first', () => { … });
});Without reading a line of implementation you already know what the domain rules are. And that documentation cannot lie, because it runs.
3 · Pressure towards a better design. This is the subtlest and the most valuable in the long run. When a function is hard to test, it is almost always because it does too much, depends on too much, or mixes decision with effect. Difficulty in testing is a bad-design detector. You will see it clearly in section 14: Board.summary() is tested in three lines because it takes data and returns data; saveAndRender() cannot be tested without a browser because it mixes three responsibilities.
A fourth benefit, more prosaic but decisive day to day: the feedback loop. Checking by hand that the six status rules still work means opening the application, creating tasks and pressing buttons: five minutes if you concentrate. The same check, automated, takes 200 milliseconds and runs twenty times an hour without you thinking about it.
- The testing pyramid
Not all tests cost or are worth the same. The testing pyramid is the classic model for distributing them:
flowchart TD
E["End-to-end (E2E)<br/>The whole app in a real browser<br/>~5%"]
I["Integration<br/>Several pieces together: model + data, view + DOM<br/>~20%"]
U["Unit<br/>One isolated unit: a class, a function<br/>~75%"]
E --> I --> U
style E fill:#fecaca,stroke:#b91c1c
style I fill:#fde68a,stroke:#b45309
style U fill:#bbf7d0,stroke:#15803d
| Level | What it tests | Speed | Fragility | What a failure tells you |
|---|---|---|---|---|
| Unit | One function or class, isolated | Milliseconds | Very low | Exactly which line is wrong |
| Integration | Several pieces collaborating | Tenths of a second | Medium | That two pieces do not understand each other |
| E2E | The whole application, as Marta uses it | Seconds | High | That something along the journey does not work |
The pyramid shape is justified by two axes that pull in opposite directions: as you go up, confidence increases (a green E2E test means the application really works) but so do cost and fragility (it takes seconds, it breaks when a label changes, and when it fails it does not say where).
Two warnings about this model:
- The pyramid is a guide, not a law. The percentages depend on the project. In Integration Testing you will meet the testing trophy, a variant that gives more weight to integration and that fits interface-heavy applications better.
- The antipattern to avoid has a name: the ice cream cone. Lots of slow E2E tests on top, few unit tests underneath. The suite takes forty minutes, fails intermittently, nobody knows why, and it ends up switched off.
In this module you build all three levels over the same project: the model unit tests here, the integration tests in 08-05, and three well-chosen E2E journeys in 08-06.
- Jest: installing and first run
Jest is a complete test runner: it finds the files, runs them in parallel, provides the assertions, measures coverage and ships with built-in test doubles (that last part, in 08-04).
Jest discovers tests by convention. Any of these locations works:
| Pattern | Example | When it suits |
|---|---|---|
*.test.js next to the code |
js/model/task.test.js |
Small projects: the test sits alongside |
__tests__/ |
js/model/__tests__/task.js |
Jest's default convention |
A mirrored test/ folder |
test/model/task.test.js |
The one we will use: keeps code and tests apart |
For Nómada Tasks we use a mirrored folder, because it keeps the js/ folder that gets served to the browser clean:
nomada-tasks/
js/ ← the application (gets deployed)
test/ ← the tests (never deployed)
model/
task.test.js
board.test.js
util/
dates.test.js
helpers/
test-backlog.js ← shared utilities, with no tests insideThe scripts:
{
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:watch": "npm test -- --watch",
"test:cov": "npm test -- --coverage"
}
}And the run:
$ npm test
PASS test/model/task.test.js
PASS test/model/board.test.js
Test Suites: 2 passed, 2 total
Tests: 31 passed, 31 total
Snapshots: 0 total
Time: 0.842 sWatch mode is where Jest changes the way you work:
It stays running and, on every save, reruns only the tests affected by what you changed (it uses the dependency graph of your imports to work that out). With the editor on the left and the results on the right, the write → save → see green cycle drops below a second. Its interactive shortcuts:
| Key | What it does |
|---|---|
a |
Run all the tests |
f |
Only the ones that failed last time |
p |
Filter by file name pattern |
t |
Filter by test name |
o |
Only what relates to files modified in Git |
- Configuration for ES modules
There is a real stumbling block here worth understanding, because it is the first stone everybody trips over.
Jest was born when Node used CommonJS (require). Your project uses ES modules (import/export), which is what you studied in 05-04 and what the browser runs natively. There are two ways to reconcile them:
Route A · Native ES modules (the one we use). Node executes the real import statements; there is no transformation in the middle, so what you test is exactly what you deploy.
// package.json
{
"type": "module",
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
}
}// jest.config.js
export default {
// 'node' is enough for the model: it does not touch the DOM. In 08-05 it becomes 'jsdom'.
testEnvironment: 'node',
// No transformations: Node understands the imports as they are.
transform: {},
testMatch: ['**/test/**/*.test.js'],
// Application files that count towards coverage
collectCoverageFrom: ['js/**/*.js', '!js/app.js'],
// More readable output when something fails
verbose: false
};Node's --experimental-vm-modules flag is what enables ES modules inside Jest's sandboxed environment. The name is scarier than it deserves: it is the officially documented route and it works reliably. Since it cannot be passed through jest.config.js, it goes in the script (or in NODE_OPTIONS).
Route B · Transform with Babel. You install babel-jest with a preset that converts the import statements to require before running. Advantage: everything works with no flags and with full support for the module mocking features. Drawback: you are testing transformed code, not what you deploy, and there is one more layer to configure and to break.
// babel.config.js — only if you choose route B
export default {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
};| Route A · native ESM | Route B · Babel | |
|---|---|---|
| Fidelity to production | High: the same code | Medium: transformed |
| Configuration | One Node flag | Babel + preset |
jest.mock of modules |
Needs the ESM API (08-04) | Straightforward |
| Recommendation here | ✅ This one | A valid alternative |
We choose route A because it fits the spirit of the project: Nómada Tasks is served with no bundler, and testing the real code avoids the entire class of "it worked in the tests but not in the browser" failures.
- Anatomy of a test:
describe, test and the AAA pattern
describe, test and the AAA pattern// test/model/task.test.js
import { Task } from '../../js/model/task.js';
import { ValidationError } from '../../js/model/errors.js';
const VALID_DATA = {
id: 1,
title: 'Redesign the multipurpose room',
assignee: 'Iván',
priority: 'high',
status: 'in-progress',
tags: ['space', 'design'],
estimatedHours: 12,
dueDate: '2026-09-30',
reviewer: 'Marta'
};
describe('Task', () => {
describe('construction', () => {
test('keeps the title with no surrounding whitespace', () => {
// Arrange
const data = { ...VALID_DATA, title: ' Redesign the room ' };
// Act
const task = new Task(data);
// Assert
expect(task.title).toBe('Redesign the room');
});
});
});The three pieces:
describe(name, fn)groups related tests. It can be nested, and nesting it well produces output that reads like an index of the behavior. It is not compulsory, but as soon as you go past ten tests per file, it is what makes them navigable.test(name, fn)is a test.itis exactly the same thing, an alias that comes from the tradition of writingit('should trim the title'). Use whichever you prefer, but always use the same one across the project.expect(value).matcher(expected)is the assertion. If it holds, nothing happens; if it does not, Jest throws an error with the detailed difference.
The AAA pattern (Arrange-Act-Assert) is the structure every test body should have:
| Phase | What you do | Warning sign |
|---|---|---|
| Arrange | You build the data and the object under test | If it takes 30 lines, the object is too complicated |
| Act | One single call: the thing you are testing | If there are three calls, those are probably three tests |
| Assert | The assertions about the result | If you check five unrelated things, split it up |
Separating the three phases with blank lines (or with comments at first, while you get used to it) is one of those small habits that make somebody else's tests readable in five seconds.
- The matchers that actually get used
Jest ships with dozens of matchers. These are the ones that cover 95% of the cases:
| Matcher | Checks | Example |
|---|---|---|
toBe(v) |
Equality by identity (Object.is) |
expect(t.estimatedHours).toBe(12) |
toEqual(v) |
Structural, recursive equality | expect(t.tags).toEqual(['space', 'design']) |
toStrictEqual(v) |
Structural + class type + undefined keys |
expect(t.toJSON()).toStrictEqual({ … }) |
toContain(v) |
An array contains a value (by identity) | expect(t.tags).toContain('design') |
toContainEqual(v) |
An array contains an equivalent object | expect(tasks).toContainEqual({ id: 3, … }) |
toHaveLength(n) |
The .length of an array or string |
expect(board.openTasks).toHaveLength(5) |
toMatchObject(v) |
The object contains at least these properties | expect(r).toMatchObject({ openHours: 45 }) |
toThrow(v) |
The function throws (optionally, something specific) | expect(() => new Task({})).toThrow(ValidationError) |
toBeCloseTo(n, d) |
Floating-point numbers, with tolerance | expect(average).toBeCloseTo(8.166, 2) |
toBeNull() |
Exactly null |
expect(board.findById(99)).toBeNull() |
toBeUndefined() |
Exactly undefined |
expect(t.reviewer).toBeUndefined() |
toBeTruthy() / toBeFalsy() |
Truthiness (01-07) | expect(t.isOpen).toBeTruthy() |
toBeGreaterThan(n) |
Numeric comparison | expect(r.effort).toBeGreaterThan(100) |
not |
Negates any matcher | expect(t.tags).not.toContain('web') |
Two tips on choosing:
- Prefer the most specific matcher.
expect(board.openTasks).toHaveLength(5)fails saying "expected length 5, received 6";expect(board.openTasks.length === 5).toBe(true)fails saying "expected true, received false", which helps nobody. The failure message is part of a test's value. - Be suspicious of
toBeTruthy.expect(t.isOpen).toBeTruthy()also passes ifisOpenreturns'yes',1or[]. If you expect a boolean, writetoBe(true).
toMatchObject deserves its own comment because it strikes the best balance between precision and maintenance on large objects:
// Checks ONLY what matters to this test; the rest can change without breaking it
expect(board.summary(TODAY)).toMatchObject({ openHours: 45, overdue: 1 });
// As opposed to this, which breaks if tomorrow the summary adds a new field:
expect(board.summary(TODAY)).toStrictEqual({
total: 6, open: 5, totalHours: 48, openHours: 45, overdue: 1, effort: 124
});Both versions have their place. The second is appropriate precisely for the canonical summary, where you want adding a field to force a review of the test. The first, for everything else.
toBe, toEqual and toStrictEqual: comparison by reference
toBe, toEqual and toStrictEqual: comparison by referenceThis is where people trip up most, and it is exactly the topic of 01-07 and 04-08 applied to tests.
const a = { id: 1, title: 'Redesign the room' };
const b = { id: 1, title: 'Redesign the room' };
expect(a).toBe(b); // ✗ FAILS: they are two different objects in memory
expect(a).toEqual(b); // ✓ passes: they have the same contents
expect(a).toBe(a); // ✓ passes: it is literally the same objecttoBe uses Object.is, which for objects is comparison by reference: two objects with the same contents are different. It is the 04-08 trap, now with consequences in your tests.
The operational rule:
| What you compare | Matcher |
|---|---|
Numbers, strings, booleans, null, undefined |
toBe |
| Objects, arrays, maps, sets | toEqual or toStrictEqual |
| That it is literally the same object (identity) | toBe — on purpose |
And the difference between toEqual and toStrictEqual, which is three concrete differences:
// 1 · Properties whose value is undefined
expect({ id: 1, reviewer: undefined }).toEqual({ id: 1 }); // ✓ passes: toEqual ignores them
expect({ id: 1, reviewer: undefined }).toStrictEqual({ id: 1 }); // ✗ fails: they are different
// 2 · Class type
class Task { constructor() { this.id = 1; } }
expect(new Task()).toEqual({ id: 1 }); // ✓ passes: same contents
expect(new Task()).toStrictEqual({ id: 1 }); // ✗ fails: one is a Task, the other a plain object
// 3 · Holes in arrays
expect([, 1]).toEqual([undefined, 1]); // ✓ passes
expect([, 1]).toStrictEqual([undefined, 1]); // ✗ failsThe first difference is the one that matters in Nómada Tasks. R8 says a task with no assignee has assignee: null, never '' and never undefined. With toEqual, a toJSON() that returned { …, reviewer: undefined } would pass the test and would then produce JSON with no reviewer field on serialization —because JSON.stringify drops undefined values (04-08)— and fromJSON would rebuild something different. With toStrictEqual, the test fails and the bug is seen before it reaches storage.
A rule for this project: use toStrictEqual to check the output of toJSON(), and toEqual for the rest.
And one use of toBe on objects that is deliberate, checking the defensive copies from 05-03:
test('the tasks getter returns a copy, not the internal array', () => {
const board = new Board('Taller Nómada', createBacklog());
expect(board.tasks).not.toBe(board.tasks); // two calls, two different arrays
expect(board.tasks).toEqual(board.tasks); // but with the same contents
});That test protects a specific design decision: if somebody "optimized" the getter by returning this.#tasks directly, a .sort() in the view would reorder the model. The test would turn red instantly.
- Checking that something throws
The business rules of Nómada Tasks are expressed by throwing ValidationError. Testing that it is thrown is as important as testing that something works.
The matcher is toThrow, and there is one detail that catches everybody out the first time:
// ❌ WRONG: the exception is thrown while evaluating the argument, before expect is reached
expect(new Task({ title: '' })).toThrow(ValidationError);
// ✅ RIGHT: you pass a FUNCTION that Jest will run inside a try/catch
expect(() => new Task({ title: '' })).toThrow(ValidationError);toThrow takes four forms of argument, from least to most specific:
const create = () => new Task({ id: 1, title: ' ', estimatedHours: 4 });
expect(create).toThrow(); // 1 · it throws something
expect(create).toThrow(ValidationError); // 2 · it throws THAT type
expect(create).toThrow('The title cannot be empty.'); // 3 · the message CONTAINS that text
expect(create).toThrow(/title/i); // 4 · the message matches the regular expressionRecommendation: use form 2, the type. The type is stable; the message text changes when somebody improves the wording, and you do not want improving a message to turn ten tests red.
But there is something toThrow does not check that is essential in this project: the error's field property. That field is what let the form in 06-07 move focus to the right <input>. There are two patterns for checking it:
// Pattern A · explicit try/catch with expect.assertions
test('the hours error identifies the estimatedHours field', () => {
expect.assertions(3); // ← guarantees that all 3 assertions run
try {
new Task({ id: 1, title: 'Test', estimatedHours: 99 });
} catch (error) {
expect(error).toBeInstanceOf(ValidationError);
expect(error.field).toBe('estimatedHours');
expect(error.receivedValue).toBe(99);
}
});
// Pattern B · capture with a helper function (more readable when it repeats)
function capture(fn) {
try { fn(); return null; } catch (error) { return error; }
}
test('the hours error identifies the estimatedHours field', () => {
const error = capture(() => new Task({ id: 1, title: 'Test', estimatedHours: 99 }));
expect(error).toBeInstanceOf(ValidationError);
expect(error).toMatchObject({ field: 'estimatedHours', receivedValue: 99 });
});expect.assertions(n) in pattern A is essential and deserves an explanation. Without that line, if the code stopped throwing, the try would finish without an error, the catch would not run, the test would check nothing… and it would pass green. A test that passes once the behavior is broken is worse than no test at all. expect.assertions(3) forces exactly three assertions to run; otherwise, Jest fails.
- Hooks:
beforeEach and friends
beforeEach and friendsTests in the same describe usually need the same setup. Hooks centralize it:
| Hook | When it runs | Typical use |
|---|---|---|
beforeEach(fn) |
Before each test | Create a clean board. By far the most used |
afterEach(fn) |
After each test | Clean up: restore doubles, empty storage |
beforeAll(fn) |
Once, before the whole block | Expensive, read-only setup |
afterAll(fn) |
Once, at the end | Close resources |
describe('Board', () => {
let board;
beforeEach(() => {
// A NEW board for every test: nobody inherits the neighbor's changes
board = new Board('Taller Nómada', createBacklog());
});
test('adds the six backlog tasks', () => {
expect(board.total).toBe(6);
});
test('changing a status does not alter the total', () => {
board.changeStatus(2, 'in-progress');
expect(board.total).toBe(6);
});
});Here the design from 05-04 pays off: createBacklog() is a function that returns new instances, not an already-built exported array. If it were a shared array, the changeStatus(2, …) in the second test would contaminate the next one. That decision, taken three modules ago for encapsulation reasons, is what today makes your tests independent. It is a good example of how good design pays interest in unexpected places.
The execution order with nested describe blocks follows a clear rule: every outer beforeEach from the outside in, then the test, then every afterEach from the inside out.
describe('Board', () => {
beforeEach(() => console.log('1 · outer'));
describe('with one closed task', () => {
beforeEach(() => console.log('2 · inner'));
test('counts 4 open', () => console.log('3 · test'));
});
});
// Output: 1 · outer → 2 · inner → 3 · test
- Shared state, the source of flaky tests
This section is short and critical. The rule: every test must be able to run on its own, in any order, and give the same result.
When that is broken, the most maddening failure of all appears: the test that passes alone and fails in the suite, or the other way round. Look at the mistake:
// ❌ WRONG: the board is created ONCE and every test shares it
const board = new Board('Taller Nómada', createBacklog());
test('task 2 can be started', () => {
board.changeStatus(2, 'in-progress');
expect(board.findById(2).status).toBe('in-progress');
});
test('the backlog has 5 open tasks', () => {
expect(board.openTasks).toHaveLength(5); // passes… until somebody closes one above
});
test('task 2 starts out pending', () => {
expect(board.findById(2).status).toBe('pending'); // ✗ FAILS because of the first one
});The third test is correct, and it fails. And it fails only if the first one ran before it, which means running it in isolation goes green and it becomes impossible to understand.
// ✅ RIGHT: beforeEach rebuilds the state
let board;
beforeEach(() => { board = new Board('Taller Nómada', createBacklog()); });Four sources of shared state to watch out for:
| Source | How it shows up | Fix |
|---|---|---|
| An object created at module scope | Like the example above | beforeEach |
A const array or map mutated by the tests |
It fills up little by little | Recreate it in beforeEach |
localStorage / sessionStorage |
Data from the previous test | Clean up in afterEach (08-04) |
| Modules with internal state | An id counter that is never reset | Reset it explicitly, or inject it |
That last case has a direct example in the project: createIdGenerator from 03-04 keeps a counter in its closure. If the module instantiates it at import time, the first test will get id 7 and the second id 8; if somebody reorders the tests, both fail. The fix is the same decision as always: export the factory, not the instance, and let each test create its own.
Jest, moreover, runs each test file in an isolated environment with its own module registry. That protects you between files, but not within a single file. beforeEach discipline is still your job.
- Parameterized tests with
test.each
test.eachThe R6 transition table has nine possible combinations (three source statuses × three targets). Writing nine nearly identical tests is tedious and, above all, it means adding a new status requires writing three more tests by hand.
test.each takes a table and generates one test per row:
describe('Task · status transitions (R6)', () => {
// ── The allowed ones ────────────────────────────────────────────────
test.each([
['pending', 'in-progress'],
['in-progress', 'done'],
['in-progress', 'pending']
])('allows going from %s to %s', (from, to) => {
const task = new Task({ ...VALID_DATA, status: from });
task.changeStatus(to);
expect(task.status).toBe(to);
});
// ── The forbidden ones ──────────────────────────────────────────────
test.each([
['pending', 'pending'],
['pending', 'done'], // it has to be started before it can be finished
['in-progress', 'in-progress'],
['done', 'pending'], // a finished task is not reopened
['done', 'in-progress'],
['done', 'done']
])('forbids going from %s to %s', (from, to) => {
const task = new Task({ ...VALID_DATA, status: from });
expect(() => task.changeStatus(to)).toThrow(ValidationError);
});
});Output:
Task · status transitions (R6)
✓ allows going from pending to in-progress
✓ allows going from in-progress to done
✓ allows going from in-progress to pending
✓ forbids going from pending to pending
✓ forbids going from pending to done
✓ forbids going from in-progress to in-progress
✓ forbids going from done to pending
✓ forbids going from done to in-progress
✓ forbids going from done to doneNine independent tests, each with its own readable name, and the whole table covered with no gaps. Notice that the two tables together add up to exactly nine combinations: that is an exhaustiveness check you can see at a glance.
The name placeholders are %s (string), %d (number), %o (object) and %p (pretty-formatted). And there is a second syntax, with a tagged template, that reads even better when there are more than two columns:
describe('Task · hours validation (R3)', () => {
test.each`
hours | valid | reason
${0} | ${false} | ${'zero is not a valid value'}
${-3} | ${false} | ${'negative'}
${0.5} | ${true} | ${'half an hour is acceptable'}
${1} | ${true} | ${'lower bound'}
${40} | ${true} | ${'exact upper bound'}
${41} | ${false} | ${'over the weekly maximum'}
${NaN} | ${false} | ${'NaN is not comparable'}
${'12'} | ${false} | ${'a string is not a number'}
`('estimatedHours $hours → $valid ($reason)', ({ hours, valid }) => {
const create = () => new Task({ ...VALID_DATA, estimatedHours: hours });
if (valid) expect(create().estimatedHours).toBe(hours);
else expect(create).toThrow(ValidationError);
});
});This table is especially valuable because it captures the boundary values: 0, 1, 40, 41. Most validation bugs live exactly on those edges (a > where a >= belonged), and a table makes them explicit. The last two rows —NaN and the string '12'— also document two design decisions that are not obvious: the setter requires typeof value === 'number', so '12' is rejected even though it looks like a number, and NaN fails because no comparison with NaN is ever true (01-07).
- Testing asynchronous code
Everything from Module 5 applied to tests. The fundamental rule: if the test is asynchronous, Jest has to know, or it will finish before anything is checked.
// ❌ WRONG: the test finishes before the promise resolves. It always passes.
test('imports the tasks', () => {
loadBoard().then((board) => {
expect(board.total).toBe(6); // ← this runs when nobody is watching
});
});
// ✅ RIGHT: the function is async and it is awaited
test('imports the tasks', async () => {
const board = await loadBoard();
expect(board.total).toBe(6);
});
// ✅ ALSO RIGHT: return the promise
test('imports the tasks', () => {
return loadBoard().then((board) => {
expect(board.total).toBe(6);
});
});The first version is a false green, the worst possible outcome: the test is in the suite, it shows green and it checks nothing. The jest/valid-expect rule you configured in 08-02 catches some variants of this mistake; the discipline of always writing async/await avoids it entirely.
For promises, Jest also offers two modifiers that save on ceremony:
// resolves: applies the matcher to the RESOLVED value
await expect(loadBoard()).resolves.toMatchObject({ total: 6 });
// rejects: applies the matcher to the REJECTION reason
await expect(loadBoard('broken')).rejects.toThrow(DataError);
await expect(loadBoard('broken')).rejects.toBeInstanceOf(DataError);Do not forget the await in front of expect when using resolves or rejects: without it, the assertion returns a promise nobody waits for and you are back to the false green.
And the pattern for checking properties of the rejected error, which is often needed in this project:
test('importing corrupt data throws DataError with the cause inside', async () => {
expect.assertions(2);
try {
await repository.loadFrom('{{{ this is not JSON');
} catch (error) {
expect(error).toBeInstanceOf(DataError);
expect(error.cause).toBeInstanceOf(SyntaxError);
}
});A warning about timers: if your code uses setTimeout —like the sleep() inside withRetries in 07-03— a test that actually runs it will take seconds and slow the suite down. The answer is fake timers, and that is the subject of the next lesson, Test Doubles. Here we stay with the model tests, which are pure and wait for nothing.
- What makes a module testable
This is where the promise from 03-03 is kept. Compare these two functions:
// ── A · Pure function: data in, data out ──────────────────────────────
export function isOverdue(dueDate, status, today = TODAY) {
return dueDate < today && status !== 'done';
}
// ── B · Impure function: it depends on the world and modifies it ──────
export function markOverdue() {
const today = new Date().toISOString().slice(0, 10); // ① the real clock
const saved = JSON.parse(localStorage.getItem('nomada:board:v1')); // ② the store
for (const t of saved.tasks) {
if (t.dueDate < today && t.status !== 'done') {
document.querySelector(`[data-id="${t.id}"]`).classList.add('overdue'); // ③ the DOM
}
}
}Testing A:
test('a task with a past date and unfinished is overdue (R10)', () => {
expect(isOverdue('2026-09-05', 'pending', '2026-09-20')).toBe(true);
});One line. No setup, no cleanup, no browser, in under a millisecond, and with the same result today, tomorrow and in three years' time.
Testing B requires a DOM, a localStorage, and freezing the system clock, because the result changes with the day it runs. It is possible —you will do it in 08-04 and 08-05— but it costs twenty times as much and the resulting test is fragile.
The three properties of a pure function (03-03) explain the difference:
| Property | Consequence for the test |
|---|---|
| The result depends only on the arguments | No environment to set up |
| It modifies nothing outside itself | Nothing to clean up afterwards |
| The same input always gives the same output | It is never flaky |
The second tool is dependency injection, and in Nómada Tasks it is already applied in several places without our calling it that:
// js/util/dates.js — the date COMES IN as a parameter, with a default value
export function isOverdue(dueDate, status, today = TODAY) { … }
// js/model/task.js
isOverdue(today = TODAY) { return dateIsOverdue(this.dueDate, this.#status, today); }
// js/data/local-repository.js — the store COMES IN through the constructor
constructor({ key = KEY, store } = {}) {
this.#store = store ?? (this.#persistent ? window.localStorage : inMemoryStore());
}That today parameter with a default value is what lets you write:
test('the carpentry workshop quote is overdue on 20 September', () => {
const board = new Board('Taller Nómada', createBacklog());
expect(board.overdue('2026-09-20')).toHaveLength(1);
expect(board.overdue('2026-09-20')[0].title).toBe('Carpentry workshop quote');
});The test will give the same result whatever day it runs, because time comes in as data. Without that parameter, the test would pass today and fail on 1 October, when task 3 would also be overdue. That kind of failure —a suite that breaks by itself on a Tuesday morning without anybody having touched anything— is one of the biggest destroyers of confidence.
And that same LocalRepository constructor that accepts a store is what will let you test it without a browser in 08-05. The underlying lesson: a dependency that comes in through a parameter is a dependency you can substitute in a test; one that is taken from the global scope is not.
flowchart TD
A["Does the function depend<br/>only on its arguments?"] -->|Yes| B["Pure function<br/>trivial to test"]
A -->|No| C["Does the dependency<br/>come in as a parameter?"]
C -->|Yes| D["Injectable<br/>easy to test (08-04)"]
C -->|No| E["Coupled to the environment<br/>redesign it<br/>or move up to integration (08-05)"]
style B fill:#bbf7d0,stroke:#15803d
style D fill:#fde68a,stroke:#b45309
style E fill:#fecaca,stroke:#b91c1c
- Coverage: what it really measures
Coverage measures what percentage of your code runs while the tests pass.
-------------------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -------------------------|---------|----------|---------|---------|------------------- All files | 84.21 | 78.94 | 88.23 | 84.05 | js/model | 97.14 | 95.45 | 100 | 97.10 | errors.js | 100 | 100 | 100 | 100 | task.js | 96.42 | 94.11 | 100 | 96.36 | 88 board.js | 100 | 100 | 100 | 100 | js/util | 100 | 100 | 100 | 100 | dates.js | 100 | 100 | 100 | 100 | format.js | 100 | 100 | 100 | 100 | js/data | 42.85 | 28.57 | 50.00 | 42.30 | 34-58,71-88 backlog.js | 100 | 100 | 100 | 100 | local-repository.js | 31.25 | 20.00 | 33.33 | 30.76 | 34-58,71-88 -------------------------|---------|----------|---------|---------|-------------------
The four columns measure different things:
| Metric | What it counts | Why it matters |
|---|---|---|
| Statements | Statements executed | The coarsest measure |
| Branch | Branches of each condition (if/else, &&, ?:, ??) |
The most informative: it reveals untested paths |
| Functions | Functions called at least once | Detects dead or untested code |
| Lines | Lines executed | Almost the same as statements |
Branch is the one to look at. A condition like dueDate < today && status !== 'done' has four combinations; walking just one gives 100% of lines and 25% of branches. The HTML report (coverage/lcov-report/index.html) paints them yellow, and browsing it is the best way to find real gaps.
And now the important part: what coverage does NOT measure.
// This "test" gives 100% coverage of the function… and checks nothing
test('summary', () => {
const board = new Board('Taller Nómada', createBacklog());
board.summary('2026-09-20'); // it runs in full. Zero assertions.
});Coverage measures execution, not verification. A 100% built on assertion-free tests is an empty 100%. That is why you configured jest/expect-expect in the previous lesson.
Three statements worth being clear about:
- Low coverage is a reliable signal of a problem. If
local-repository.jsis at 31%, there are important paths untested (here, precisely, the handling of corrupt data and the in-memory fallback). - High coverage is a signal of nothing. It does not say the assertions are correct, nor that you tested the edge cases, nor that the logic is what the business wants.
- Chasing 100% is counterproductive. The last few percentage points are usually in defensive branches that almost never happen, and they get "covered" by artificial tests that add nothing and have to be maintained. The cost grows exponentially and the value falls.
A reasonable policy, in jest.config.js:
export default {
// …
coverageThreshold: {
global: { branches: 70, functions: 80, lines: 80 },
'./js/model/': { branches: 95, functions: 100, lines: 95 }, // the heart: demanding
'./js/view/': { branches: 50, functions: 60, lines: 60 } // the DOM: integration (08-05)
}
};Thresholds per folder, not one global figure: the model, which is pure and holds the business rules, deserves maximum rigor; the view is better covered by integration tests. And thresholds break the build if coverage drops, which is their real usefulness: they stop untested code creeping in bit by bit.
- Naming a test and the single-reason rule
A test's name gets read twice: when somebody is looking for what is covered, and —above all— when it fails in continuous integration. In that second moment, a good name saves you opening the file.
// ❌ They say nothing
test('board', …);
test('works', …);
test('test 3', …);
test('changeStatus', …);
// ✅ Subject + behavior + condition
test('changeStatus throws ValidationError if the task does not exist', …);
test('the canonical backlog summary gives 45 open hours out of 48', …);
test('add rejects a duplicate id (R1)', …);
test('tags are normalized to lowercase with no duplicates (R9)', …);The template that works: <what> <does or does not do> <when>. And a recommendation for this project: cite the business rule in parentheses. When an R6 test turns red, knowing it is R6 takes you straight to the right conversation with Marta.
The single-reason rule. A test should fail for one single reason. Compare:
// ❌ If it fails, what is broken? You have to read the report with a magnifying glass
test('the board works', () => {
const board = new Board('Taller Nómada', createBacklog());
expect(board.total).toBe(6);
board.changeStatus(2, 'in-progress');
expect(board.findById(2).status).toBe('in-progress');
expect(board.summary(TODAY).openHours).toBe(45);
expect(() => board.add(new Task(VALID_DATA))).toThrow();
});
// ✅ Four tests, four names, four diagnoses
test('the canonical backlog has 6 tasks', …);
test('changeStatus applies the transition to the given task', …);
test('the canonical backlog summary gives 45 open hours', …);
test('add rejects a duplicate id (R1)', …);The nuance: "one single reason to fail" does not mean "one single assertion". Checking three properties of the same result is a single reason:
test('the hours error identifies the field and the received value', () => {
const error = capture(() => new Task({ ...VALID_DATA, estimatedHours: 99 }));
expect(error).toBeInstanceOf(ValidationError); // three assertions…
expect(error.field).toBe('estimatedHours'); // …about the SAME error…
expect(error.receivedValue).toBe(99); // …= one single reason to fail
});
- TDD: red, green, refactor
Test-driven development (TDD) inverts the usual order: test first, code afterwards.
flowchart LR
R["🔴 RED<br/>Write a test<br/>that fails"] --> V["🟢 GREEN<br/>The MINIMUM code<br/>that makes it pass"]
V --> F["🔵 REFACTOR<br/>Improve the code<br/>with the test as a net"]
F --> R
Let us apply it to a new rule Marta is asking for: R11 — an overdue task cannot have 'low' priority; if it is overdue and still open, it is automatically escalated to 'high'.
Round 1 · Red. The test first, before touching task.js:
describe('Task · priority escalation (R11)', () => {
test('an overdue low-priority task is escalated to high', () => {
const task = new Task({ ...VALID_DATA, priority: 'low',
dueDate: '2026-09-05', status: 'pending' });
expect(task.effectivePriority('2026-09-20')).toBe('high');
});
});● Task · priority escalation (R11) › an overdue low-priority task is escalated to high TypeError: task.effectivePriority is not a function
Red, and red for the right reason: the method does not exist. This step is not a formality: verifying that the test fails proves the test really checks something. A test that passes before the code is written is a badly written test.
Round 1 · Green. The minimum code:
// js/model/task.js
effectivePriority(today = TODAY) {
return 'high'; // yes, that minimal. The next tests will fix it.
}Green. It looks like cheating, and it is deliberate: it forces you to write the next test, the one that distinguishes the cases.
Round 2 · Red. The opposite case:
test('a task that is not overdue keeps its priority', () => {
const task = new Task({ ...VALID_DATA, priority: 'low', dueDate: '2026-11-30' });
expect(task.effectivePriority('2026-09-20')).toBe('low');
});Round 2 · Green.
Round 3 · Red. The edge case that reveals a nuance in the wording:
test('an overdue medium-priority task keeps its priority', () => {
const task = new Task({ ...VALID_DATA, priority: 'medium', dueDate: '2026-09-05' });
expect(task.effectivePriority('2026-09-20')).toBe('medium');
});It fails: the implementation escalates everything overdue, and the rule said "low priority".
Round 3 · Green.
effectivePriority(today = TODAY) {
return this.isOverdue(today) && this.priority === 'low' ? 'high' : this.priority;
}Refactor. With the three tests green, you can improve things without fear. The ternary expression is starting to ask for a name:
// js/model/task.js
/** R11: overdue and low priority gets escalated; everything else keeps its own. */
get #isOverdueAndLowPriority() {
return this.isOverdue() && this.priority === 'low';
}
effectivePriority(today = TODAY) {
return this.isOverdue(today) && this.priority === 'low' ? 'high' : this.priority;
}The three tests are still green after the change: that is exactly what "safety net" means.
What TDD brings, honestly:
| For | Against |
|---|---|
| Guarantees that all the code has a test | It costs more at first, until you find the rhythm |
| Forces you to design the interface before the implementation | It does not fit well when you are exploring and do not know what you want |
| Stops you writing extra code ("just in case") | It requires sustained discipline |
| Documents every decision as it is taken | With requirements that change hourly, you redo a lot |
You do not have to do TDD all the time. But there is one moment when it is unarguably the best option: when fixing a bug. Write the test that reproduces it first —step 6 of the method from 08-01—, check that it is red, and fix it. That way you guarantee both that the fix works and that the bug will not come back.
- Nómada Tasks: the complete model test suite
Let us put it all together. First, a shared helper so we do not repeat the data in every file:
// test/helpers/test-backlog.js
import { Task } from '../../js/model/task.js';
import { Board } from '../../js/model/board.js';
import { createBacklog } from '../../js/data/backlog.js';
/** The project's canonical date: fixed, so the tests do not depend on the calendar. */
export const TODAY = '2026-09-20';
export const VALID_DATA = Object.freeze({
id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván',
priority: 'high', status: 'in-progress', tags: ['space', 'design'],
estimatedHours: 12, dueDate: '2026-09-30', reviewer: 'Marta'
});
/** A valid task with whatever you want changed. */
export const aTask = (changes = {}) => new Task({ ...VALID_DATA, ...changes });
/** A NEW board with the canonical backlog. Fresh instances on every call. */
export const aBoard = () => new Board('Taller Nómada', createBacklog());
/** Captures the exception thrown by fn, or returns null if it does not throw. */
export function capture(fn) {
try { fn(); return null; } catch (error) { return error; }
}Now the Task tests:
// test/model/task.test.js
import { Task } from '../../js/model/task.js';
import { ValidationError } from '../../js/model/errors.js';
import { TODAY, VALID_DATA, aTask, capture } from '../helpers/test-backlog.js';
describe('Task', () => {
// ── Construction and validation ───────────────────────────────────────
describe('construction', () => {
test('keeps the valid data it receives', () => {
const task = aTask();
expect(task).toMatchObject({
id: 1, title: 'Redesign the multipurpose room',
assignee: 'Iván', priority: 'high', estimatedHours: 12
});
});
test('trims the whitespace around the title', () => {
expect(aTask({ title: ' Signage ' }).title).toBe('Signage');
});
test.each([['', 'empty string'], [' ', 'whitespace only'], [null, 'null'], [42, 'a number']])(
'rejects the title %p (%s) with ValidationError (R2)',
(title) => {
expect(() => aTask({ title })).toThrow(ValidationError);
}
);
test('the title error identifies the field and the received value', () => {
const error = capture(() => aTask({ title: ' ' }));
expect(error).toBeInstanceOf(ValidationError);
expect(error.field).toBe('title');
expect(error.receivedValue).toBe(' ');
});
test('starts out pending when no status is given (R5)', () => {
const { status, ...withoutStatus } = VALID_DATA; // destructuring from 04-07
expect(new Task(withoutStatus).status).toBe('pending');
});
test('rejects an unknown status', () => {
expect(() => aTask({ status: 'archived' })).toThrow(ValidationError);
});
test('a task with no assignee stores it as null, never as an empty string (R8)', () => {
expect(aTask({ assignee: '' }).assignee).toBeNull();
expect(aTask({ assignee: undefined }).assignee).toBeNull();
});
test('normalizes tags to lowercase with no duplicates (R9)', () => {
const task = aTask({ tags: ['Space', ' DESIGN ', 'space', 'design'] });
expect(task.tags).toEqual(['space', 'design']);
});
});
// ── Hours (R3), with the boundary values ──────────────────────────────
describe('estimatedHours (R3)', () => {
test.each`
hours | valid | reason
${0} | ${false} | ${'zero'}
${-3} | ${false} | ${'negative'}
${1} | ${true} | ${'lower bound'}
${40} | ${true} | ${'exact upper bound'}
${41} | ${false} | ${'over the maximum'}
${NaN} | ${false} | ${'NaN'}
${'12'} | ${false} | ${'a string, not a number'}
`('$hours → valid: $valid ($reason)', ({ hours, valid }) => {
const create = () => aTask({ estimatedHours: hours });
if (valid) expect(create().estimatedHours).toBe(hours);
else expect(create).toThrow(ValidationError);
});
test('the setter validates just like the constructor', () => {
const task = aTask();
expect(() => { task.estimatedHours = 99; }).toThrow(ValidationError);
expect(task.estimatedHours).toBe(12); // and it has NOT been modified
});
});
// ── Transitions (R6) ──────────────────────────────────────────────────
describe('status transitions (R6)', () => {
test.each([
['pending', 'in-progress'], ['in-progress', 'done'], ['in-progress', 'pending']
])('allows %s → %s', (from, to) => {
const task = aTask({ status: from });
expect(task.changeStatus(to).status).toBe(to); // it returns this: chainable
});
test.each([
['pending', 'pending'], ['pending', 'done'], ['in-progress', 'in-progress'],
['done', 'pending'], ['done', 'in-progress'], ['done', 'done']
])('forbids %s → %s', (from, to) => {
expect(() => aTask({ status: from }).changeStatus(to)).toThrow(ValidationError);
});
test('an invalid transition does not leave the task half changed', () => {
const task = aTask({ status: 'done' });
capture(() => task.changeStatus('pending'));
expect(task.status).toBe('done'); // the status has not been corrupted
});
});
// ── Overdue status (R10) and derived values ───────────────────────────
describe('overdue status (R10)', () => {
test('overdue if the date has passed and it is not done', () => {
expect(aTask({ dueDate: '2026-09-05', status: 'pending' }).isOverdue(TODAY)).toBe(true);
});
test('not overdue if it is done, even if the date has passed', () => {
expect(aTask({ dueDate: '2026-09-05', status: 'done' }).isOverdue(TODAY)).toBe(false);
});
test('not overdue on the due date itself', () => {
expect(aTask({ dueDate: TODAY, status: 'pending' }).isOverdue(TODAY)).toBe(false);
});
test('isOpen is false only when it is done', () => {
expect(aTask({ status: 'pending' }).isOpen).toBe(true);
expect(aTask({ status: 'in-progress' }).isOpen).toBe(true);
expect(aTask({ status: 'done' }).isOpen).toBe(false);
});
test('the effort is hours × priority weight', () => {
expect(aTask({ priority: 'high', estimatedHours: 12 }).effort).toBe(36);
expect(aTask({ priority: 'medium', estimatedHours: 6 }).effort).toBe(12);
expect(aTask({ priority: 'low', estimatedHours: 3 }).effort).toBe(3);
});
});
// ── Serialization (picking up 04-08) ──────────────────────────────────
describe('serialization', () => {
test('toJSON includes the private state, which would otherwise be lost', () => {
const task = aTask({ status: 'in-progress' });
expect(task.toJSON()).toStrictEqual({
id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván',
priority: 'high', status: 'in-progress', tags: ['space', 'design'],
estimatedHours: 12, dueDate: '2026-09-30', reviewer: 'Marta'
});
});
test('the round trip preserves every field', () => {
const original = aTask({ status: 'in-progress' });
const copy = Task.fromJSON(JSON.stringify(original));
expect(copy.toJSON()).toStrictEqual(original.toJSON());
expect(copy).not.toBe(original); // it is ANOTHER instance
expect(copy).toBeInstanceOf(Task); // but of the same class
});
test('toJSON returns a copy of the tags, not the internal array', () => {
const task = aTask();
task.toJSON().tags.push('injected');
expect(task.tags).toEqual(['space', 'design']); // untouched
});
});
});And the Board ones, where the canonical numbers are finally checked:
// test/model/board.test.js
import { Task } from '../../js/model/task.js';
import { ValidationError } from '../../js/model/errors.js';
import { TODAY, aTask, aBoard } from '../helpers/test-backlog.js';
describe('Board', () => {
let board;
beforeEach(() => { board = aBoard(); }); // ← new instances: independent tests
describe('the canonical backlog', () => {
test('has 6 tasks and 5 open', () => {
expect(board.total).toBe(6);
expect(board.openTasks).toHaveLength(5);
});
test('adds up to 48 total hours and 45 open hours', () => {
expect(board.totalHours).toBe(48);
expect(board.openHours).toBe(45); // ← the test missing in case 3 of 08-01
});
test('has a weighted effort of 124', () => {
expect(board.effort).toBe(124);
});
test('has exactly one overdue task: the carpentry workshop quote (R10)', () => {
const overdue = board.overdue(TODAY);
expect(overdue).toHaveLength(1);
expect(overdue[0].title).toBe('Carpentry workshop quote');
});
test('splits the open hours: Iván 25, Lucía 14, Marta 6', () => {
expect(board.hoursByAssignee()).toEqual({ 'Iván': 25, 'Lucía': 14, 'Marta': 6 });
});
test('the complete summary is exactly what is expected', () => {
expect(board.summary(TODAY)).toStrictEqual({
total: 6, open: 5, totalHours: 48,
openHours: 45, overdue: 1, effort: 124
});
});
});
describe('add', () => {
test('adds a new task and updates the total', () => {
board.add(aTask({ id: 7, title: 'Check the fire extinguishers', estimatedHours: 2 }));
expect(board.total).toBe(7);
expect(board.findById(7).title).toBe('Check the fire extinguishers');
});
test('rejects a duplicate id (R1)', () => {
expect(() => board.add(aTask({ id: 3 }))).toThrow(ValidationError);
expect(board.total).toBe(6); // and it added nothing
});
test('rejects anything that is not a Task', () => {
expect(() => board.add({ id: 9, title: 'Plain object' })).toThrow(ValidationError);
});
});
describe('changeStatus', () => {
test('applies the transition to the given task', () => {
board.changeStatus(2, 'in-progress');
expect(board.findById(2).status).toBe('in-progress');
});
test('recalculates the open hours when a task is closed', () => {
board.changeStatus(1, 'done'); // task 1: in-progress, 12 h, Iván's
expect(board.openHours).toBe(33); // 45 − 12
expect(board.hoursByAssignee()).toEqual({ 'Iván': 13, 'Lucía': 14, 'Marta': 6 });
});
test('throws ValidationError if the task does not exist', () => {
expect(() => board.changeStatus(99, 'in-progress')).toThrow(ValidationError);
});
test('propagates the error of a forbidden transition (R6)', () => {
expect(() => board.changeStatus(4, 'in-progress')).toThrow(ValidationError); // task 4 is done
});
});
describe('queries', () => {
test('findById returns null when it does not exist, never undefined', () => {
expect(board.findById(99)).toBeNull();
});
test('filter returns a new array without touching the board', () => {
const high = board.filter((t) => t.priority === 'high');
expect(high).toHaveLength(3);
expect(board.total).toBe(6);
});
test('the tasks getter returns a defensive copy (05-03)', () => {
const copy = board.tasks;
copy.push(aTask({ id: 99 }));
copy.sort((a, b) => a.title.localeCompare(b.title));
expect(board.total).toBe(6); // the push had no effect
expect(board.tasks[0].id).toBe(1); // nor did the sort
});
test('it is iterable: for…of walks the six tasks (05-08)', () => {
const titles = [...board].map((t) => t.title);
expect(titles).toHaveLength(6);
expect(titles[0]).toBe('Redesign the multipurpose room');
});
});
});$ npm test
PASS test/model/task.test.js
PASS test/model/board.test.js
Test Suites: 2 passed, 2 total
Tests: 48 passed, 48 total
Time: 0.913 sForty-eight checks on the behavior of the whole model, in under a second, with no browser. And the debt from case 3 of 08-01 is formally settled: if somebody writes this.#tasks.reduce in openHours again, two tests turn red instantly and one of them says, literally, "adds up to 48 total hours and 45 open hours".
The last step: add the tests to the continuous integration workflow from 08-02.
# .github/workflows/quality.yml — one more step
- name: Run the tests
run: npm test -- --coverage --ci--ci disables automatic writing of new snapshots; on the server, a snapshot that does not exist should fail, not be created silently.
Common Mistakes and Tips
- Using
toBewith objects or arrays. It compares references:expect({a:1}).toBe({a:1})always fails. For contents,toEqualortoStrictEqual. - Forgetting the function in
toThrow.expect(new Task({}))throws beforeexpectdoes anything. Alwaysexpect(() => …).toThrow(…). - Asynchronous tests with no
async/awaitand noreturn. The test finishes before anything is checked and it always passes. It is a false green, the most dangerous result of all. - Forgetting the
awaitbeforeexpect(...).resolves. Same problem, same false green. - Sharing state between tests. An object created at module scope makes order matter and produces flaky failures. Always
beforeEach. - Leaving a
test.onlyin place. The suite goes green while running one single test. It is exactly whatjest/no-focused-testscatches, configured in 08-02. - Testing internal details instead of behavior. A test that checked
board.#tasks(if it could) would break with every refactor without anything actually being wrong. Test what the module promises, not how it delivers it. - Chasing 100% coverage. The last points cost a lot and are worth little. Demand a lot in the model, be reasonable in the view.
- Using the real date in tests. A test that calls
new Date()changes result on 1 October. PassTODAY = '2026-09-20'as an argument; for code that does not allow it, there are the fake timers of 08-04. - Tip: write the test that reproduces the bug first. It is step 6 of the method from 08-01 and the one case where TDD is unarguable.
- Tip: check that the test fails before fixing anything. A test you have never seen red may not be checking what you think.
- Tip: when a test is hard to write, suspect the code, not the test. The difficulty almost always points at a function that does too much.
Exercises
Exercise 1 — A test suite for js/util/dates.js.
Write test/util/dates.test.js with complete coverage of the module's three functions. For daysBetween, include positive days, negative days, zero and a month boundary. For isOverdue, use test.each with a table covering the four combinations of (past/future date) × (done/not done status), plus the same-day case. For readableDate, check at least January, September and December, and a day with no leading zero. Justify in a comment why no test calls new Date() with no arguments.
Exercise 2 — A new rule with TDD: R12, the weekly limit.
Marta asks you to finally implement R7 as a board method: canAssign(assignee, hours, today) must return false if assigning those hours would push that person over 40 open hours. Develop it with the red-green-refactor cycle, writing each test before the code, and document the four rounds. Cover: (a) Iván has 25 open hours, so accepting 10 more is allowed; (b) accepting 16 more is not; (c) the exact limit (25 + 15 = 40) is allowed; (d) done tasks do not count; (e) somebody with no tasks can take up to 40 hours.
Exercise 3 — Diagnosing a sick suite. This suite has five distinct problems. Identify them all, explain the consequence of each one and rewrite it correctly.
import { Board } from '../../js/model/board.js';
import { createBacklog } from '../../js/data/backlog.js';
const board = new Board('Taller Nómada', createBacklog());
describe('board', () => {
test('works', () => {
expect(board.total).toBe(6);
board.changeStatus(2, 'in-progress');
expect(board.findById(2).status).toBe('in-progress');
});
test('summary', () => {
const r = board.summary(new Date().toISOString().slice(0, 10));
expect(r).toBe({ total: 6, open: 5, totalHours: 48,
openHours: 45, overdue: 1, effort: 124 });
});
test('load', () => {
loadFromServer().then((b) => {
expect(b.total).toBe(6);
});
});
test.only('duplicate id', () => {
expect(() => board.add(board.tasks[0])).toThrow();
});
});Solutions
Solution 1
// test/util/dates.test.js
import { TODAY, daysBetween, isOverdue, readableDate } from '../../js/util/dates.js';
// No test calls new Date() with no arguments: if it did, the result would depend
// on the day it ran and the suite would break by itself on some random Tuesday
// without anybody having touched the code. Time ALWAYS comes in as data.
describe('TODAY', () => {
test('is the project canonical date in ISO format', () => {
expect(TODAY).toBe('2026-09-20');
expect(TODAY).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
describe('daysBetween', () => {
test.each`
from | to | expected | scenario
${'2026-09-20'} | ${'2026-09-30'} | ${10} | ${'ten days forward'}
${'2026-09-20'} | ${'2026-09-05'} | ${-15} | ${'fifteen days back'}
${'2026-09-20'} | ${'2026-09-20'} | ${0} | ${'the same day'}
${'2026-09-20'} | ${'2026-10-02'} | ${12} | ${'across a month boundary'}
${'2026-12-28'} | ${'2027-01-03'} | ${6} | ${'across a year boundary'}
${'2028-02-27'} | ${'2028-03-01'} | ${3} | ${'leap year'}
`('$scenario: $from → $to = $expected', ({ from, to, expected }) => {
expect(daysBetween(from, to)).toBe(expected);
});
});
describe('isOverdue (R10)', () => {
test.each`
date | status | expected | reason
${'2026-09-05'} | ${'pending'} | ${true} | ${'past and not started'}
${'2026-09-05'} | ${'in-progress'}| ${true} | ${'past and under way'}
${'2026-09-05'} | ${'done'} | ${false} | ${'past but finished'}
${'2026-10-30'} | ${'pending'} | ${false} | ${'future and not started'}
${'2026-10-30'} | ${'done'} | ${false} | ${'future and finished'}
${'2026-09-20'} | ${'pending'} | ${false} | ${'due today: there is still time'}
`('$date + $status → $expected ($reason)', ({ date, status, expected }) => {
expect(isOverdue(date, status, '2026-09-20')).toBe(expected);
});
test('uses TODAY by default when no reference date is passed', () => {
expect(isOverdue('2026-09-05', 'pending')).toBe(true);
expect(isOverdue('2026-12-01', 'pending')).toBe(false);
});
});
describe('readableDate', () => {
test.each([
['2026-01-01', '1 January 2026'],
['2026-09-05', '5 September 2026'],
['2026-09-20', '20 September 2026'],
['2026-12-31', '31 December 2026']
])('%s → %s', (iso, expected) => {
expect(readableDate(iso)).toBe(expected);
});
test('strips the leading zero from the day', () => {
expect(readableDate('2026-03-08')).toBe('8 March 2026');
expect(readableDate('2026-03-08')).not.toContain('08');
});
});Solution 2
test('Iván, with 25 open hours, can take 10 more (R7)', () => {
expect(aBoard().canAssign('Iván', 10)).toBe(true);
});
// ✗ TypeError: board.canAssign is not a functiontest('Iván, with 25 open hours, can NOT take 16 more (25 + 16 = 41 > 40)', () => {
expect(aBoard().canAssign('Iván', 16)).toBe(false);
});
// ✗ Expected: false Received: truetest('the exact 40 h limit is allowed (25 + 15)', () => {
expect(aBoard().canAssign('Iván', 15)).toBe(true);
});
test('somebody with no tasks can take up to 40 h', () => {
const board = aBoard();
expect(board.canAssign('Berta', 40)).toBe(true);
expect(board.canAssign('Berta', 41)).toBe(false);
});
// Both pass: the <= and the ?? 0 were already right. Green first time,
// but they were worth writing: they document the edges.
ROUND 4 · RED — done tasks do not counttest('done tasks do not consume weekly capacity', () => {
const board = aBoard();
board.changeStatus(1, 'done'); // Iván closes 12 h: 13 open left
expect(board.canAssign('Iván', 27)).toBe(true); // 13 + 27 = 40
expect(board.canAssign('Iván', 28)).toBe(false); // 13 + 28 = 41
});
// It passes, because hoursByAssignee already filters by `openTasks`. The test
// documents and protects that detail, which is easy to break in a refactor.// js/model/board.js
const MAX_WEEKLY_HOURS = 40; // R7, in a named constant
/**
* R7: nobody may go over 40 estimated OPEN hours.
* Done tasks do not consume capacity.
*
* @param {string} assignee
* @param {number} hours Hours you intend to assign
* @returns {boolean}
*/
canAssign(assignee, hours) {
const assigned = this.hoursByAssignee()[assignee] ?? 0;
return assigned + hours <= MAX_WEEKLY_HOURS;
}
/** Hours that can still be assigned to somebody without breaking R7. */
availableCapacity(assignee) {
return MAX_WEEKLY_HOURS - (this.hoursByAssignee()[assignee] ?? 0);
}Solution 3
| # | Problem | Consequence |
|---|---|---|
| 1 | Shared state: the board is created once outside the describe blocks |
The first test closes task 2 and contaminates the rest; order starts to matter and flaky failures appear |
| 2 | Useless names ('board', 'works', 'summary', 'load') and several reasons to fail per test |
When CI goes red, the name says nothing and you have to open the file |
| 3 | toBe with an object in the summary test |
It always fails: it compares references. It should be toStrictEqual |
| 4 | The real date with new Date() |
The test changes result depending on the day: on 1 October there will be 2 overdue tasks and it will fail on its own |
| 5 | An asynchronous test with no async/await and a forgotten test.only |
The loadFromServer one always passes without checking anything (a false green), and the .only leaves the rest of the suite unrun |
// Corrected suite
import { Board } from '../../js/model/board.js';
import { ValidationError } from '../../js/model/errors.js';
import { createBacklog } from '../../js/data/backlog.js';
import { loadFromServer } from '../../js/data/tasks-api.js';
const TODAY = '2026-09-20'; // ← a fixed date: time comes in as data
describe('Board', () => {
let board;
beforeEach(() => { // ← fresh state for every test
board = new Board('Taller Nómada', createBacklog());
});
test('the canonical backlog has 6 tasks', () => {
expect(board.total).toBe(6);
});
test('changeStatus applies the transition to the given task', () => {
board.changeStatus(2, 'in-progress');
expect(board.findById(2).status).toBe('in-progress');
});
test('the canonical backlog summary is exactly what is expected', () => {
expect(board.summary(TODAY)).toStrictEqual({ // ← toStrictEqual, not toBe
total: 6, open: 5, totalHours: 48,
openHours: 45, overdue: 1, effort: 124
});
});
test('add rejects a duplicate id (R1)', () => { // ← no .only
expect(() => board.add(board.tasks[0])).toThrow(ValidationError);
expect(board.total).toBe(6);
});
test('loadFromServer returns the 6 backlog tasks', async () => {
const loaded = await loadFromServer(); // ← async + await
expect(loaded.total).toBe(6);
});
// Note: this last test still depends on the real network, which makes it slow
// and non-deterministic. In 08-04 fetch is replaced by a double and it starts
// running in milliseconds, without leaving the machine.
});Conclusion
Nómada Tasks finally has a safety net. You know what an automated test is —a program that runs another program and checks the result— and what the team really gains from them: being able to refactor without fear, executable documentation that cannot lie because it fails when it stops being true, and constant pressure towards a better design, because what is hard to test is usually badly designed. You know the testing pyramid with its three levels, why it has that shape —confidence and cost rise together— and the ice cream cone antipattern.
You have Jest running over native ES modules, with the Node flag in the script and a jest.config.js with no transformations, so that what you test is exactly what you deploy; and you know the Babel alternative and its trade-offs. You have the anatomy of a test down: describe to group, test to check, and the AAA pattern —Arrange, Act once, Assert. You know how to choose a matcher: toBe for primitives and for deliberate identity, toEqual for contents, toStrictEqual when class type and undefined values matter —as in the output of toJSON(), because of the 04-08 trap—, toMatchObject so as not to tie yourself to fields that are beside the point, and toThrow always with a function inside, preferably checking the type rather than the message text, with expect.assertions protecting your try/catch blocks so a failure cannot disguise itself as green.
You know how to use hooks and why beforeEach is the most used; you understand that shared state is the number one cause of flaky failures, and that the 05-04 decision to export createBacklog() as a function rather than an already-built array is what makes your 48 tests independent today. You cover the complete R6 transition table with test.each in both syntaxes, and the R3 boundary values —0, 1, 40, 41, NaN, '12'— in a table that reads like a specification. You know how to test asynchronous code with async/await, resolves and rejects, and how to recognize the false green of an asynchronous test nobody waits for.
And above all, the promise from 03-03 has been kept: you understand what makes a module testable. A pure function like isOverdue(date, status, today) is tested in one line because time comes in as data; markOverdue(), which reads the clock, the store and the DOM, needs half of Module 8 to be tested. The dependency injection you have been applying without naming it —the today = TODAY in isOverdue, the store in the LocalRepository constructor— is exactly what makes a dependency substitutable in a test. You know how to read coverage, to look at the branch column before any other, to set thresholds per folder (demanding in the model, reasonable in the view) and not to confuse running with verifying. You name tests with subject, behavior and condition, you cite the business rule in parentheses, and you respect the single reason to fail rule. And you have done real TDD in three red-green-refactor rounds over R11, checking that the red arrives for the right reason before writing a line.
The result is 48 tests in under a second that armor-plate Task and Board completely: validations R2, R3, R5, R8 and R9; the nine combinations of R6; the R10 overdue rule with its same-day case; the toJSON/fromJSON round trip; the defensive copies of 05-03; the iterability of 05-08; and the canonical backlog numbers —6 tasks, 48 total hours, 45 open hours, effort 124, one overdue, Iván 25 / Lucía 14 / Marta 6. Case 3 from 08-01 cannot come back.
But notice what was left out. js/data/local-repository.js shows up in the coverage report at 31%, and that is not laziness: testing it needs a localStorage, which does not exist in Node. js/data/tasks-api.js has not a single test, because testing it properly would mean calling a server —slow, unreliable, and with a .example TLD that never resolves. The debounce in js/util/time.js would wait a real 300 ms per test, and the exponential-backoff retries in js/data/http.js would take almost two seconds each. And isOverdue() is only checkable because we had the foresight to pass it today as a parameter; the day somebody calls new Date() inside it, the suite will start failing by itself on Tuesdays. Network, clock, storage and randomness: four dependencies that make tests slow and non-deterministic, which is the exact definition of a suite nobody runs. The answer is to replace them with controlled pieces, and that is Test Doubles: Mocks, Stubs and Spies, where you will test the four remaining layers with no network, no waiting and the calendar frozen on 20 September 2026.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
