PLAINFINCH learn by building

Project: test the booking rules

You will write a full set of tests for a repair booking rule list, covering dates, a closed Sunday, the price and a full day.

What does bookRepair have to get right?

Before writing a test, read the code and the task and write the rules down as sentences. For bookRepair() there are six: refuse a service the shop doesn't offer, refuse today or any earlier day, refuse Sundays, ask the calendar about the requested day, refuse a day the calendar says is full, and charge 10 more for an e-bike. Each sentence becomes a test name.

That list is your plan and your coverage map. If you finish and one rule has no test, you know before Check my work tells you. It also stops the common habit of writing five tests for the happy path and none for refusals, which is where booking code usually goes wrong: a double booking on a full Saturday or a customer turning up to a locked workshop on a Sunday.

How do you pin today's date?

The rule "a day after today" depends on the clock, so a test written in March would start failing in April if nothing held the date still. vi.useFakeTimers() followed by vi.setSystemTime(new Date('2026-03-14T09:00:00Z')) in beforeEach makes every test run on Saturday 14 March 2026, and vi.useRealTimers() in afterEach puts the real clock back.

Putting it in beforeEach matters. Every test then starts from the same moment, whatever order they run in, and nobody reading a single test has to wonder what day it is. The comment above it saves a reader working out what day of the week the 14th was.

The practice runner starts every run with the clock at that same moment and resets fake timers after each test, so the tests would pass here without the setup. Keep it anyway: on your own machine Vitest uses the real clock, and these tests would rot within a day.

// Today is Saturday 14 March 2026 in every test
beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-03-14T09:00:00Z'));
});

Which dates make good test data?

Pick dates that sit on the edges of the rules. Today, the 14th, is the boundary for "after today", so it is the date most likely to expose a < written where <= was meant. Sunday the 15th tests the closed day, and it is also tomorrow, so a booking for the 15th is refused only by the Sunday rule. Monday the 16th is the first date that should work.

Tuesday the 17th makes a dull, safe date for tests about other rules, such as the price. Using a safe date there keeps each test about one thing. If the e-bike test used a Sunday, it would fail for a reason that has nothing to do with e-bikes, and the failure message would send you looking in the wrong place.

  • 2026-03-14: today, must be refused
  • 2026-03-15: Sunday, must be refused
  • 2026-03-16: Monday, the first day that works
  • 2026-03-17: Tuesday, a plain date for other rules
FigureWith the clock fixed at Saturday 14 March 2026, bookRepair refuses 13 March as past, 14 March as today, 15 March as a Sunday, and books Monday 16 March and Tuesday 17 March.With the clock fixed at Saturday 14 March 2026, bookRepair refuses 13 March as past, 14 March as today, 15 March as a Sunday, and books Monday 16 March and Tuesday 17 March.
Pinning today to Saturday 14 March puts three refusal rules side by side. The 14th tests the after today boundary, the 15th only the Sunday rule, and the 16th is the first day that books.

Why is isDayFree passed in as an argument?

The real availability check asks the shop's booking system over the network. Passing it into bookRepair() as isDayFree means the rules don't care where the answer comes from. In the app, it is a function that calls the server. In a test, it is vi.fn().mockResolvedValue(true) for a day with space or mockResolvedValue(false) for a full one.

This is called dependency injection, and it often makes code easier to test than stubbing a global. You don't need vi.stubGlobal() or any cleanup, and each test reads clearly: here is the calendar's answer, here is what should happen. A small freeDay() helper that returns a fresh mock keeps the happy-path tests short without sharing one mock between them.

How do you test the questions the code asks?

A mock records every call, and that record is worth checking. expect(isDayFree).toHaveBeenCalledWith('2026-03-16') proves the code asked about the day the customer chose. Without it, a bug that asks about today, or about a date in the wrong format, would pass, because the mock happily answers true to anything.

The reverse question is useful too. A request for a respray, which the shop doesn't offer, should be refused before the calendar is bothered. expect(isDayFree).not.toHaveBeenCalled() checks that order. In the real app that saves a network request, and it means a customer asking for something impossible never sees a misleading "fully booked" message.

test('asks about availability for the requested day', async () => {
  const isDayFree = freeDay();
  await bookRepair({ service: 'brakes', date: '2026-03-16' }, isDayFree);
  expect(isDayFree).toHaveBeenCalledWith('2026-03-16');
});

Why does a missing await book full days?

Look at the broken copy Check my work uses: const free = isDayFree(date); without await. The variable then holds a promise, and every promise is truthy, so if (!free) never runs and a full Saturday gets booked anyway. It is one of the most common async bugs, and AI tools produce it when they turn a synchronous helper into an async one and miss a call site.

Only one kind of test catches it: a test where the calendar says no. A suite that always mocks true would pass against the broken copy, because a promise and true look the same to an if. This is why the refusal tests matter as much as the booking tests.

How should the refusal tests be written?

Every refusal is a rejected promise, so every refusal test uses await expect(...).rejects followed by a check on the error. In Vitest that is usually .rejects.toThrow('fully booked'). That works in the practice runner too. These tests use .rejects.toHaveProperty('message', 'That day is fully booked'), which checks the whole message and works the same in Vitest and Jest. Either way, name the message. A bare .rejects.toBeInstanceOf(Error) passes for any error, including a TypeError from a typo, which would hide a real bug behind a passing test.

Grouping them in their own describe('bookRepair refuses', ...) block makes the Results view read like a list of rules: bookRepair refuses a booking for today, a Sunday, a day that is fully booked. When one fails, its full name tells you which rule broke before you read any code.

Keep the await in front of expect every time. The Break It challenge has a refusal test without it, and that test passes against code that is plainly wrong.

How do you know the suite is good enough?

Coverage helps a little. Open the Coverage view after writing the tests: every line of booking.js should be shaded, because each throw runs in at least one test. A line marked "never ran" is a rule with no test. Full coverage still doesn't prove much, though. The happy-path test alone runs most lines without checking any of the refusals.

The stronger check is the one Check my work runs. It makes five broken copies of bookRepair(), one per rule, and runs your tests against each. A copy that survives tells you which rule your tests don't really check. Read its message, add the missing test, and run again. When every copy fails and your own copy passes, the suite earns its keep.

What would you add in a real project?

A few more rules usually appear once the shop starts using the system. A booking more than eight weeks ahead might be refused. A date like 2026-02-30 should be rejected as invalid instead of rolling over into March without any error. The calendar check might fail with a network error, and the customer needs a different message for that than for a full day.

Each of those is one more sentence on the list and one more test. Write the test first, watch it fail, then change the code. When an AI tool suggests the change, the suite you built here is how you check it hasn't broken the six rules that already work.

Field notes

  • Write each rule as a sentence first, and give every sentence its own test.
  • Freeze the clock with fake timers so date rules give the same answer every day.
  • Pick dates on the edges of the rules, such as today and the first open day.
  • Check refusals with await and .rejects, naming the exact error message.

Easy to get wrong

If the mock always says yes, the full-day rule never runs. Bugs such as a missing await only show up when the calendar says no.

Checking only that some error happened accepts any error at all, including one caused by a typo. Name the message so the test proves the right rule refused the booking.

Working out dates inside the test copies the logic under test and hides the boundary. Fix the clock and write the dates out so a reader can check them.

Questions people ask

Why test today and not yesterday?

Today is the boundary. A bug that writes < instead of <= allows today and still refuses yesterday, so a test for yesterday can't catch it. Test the edge, and add yesterday too if you like.

Should each test get a fresh mock?

Yes. Create the mock inside the test or with a helper like freeDay(). A mock shared between tests keeps its call record, so toHaveBeenCalledWith() in one test can be fooled by a call from another.

Can I check the error by its class?

Yes, .rejects.toBeInstanceOf(Error) works, but every refusal here is a plain Error, so the class can't tell them apart. The message is what distinguishes a Sunday from a full day.

Do I need fake timers if I only call setSystemTime?

In Vitest, vi.setSystemTime() also works without fake timers and changes only the date. Using both is a common habit and harmless here. Always restore real timers afterwards so later tests aren't affected.

Keep going