PLAINFINCH learn by building

Project: a test suite you trust

You will turn a list of planned tests into a full suite for a bird sightings module and remove the stray line that skips most of it.

What makes a test suite trustworthy?

A trustworthy suite fails when the code is wrong and passes when it is right, and you believe both answers. Each part of that has a job. A suite that always passes is decoration. A suite that fails at random, or for reasons nobody understands, gets ignored, and then it is decoration too. The goal of this project is a suite for the sightings module where green means you would ship it and red means something real is broken.

Everything from the course comes together here. Units with exact expected values. Edge cases chosen on purpose. Errors checked with their messages. An async call with a fake API. A page test that queries the page the way a person reads it. And the final test of all of them: Check my work runs your suite against six broken copies of sightings.js, and your suite has to fail on every one.

Where do you start with a module this size?

Start with a plan, which is what the test.todo lines are. Each one names a behaviour the module promises. Writing the list first keeps you from spending all your time on the first function and forgetting the page. It also shows up in the Results view as a set of todo items, so the remaining work is visible.

Then work through the plan from the bottom of the stack up. parseSighting() and totalBySpecies() are pure functions: data in, data out, no network, no page. Test them first, because they are quick to write and their failures are easiest to read. loadSightings() depends on parseSighting(), so once parsing is solid, a loading failure points at the loading code. The page comes last.

describe('loadSightings', () => {
  test.todo('asks the club API and parses every line');
  test.todo('rejects with a clear message when the API fails');
});

Why does one .only switch off the rest?

test.only tells the runner to run that test and skip every other test in the file. It is handy while you are working on one test, and dangerous when it is committed. The starting file has one, and the Results view shows a single pass with everything else skipped. A suite in that state can report green while most of it never ran.

Real runners protect you in some situations. Vitest refuses to run a suite containing .only when it detects a CI environment, unless you pass --allowOnly, so a stray .only fails the build there. On your own machine, nothing stops it. Linters can flag it too: the Vitest and Jest ESLint plugins have rules that report focused and skipped tests. Check my work here does the same and fails while any .only or .skip remains.

Which edge cases does the parser need?

Start from the rules in the code and the shape of the input. A line has three parts separated by commas, so test a line with two parts. The count must be a whole number of 1 or more, so test 0, a negative number, a fraction and a word. Then test the edge that should pass: exactly 1. A table with test.each keeps the four refusal cases short and gives each one its own name in Results.

Ask what volunteers will really type, too. Spaces after commas are handled by trim(), and the first test already covers them. Lower-case species names are allowed by the parser and matter later for isRare(). Each case is cheap to add, and each one closes a door that real data would otherwise walk through.

test.each(['0', '-3', '1.5', 'two'])('refuses a count of %s', (count) => {
  expect(() => parseSighting('Heron, ' + count + ', 2026-03-10')).toThrow('whole number');
});

How do you test the loading code without the club API?

loadSightings() takes the fetch function as an argument, so you can hand it a fake. vi.fn().mockResolvedValue(...) returns a promise of an object shaped like a response: an ok flag and a json() method. That is all the code uses, so it is all the fake needs. No network call happens, the test is fast, and you choose exactly what the API says.

Write two tests. In the happy path, the fake returns two lines; check that the result is the two parsed sightings, and check with toHaveBeenCalledWith that the code asked for /api/sightings. In the failure path, the fake returns ok: false; check that the promise rejects with the friendly message. Both tests must await their expect, or they finish before the promise settles and pass whatever happens. The Break It challenge has that exact bug.

const fetchFn = vi.fn().mockResolvedValue({ ok: false, json: async () => [] });
await expect(loadSightings(fetchFn)).rejects.toHaveProperty('message', 'Sightings are not available right now');

What should the page test check?

Check what a club member would see. After renderSightings() with two sightings, there is a list named Sightings with two items, and one of them reads “2 Heron, 2026-03-09”. With an empty log, the page says “No sightings yet” and shows no list. Use getByRole('list', { name: 'Sightings' }) and within(list) to count items, and getByText for the exact wording.

Avoid checking internals such as innerHTML or class names. Those change when someone tidies the markup, and a test that breaks on a harmless change trains people to ignore failures. Role and text queries break only when the page really says something different. In this practice runner, the body is reset to the lesson's HTML before each test. On your own projects, Vitest needs the jsdom or happy-dom environment for page tests, and Testing Library's framework packages clean up between tests when Vitest's globals option is on; otherwise call cleanup() in an afterEach.

How do you know the suite is good enough?

Break the code on purpose and see what happens. That is what Check my work does with six copies of the module: one accepts a count of 0, one forgets earlier sightings when adding totals, one makes isRare() case sensitive, one ignores a failed response, one never shows the empty message and one renders an empty list. Each copy is a realistic mistake. A trustworthy suite turns red for every one.

Do the same on your own projects without any tools. Pick the most important rule in a file, change it slightly, run the tests, and put it back. If nothing failed, you have found the next test to write. Coverage helps you find code nobody ran. Breaking the code tells you whether anybody checked it.

  • Every test calls the real code and checks an exact answer
  • Each rule has a test on its boundary
  • Every error path is tested with its message
  • Every async assertion is awaited
  • Mocks stand in only for the network, and their calls are checked
  • No .only or .skip left in the file
  • Breaking any important rule turns at least one test red
FigureSix broken copies of sightings.js, such as accepting a count of 0 or a case sensitive isRare, each matched to the test that turns red, like test.each refusing count 0 or showing No sightings yet.Six broken copies of sightings.js, such as accepting a count of 0 or a case sensitive isRare, each matched to the test that turns red, like test.each refusing count 0 or showing No sightings yet.
A suite you trust turns red for every realistic mistake. Each broken copy that Check my work tries is caught by one named test.

How should the suite grow from here?

Every bug report becomes a regression test before it is fixed, so the suite grows where the bugs were. Every new rule gets its tests first. When a test fails at random, fix it straight away, usually by faking time or awaiting something; a flaky test does more damage than a missing one because it teaches people to rerun until green.

Keep the file readable as it grows. Shared test data like heron and osprey near the top, one describe per function, and test names that read as the module's promises. A year from now, someone new to the club's site should be able to read sightings.test.js and know what the module does. That is the last sign of a suite you can trust: people read it as the specification, because it is one.

Field notes

  • A suite you can trust fails when the code is wrong and passes when it is right.
  • One test.only makes the runner skip every other test in the file.
  • Test the pure functions first, then the loading code, then the page.
  • Hand loadSightings() a fake fetchFn and await every async check.

Easy to get wrong

.only skips every other test in the file. Committed by accident, it makes a whole suite report green while most of it never runs.

Without await, the test finishes before the promise settles, so it passes even when loadSightings() resolves happily.

Markup checks break on harmless changes and say little about what a person sees. Role queries check the page the way people and screen readers use it.

An expected value copied from the code's output turns a bug into a promise. Work out each answer from the data in the test.

Questions people ask

How many tests does a module like this need?

Enough that every rule and edge has a test and every broken copy fails at least one. For this module that is around fifteen, counting each row of the test.each table. Counting tests is less useful than breaking the code and seeing whether they notice.

Should the page test and the unit tests live in one file?

Here they do, to keep the project in one place. On larger projects, many teams keep pure unit tests in one file and page tests in another, because page tests need a DOM environment and run a little slower.

Why pass fetchFn as an argument instead of mocking fetch?

It makes the dependency visible and the fake trivial. Stubbing the global fetch with vi.stubGlobal works too, and the testing-fetch lesson shows it. Passing the function in is often simpler for code you control.

Does the practice runner behave like Vitest here?

Closely, for everything this project uses. Two differences: it resets fake timers and the page after every test on its own, which Vitest does only when configured, and its coverage counts statements by line with no branch figures. Both .rejects.toHaveProperty('message', ...) and the more usual await expect(promise).rejects.toThrow('not available') work in the runner and in Vitest.

Keep going