The environment is already alive: Vitest finds the files, jsdom provides a DOM, and the jest-dom matchers are registered. It's time to write real tests, and this lesson does it deliberately outside React. There isn't a single render anywhere in this text. The reason is twofold: first, because the runner's tools — describe, the hooks, the assertions, the test doubles, the fake timers — are much easier to understand without the noise of a component tree; and second, because the most cost-effective part of CicloUrbano is precisely the part that doesn't need React. validateBooking encodes every business rule around bookings. The bookingsSlice reducer encodes the activa → confirmada / cancelada lifecycle. The selectors derive the visible catalogue. All three are pure functions, and testing a pure function means calling it and comparing. That's what was promised back in 05-05 and 07-04, and this lesson cashes it in.
The title says "with Jest," and there's a reason to honor it: Jest is the ecosystem's mental model. Everything you learn here gets written with Vitest, which is what fits a Vite project, but its API is the same, so it applies exactly as well to the thousands of projects that use Jest. We'll start by making that equivalence clear.
Contents
- What a test runner is and what it does for you
- Jest and Vitest: the same model, two implementations
- Configuring Jest in a project that doesn't use Vite
- Anatomy of a test file:
describe,test, and the hooks - Assertions: the matcher catalogue
toBevs.toEqual: the difference that causes the most bugs- The
validateBookingsuite, rule by rule - Edge cases and
test.each - Testing the
bookingsSlicereducer as a pure function - Testing the selectors
- Test doubles: spies, stubs, and mocks
vi.fn(): checking that a callback was calledvi.spyOnandvi.mock: substituting without breaking things- Controlling time:
vi.useFakeTimers - Running, filtering, and reading coverage
- What a test runner is and what it does for you
A test runner is a program that automates everything around a test. Without one, you'd have to hand-write the loop that calls each function, the try/catch that catches errors, and the console.log that summarizes the result. With one, you write only the part that adds value.
What a modern runner does, in execution order:
| Responsibility | What it means in practice |
|---|---|
| Discover | Finds files matching a pattern (*.test.js, *.spec.jsx) without you registering them |
| Transform | Converts JSX, ES modules, and modern syntax into something the environment can run |
| Isolate | Each file runs in its own module context, so one can't contaminate another |
| Run in parallel | Spreads files across several processes and uses every core |
| Provide the API | describe, test, expect, the hooks, test doubles, fake timers |
| Report | Shows what passed and what failed, with the exact diff between expected and received |
| Watch | In watch mode, detects which files changed and reruns only what's affected |
| Measure | Instruments the code to calculate coverage |
That "report" column is the one you notice most while debugging. When an assertion on objects fails, a good runner doesn't say "they're not equal": it says which field differs.
- Jest and Vitest: the same model, two implementations
Jest was born at Facebook and became the de facto standard for testing in JavaScript: for years, "testing in React" and "testing with Jest" were the same sentence. Create React App shipped it preconfigured, and most of the documentation, Stack Overflow answers, and legacy code you'll run into is written against its API.
Vitest came later, for projects built with Vite, and made a very deliberate design decision: replicate Jest's API. It's not a similar dialect; it's the same API, with the same behavior, so migrating costs almost nothing and everything the ecosystem already knows keeps working.
Why CicloUrbano uses Vitest and not Jest:
- It shares Vite's configuration. The aliases, plugins, environment variables, and module resolution are already defined in
vite.config.js. With Jest, all of it would need to be duplicated and kept in sync. - It understands ES modules natively. The whole project uses
import/export. Jest relies on CommonJS and needs a transform or experimental flags for ES modules. - It transforms with esbuild. Startup in tens of milliseconds instead of seconds, and near-instant watch mode.
- There's no second build pipeline. What gets tested is transformed the same way as what runs in development. That eliminates a whole family of "works in the app but not in the tests" bugs.
Equivalence table
| Concept | Jest | Vitest | Differs? |
|---|---|---|---|
| Grouping | describe |
describe |
No |
| Declaring a test | test / it |
test / it |
No |
| Assertion | expect(x).toBe(y) |
expect(x).toBe(y) |
No |
| Matchers | toEqual, toContain, toThrow… |
The same | No |
| Hooks | beforeEach, afterAll… |
The same | No |
| Utility object | jest |
vi |
Yes: the name |
| Mock function | jest.fn() |
vi.fn() |
Just the prefix |
| Spying on a method | jest.spyOn(obj, 'm') |
vi.spyOn(obj, 'm') |
Just the prefix |
| Mocking a module | jest.mock('./m') |
vi.mock('./m') |
The prefix, and that Vitest doesn't auto-hoist variables: it uses factories |
| Fake timers | jest.useFakeTimers() |
vi.useFakeTimers() |
Just the prefix |
| Advancing the clock | jest.advanceTimersByTime(400) |
vi.advanceTimersByTime(400) |
Just the prefix |
| Restoring | jest.restoreAllMocks() |
vi.restoreAllMocks() |
Just the prefix |
| Configuration | jest.config.js |
The test block in vite.config.js |
Yes |
| Transform | Babel or ts-jest |
esbuild, via Vite | Yes |
| ES modules | Partial support, needs tweaks | Native | Yes |
| DOM environment | testEnvironment: 'jsdom' |
environment: 'jsdom' |
Just the key name |
| Startup speed | Seconds | Tens of milliseconds | Yes |
| Globals without importing | Requires globals: true |
Requires globals: true |
Yes |
The real differences boil down to three things: the vi prefix instead of jest, where the configuration lives, and how the code gets transformed. 95% of the content of a test file is identical. In fact, with globals: true in the configuration, a test file written for Jest will usually run in Vitest without touching a single line.
In this project, vi is always used. When you come across code with jest.fn(), you'll know it's exactly the same thing.
- Configuring Jest in a project that doesn't use Vite
This is the recipe for when you end up on a project with Webpack, legacy Create React App, or no modern bundler. It isn't used in CicloUrbano, but it's worth being able to read.
npm install -D jest jest-environment-jsdom @babel/preset-env @babel/preset-react babel-jest \
@testing-library/react @testing-library/jest-dom @testing-library/user-event \
identity-obj-proxy// jest.config.js
export default {
// 1) The in-memory DOM: in Jest 28+ it's a separate package
testEnvironment: 'jest-environment-jsdom',
// 2) Equivalent to Vitest's setupFiles
setupFilesAfterEnv: ['<rootDir>/src/tests/setup.js'],
// 3) Jest does NOT understand CSS or images: they must be substituted
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
'\\.(jpg|png|svg|webp)$': '<rootDir>/src/tests/fileMock.js',
'^@/(.*)$': '<rootDir>/src/$1' // the alias that was already resolved in Vite
},
// 4) Which files count as tests
testMatch: ['**/*.test.{js,jsx}'],
// 5) Coverage
collectCoverageFrom: ['src/**/*.{js,jsx}', '!src/tests/**', '!src/main.jsx']
};// babel.config.js — Jest needs to transform JSX and ES modules
export default {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }]
]
};Notice the extra work that shows up here and that simply doesn't exist in Vitest: a moduleNameMapper for CSS and images, a Babel configuration, a separate package for the DOM environment, and duplicated aliases. That's exactly what you save by sharing Vite's configuration, and it's the practical argument from the previous section.
- Anatomy of a test file:
describe, test, and the hooks
describe, test, and the hooks// src/utils/example.test.js
import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
describe('outer group', () => {
beforeAll(() => console.log('1 · outer beforeAll'));
beforeEach(() => console.log('3 · outer beforeEach'));
afterEach(() => console.log('5 · outer afterEach'));
afterAll(() => console.log('7 · outer afterAll'));
describe('inner group', () => {
beforeAll(() => console.log('2 · inner beforeAll'));
beforeEach(() => console.log('4 · inner beforeEach'));
afterEach(() => console.log('6 · inner afterEach'));
test('the test', () => {
expect(true).toBe(true);
});
});
});With globals: true in the configuration, that first import line is optional. This course will omit it to avoid repeating it in every example.
| Element | When it runs | What it's for |
|---|---|---|
describe(name, fn) |
When the file loads (groups, doesn't isolate) | Organize by subject and share hooks |
test(name, fn) / it(...) |
Once per test | The test itself. test and it are exact aliases |
beforeAll(fn) |
Once, before the block's first test | Set up something expensive and shareable: start a server |
beforeEach(fn) |
Before every test in the block | Rebuild a clean scenario for each test |
afterEach(fn) |
After every test | Clean up: restore spies, clear localStorage |
afterAll(fn) |
Once, after the block's last test | Close what was opened in beforeAll |
The execution order is the one marked by the numbers in the example, and it has its own logic: the befores go from outside in, and the afters go from inside out, like a stack. That is: outer beforeAll → inner beforeAll → outer beforeEach → inner beforeEach → the test → inner afterEach → outer afterEach → inner afterAll → outer afterAll.
Practical consequences:
- Prefer
beforeEachtobeforeAll.beforeAllcreates shared state, which is the number-one cause of tests that only fail when the full suite runs (the 8.3 problem from the previous lesson). Only use it for something that's expensive and read-only. - Code inside a
describebut outside a hook runs when the file loads, not before each test. This is a classic bug:
// ❌ The object is created ONCE and the tests pass it around, mutated
describe('bookingsSlice', () => {
const state = { entities: {}, ids: [] }; // shared!
test('a', () => { state.ids.push('res-01'); /* … */ });
test('b', () => { /* by now state.ids already has 'res-01' */ });
});
// ✅ Fresh scenario per test
describe('bookingsSlice', () => {
let state;
beforeEach(() => { state = { entities: {}, ids: [] }; });
// …
});- Hooks can be asynchronous. If they return a promise, the runner waits for it. This gets put to use in 09-04 to start MSW's server.
- Assertions: the matcher catalogue
An assertion is expect(actualValue).matcher(expectedValue). If the check fails, an error is thrown with the diff, and the test is marked red.
| Matcher | Checks | Example in CicloUrbano |
|---|---|---|
toBe(x) |
Equality by identity (Object.is) |
expect(booking.status).toBe('activa') |
toEqual(x) |
Recursive structural equality; ignores undefined |
expect(errors).toEqual({ hours: '…' }) |
toStrictEqual(x) |
Like toEqual but does distinguish undefined, array holes, and the object's class |
expect(booking).toStrictEqual(new Booking(...)) |
toContain(x) |
An array contains the element (by identity), or a string contains the substring | expect(errors.bicicletaId).toContain('is not available') |
toContainEqual(x) |
An array contains an element that's structurally equal | expect(bookings).toContainEqual({ id: 'res-01', … }) |
toHaveLength(n) |
.length of arrays and strings |
expect(state.ids).toHaveLength(2) |
toHaveProperty(path, v) |
The property exists, optionally with that value | expect(errors).toHaveProperty('hours') |
toMatchObject(x) |
The object contains at least those properties | expect(booking).toMatchObject({ status: 'activa' }) |
toThrow(x) |
The function throws; optionally with that message or class | expect(() => useTheme()).toThrow('inside <ThemeProvider>') |
toBeCloseTo(n, d) |
Decimal numbers with tolerance | expect(total).toBeCloseTo(5.0, 2) |
toBeTruthy() / toBeFalsy() |
Truthiness, not exact value | expect(isValid).toBeTruthy() |
toBeNull() / toBeUndefined() / toBeDefined() |
Specific values | expect(state.error).toBeNull() |
toBeGreaterThan(n) / toBeLessThan(n) |
Numeric comparisons | expect(bike.pricePerHour).toBeGreaterThan(0) |
toMatch(regex) |
A string matches the regular expression | expect(booking.id).toMatch(/^res-[a-z0-9]{8}$/) |
toHaveBeenCalled() |
A mock function was called | Section 12 |
toHaveBeenCalledWith(...) |
It was called with those arguments | Section 12 |
toHaveBeenCalledTimes(n) |
It was called exactly n times |
Section 12 |
Negating with .not
Any matcher can be inverted by prefixing .not:
expect(errors).not.toHaveProperty('startDate'); // there is NO date error
expect(state.ids).not.toContain('res-99');
expect(onBook).not.toHaveBeenCalled();A warning about .not, because it's a recurring trap: a negative assertion proves less than it looks like. expect(errors).not.toHaveProperty('hours') passes whether the validation is correct or validateBooking returns {} because it's entirely broken. Whenever you can, pair a negative assertion with a positive one:
// ✅ The positive assertion anchors the negative one
expect(errors).toEqual({ startDate: 'The booking cannot start in the past.' });That single assertion says at once that there is a date error and that there's no other one, which is exactly what you want to state.
toBe vs. toEqual: the difference that causes the most bugs
toBe vs. toEqual: the difference that causes the most bugsconst a = { id: 'bici-001', model: 'Classic Urban' };
const b = { id: 'bici-001', model: 'Classic Urban' };
expect(a).toBe(b); // ❌ FAILS: two distinct objects in memory
expect(a).toEqual(b); // ✅ PASSES: same content
expect(a).toBe(a); // ✅ PASSES: literally the same objecttoBeusesObject.is, comparison by identity. For primitives — strings, numbers, booleans — identity and content coincide, soexpect('activa').toBe('activa')works. For objects and arrays, it compares references.toEqualwalks the structure and compares key by key, recursively.
This distinction isn't an academic detail: it's exactly the same identity comparison that governs memo and useMemo in Module 8, and the one that makes a selector returning a new array re-render the component in 07-05. The mnemonic rule: toBe for primitives, toEqual for objects and arrays.
And a difference between toEqual and toStrictEqual worth keeping in mind:
expect({ id: 'res-01', cancelledAt: undefined }).toEqual({ id: 'res-01' }); // ✅ PASSES
expect({ id: 'res-01', cancelledAt: undefined }).toStrictEqual({ id: 'res-01' }); // ❌ FAILStoEqual ignores properties with the value undefined; toStrictEqual doesn't, and it also checks that both objects are the same class. In CicloUrbano, toEqual is the usual choice, because a missing optional field and an optional field set to undefined mean the same thing to the application. Use toStrictEqual when the absence of a key is meaningful.
- The
validateBooking suite, rule by rule
validateBooking suite, rule by ruleHere's the lesson's central case. Recall the function you practiced writing in 03-05: it receives data and the catalogue of bikes, and returns an object with one message per field with an error, or {} if everything is valid. Four rules: bike required, existing, and available; date required, valid, and not earlier than now; hours an integer between 1 and 24; terms accepted.
// src/utils/validateBooking.test.js
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import { validateBooking } from './validateBooking.js';
// ARRANGE: a minimal but representative catalogue, with all three possible statuses
const BIKES = [
{ id: 'bici-001', model: 'Classic Urban', type: 'urbana', status: 'disponible', stationId: 'est-01', pricePerHour: 2.5 },
{ id: 'bici-002', model: 'Electric Pro', type: 'electrica', status: 'alquilada', stationId: 'est-01', pricePerHour: 4.0 },
{ id: 'bici-003', model: 'Cargo Max', type: 'carga', status: 'mantenimiento', stationId: 'est-02', pricePerHour: 5.5 }
];
// Factory for valid data: every test starts here and breaks ONLY one field.
// This is the pattern that keeps validation suites readable.
function validData(changes = {}) {
return {
bicicletaId: 'bici-001',
startDate: '2026-05-04T09:00',
hours: 2,
terms: true,
...changes
};
}
describe('validateBooking', () => {
// The clock is frozen: the "cannot start in the past" rule depends on Date.now()
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-04T08:00:00'));
});
afterEach(() => {
vi.useRealTimers();
});
test('returns an empty object when all the data is valid', () => {
expect(validateBooking(validData(), BIKES)).toEqual({});
});
});Two design decisions that apply to any validation suite:
- The
validData(changes)factory. Without it, every test would repeat all four fields and the reader would have to visually diff them to figure out what's being tested. With it,validData({ hours: 25 })says on its own what's being checked. Also, if the form gains a fifth field tomorrow, there's only one place to change. - The frozen clock in
beforeEach. The date rule compares againstDate.now(). If the test used the real clock, the date'2026-05-04T09:00'would be valid today and invalid a year from now: a test with an expiration date, which is a textbook flaky test. By freezing the system to2026-05-04T08:00, the test gives the same result a decade from now.
The bike rules
describe('bike', () => {
test('requires choosing a bike', () => {
const errors = validateBooking(validData({ bicicletaId: '' }), BIKES);
expect(errors).toEqual({ bicicletaId: 'Choose a bike.' });
});
test('rejects a bike that is not in the catalogue', () => {
const errors = validateBooking(validData({ bicicletaId: 'bici-999' }), BIKES);
expect(errors.bicicletaId).toBe('The selected bike does not exist.');
});
test('rejects a rented bike and includes its model in the message', () => {
const errors = validateBooking(validData({ bicicletaId: 'bici-002' }), BIKES);
expect(errors.bicicletaId).toBe('Electric Pro is not available right now.');
});
test('rejects a bike in maintenance', () => {
const errors = validateBooking(validData({ bicicletaId: 'bici-003' }), BIKES);
expect(errors.bicicletaId).toContain('is not available');
});
test('rejects any bike if the catalogue is empty', () => {
// The default value is [], and that path needs to be exercised too
expect(validateBooking(validData()).bicicletaId).toBe('The selected bike does not exist.');
});
});Notice the deliberate mix of matchers, because each one says something different:
toEqual({ bicicletaId: '…' })in the first one states that that's the only error. It's the strongest assertion, and the one that would catch a regression that also broke the date check.toBe('Electric Pro is not available right now.')checks the literal message, including the model interpolation. That's correct here because the user reads that text, and it's part of the behavior.toContain('is not available')is deliberately looser: it checks the rule without tying itself to the exact wording. Use it when the text might change for editorial reasons without the behavior changing.
The date, hours, and terms rules
describe('start date', () => {
test('requires a start date', () => {
expect(validateBooking(validData({ startDate: '' }), BIKES))
.toEqual({ startDate: 'Tell us when the booking starts.' });
});
test('rejects a date in an invalid format', () => {
expect(validateBooking(validData({ startDate: 'tomorrow' }), BIKES).startDate)
.toBe('The date is not in a valid format.');
});
test('rejects a date earlier than the current moment', () => {
// Clock frozen at 08:00; the booking asks for 07:59
expect(validateBooking(validData({ startDate: '2026-05-04T07:59' }), BIKES).startDate)
.toBe('The booking cannot start in the past.');
});
test('accepts a date in the exact minute we are in', () => {
// 08:00:00 is not LESS than 08:00:00, so it's valid: the exact boundary
expect(validateBooking(validData({ startDate: '2026-05-04T08:00' }), BIKES))
.toEqual({});
});
});
describe('terms of use', () => {
test('requires accepting the terms', () => {
expect(validateBooking(validData({ terms: false }), BIKES))
.toEqual({ terms: 'You must accept the terms of use.' });
});
});
describe('error accumulation', () => {
test('returns all the errors at once, not just the first one', () => {
const errors = validateBooking(
{ bicicletaId: '', startDate: '', hours: '', terms: false },
BIKES
);
expect(Object.keys(errors)).toHaveLength(4);
expect(errors).toEqual({
bicicletaId: 'Choose a bike.',
startDate: 'Tell us when the booking starts.',
hours: 'Tell us how many hours you want the bike for.',
terms: 'You must accept the terms of use.'
});
});
});That last test is worth more than it looks. It verifies an explicit design decision from 03-05: validation doesn't stop at the first error, because a form that shows errors one at a time forces the user into four attempts. If someone refactors validateBooking with early returns, this test catches it.
- Edge cases and
test.each
test.eachEdge cases are the values sitting right at the boundary of a rule, and that's where most real bugs live. For hours, the rule is "an integer between 1 and 24." The boundaries are 0/1 and 24/25.
Writing eight nearly identical tests is noise. test.each takes a table and generates one test per row:
describe('duration in hours', () => {
test.each([
// value, valid?, expected message fragment
[0, false, 'The minimum booking is 1 hour.'],
[1, true, null],
[2, true, null],
[24, true, null],
[25, false, 'The maximum booking is 24 hours.'],
[-3, false, 'The minimum booking is 1 hour.'],
[2.5, false, 'Hours must be a whole number.'],
['', false, 'Tell us how many hours you want the bike for.'],
['two', false, 'Tell us how many hours you want the bike for.']
])('with hours = %p the error is %p', (hours, isValid, message) => {
const errors = validateBooking(validData({ hours }), BIKES);
if (isValid) {
expect(errors).not.toHaveProperty('hours');
} else {
expect(errors.hours).toBe(message);
}
});
});How it works:
- The table is an array of arrays. Each row gets destructured into the test function's parameters.
- The name accepts
printf-style placeholders:%pprints the value as-is (useful for telling''apart from'two'),%sconverts it to a string,%ito an integer. The report shows nine tests with distinct names, so a failure points to the exact row. - There's also a tagged-template variant, more readable when there are many columns:
test.each`
hours | expected
${0} | ${'The minimum booking is 1 hour.'}
${25} | ${'The maximum booking is 24 hours.'}
${2.5} | ${'Hours must be a whole number.'}
`('with hours = $hours returns "$expected"', ({ hours, expected }) => {
expect(validateBooking(validData({ hours }), BIKES).hours).toBe(expected);
});What a poorly covered edge case reveals
Look at the 2.5 row. Without it, this alternative implementation of the rule would pass every other test:
// Implementation with a bug that only the decimal edge case catches
if (hours < 1) errors.hours = 'The minimum booking is 1 hour.';
else if (hours > 24) errors.hours = 'The maximum booking is 24 hours.';
// ⚠️ Missing the integer check: 2.5 passes as validWith 0, 1, 24, and 25 the suite would be green, and yet the user could book for two and a half hours — something the price-per-hour and the station system don't account for. That's the case for edge cases in one sentence: tests for "normal" values confirm what you already knew; tests for the boundaries find what you didn't.
The list of boundaries worth exercising every time: zero, the minimum value, the maximum, the maximum plus one, a negative number, a decimal when an integer is expected, the empty string, null, undefined, and the empty collection.
- Testing the
bookingsSlice reducer as a pure function
bookingsSlice reducer as a pure functionThis is where we close what was left pending in 05-05 and promised again in 07-04: a reducer is a pure function (state, action) => newState, so testing it means calling it and comparing. No React, no store, no component, no Provider needed.
Recall the shape of the state: { entities, ids, loadState, error, submitState }, normalized.
// src/features/bookings/bookingsSlice.test.js
import { describe, test, expect, beforeEach, vi } from 'vitest';
import bookingsReducer, {
bookingCreated, bookingConfirmed, bookingCancelled, submitStarted, submitFailed
} from './bookingsSlice.js';
const EMPTY_STATE = {
entities: {},
ids: [],
loadState: 'idle',
error: null,
submitState: 'idle'
};
const BOOKING_01 = {
id: 'res-01',
bicicletaId: 'bici-002',
user: 'usr-01',
startDate: '2026-05-04T09:00',
hours: 2,
status: 'activa'
};
// A state that's already populated, for testing transitions
const STATE_WITH_BOOKING = {
...EMPTY_STATE,
entities: { 'res-01': BOOKING_01 },
ids: ['res-01']
};
describe('bookingsReducer', () => {
test('returns the initial state for an unknown action', () => {
const result = bookingsReducer(undefined, { type: 'unknown/action' });
expect(result).toEqual(EMPTY_STATE);
});
test('does not change the state for an action that is not its own', () => {
const result = bookingsReducer(STATE_WITH_BOOKING, { type: 'catalogue/searchTermChanged' });
// toBe, not toEqual: we're checking it returns the SAME reference
expect(result).toBe(STATE_WITH_BOOKING);
});
});That second test uses toBe on purpose, and it's a good example of when identity is observable behavior: if the reducer returned a copy for every unrelated action, every useSelector subscribed to bookings would re-render on any action in the app. It's the problem from 07-05 turned into a test.
The complete lifecycle
describe('creating bookings', () => {
beforeEach(() => {
// bookingCreated uses crypto.randomUUID and new Date: both need to be fixed
vi.setSystemTime(new Date('2026-05-04T08:00:00'));
vi.spyOn(crypto, 'randomUUID').mockReturnValue('abcd1234-0000-0000-0000-000000000000');
});
test('adds the booking to entities and its id to the end of ids', () => {
// The action is built with the creator: `prepare` generates the id and the date
const action = bookingCreated('bici-001', 'usr-01', '2026-05-04T10:00', 3);
// And the reducer is invoked for what it is: a function of two arguments
const result = bookingsReducer(EMPTY_STATE, action);
expect(result.ids).toEqual(['res-abcd1234']);
expect(result.entities['res-abcd1234']).toMatchObject({
bicicletaId: 'bici-001',
user: 'usr-01',
startDate: '2026-05-04T10:00',
hours: 3,
status: 'activa'
});
expect(result.submitState).toBe('submitted');
expect(result.error).toBeNull();
});
test('does not mutate the state it receives', () => {
const action = bookingCreated('bici-001', 'usr-01', '2026-05-04T10:00', 3);
bookingsReducer(EMPTY_STATE, action);
// The input state is still intact: Immer returns a copy
expect(EMPTY_STATE.ids).toHaveLength(0);
expect(EMPTY_STATE.entities).toEqual({});
});
});Three things these tests verify that you can't verify by looking at the screen:
- The exact shape of the state after the action, including
submitStateanderror, which only show up indirectly in the UI. - That
preparegenerates the identifier in the agreed format,res-plus eight characters. That's whycrypto.randomUUIDgets spied on: without pinning it, the identifier would differ on every run and there'd be nothing to compare against. - That there's no mutation. Even if you write
state.ids.push(...)inside the reducer, Immer — which Redux Toolkit includes — produces a copy. This test confirms it, and it would catch the day someone pulls that logic out ofcreateSliceand the mutation becomes real.
The business guards
The reducer's most valuable rules are its guards, because they're invisible and hard to trigger by hand:
describe('confirming and cancelling', () => {
test('confirms an active booking', () => {
const result = bookingsReducer(STATE_WITH_BOOKING, bookingConfirmed('res-01'));
expect(result.entities['res-01'].status).toBe('confirmada');
});
test('ignores confirming a booking that does not exist', () => {
const result = bookingsReducer(STATE_WITH_BOOKING, bookingConfirmed('res-99'));
expect(result).toEqual(STATE_WITH_BOOKING);
});
test('does not confirm a booking that is already cancelled', () => {
const cancelled = {
...STATE_WITH_BOOKING,
entities: { 'res-01': { ...BOOKING_01, status: 'cancelada' } }
};
const result = bookingsReducer(cancelled, bookingConfirmed('res-01'));
expect(result.entities['res-01'].status).toBe('cancelada');
});
test('cancels an active booking and records the cancellation time', () => {
vi.setSystemTime(new Date('2026-05-04T12:30:00'));
const result = bookingsReducer(STATE_WITH_BOOKING, bookingCancelled('res-01'));
expect(result.entities['res-01'].status).toBe('cancelada');
expect(result.entities['res-01'].cancelledAt).toBe('2026-05-04T12:30:00.000Z');
});
test('cancelling twice does not change the first cancellation time', () => {
vi.setSystemTime(new Date('2026-05-04T12:30:00'));
const once = bookingsReducer(STATE_WITH_BOOKING, bookingCancelled('res-01'));
vi.setSystemTime(new Date('2026-05-04T18:00:00'));
const twice = bookingsReducer(once, bookingCancelled('res-01'));
expect(twice.entities['res-01'].cancelledAt).toBe('2026-05-04T12:30:00.000Z');
});
});That last test is exactly the kind of check that justifies the whole module: reproducing a double cancellation by hand, with the right timing, is tedious and unreliable; in code it's six lines, and it runs in two milliseconds, forever.
Chaining actions to test a sequence
test('goes through the complete cycle: create, confirm, and cancel', () => {
vi.spyOn(crypto, 'randomUUID').mockReturnValue('abcd1234-0000-0000-0000-000000000000');
// The state gets passed from one call to the next: that's the store, without the store
let state = bookingsReducer(undefined, { type: '@@init' });
state = bookingsReducer(state, submitStarted());
expect(state.submitState).toBe('submitting');
state = bookingsReducer(state, bookingCreated('bici-001', 'usr-01', '2026-05-04T10:00', 2));
expect(state.submitState).toBe('submitted');
state = bookingsReducer(state, bookingConfirmed('res-abcd1234'));
expect(state.entities['res-abcd1234'].status).toBe('confirmada');
state = bookingsReducer(state, bookingCancelled('res-abcd1234'));
expect(state.entities['res-abcd1234'].status).toBe('cancelada');
expect(state.ids).toEqual(['res-abcd1234']); // cancelling doesn't remove, only marks
});flowchart LR
A["initial state"] -->|"submitStarted()"| B["submitState: submitting"]
B -->|"bookingCreated(...)"| C["res-abcd1234 · activa"]
C -->|"bookingConfirmed(id)"| D["confirmada"]
C -->|"bookingCancelled(id)"| E["cancelada + cancelledAt"]
D -->|"bookingCancelled(id)"| E
E -->|"bookingConfirmed(id)"| E2["no change (guard)"]
- Testing the selectors
A selector is just as pure: (state) => derivedValue. The only quirk is that the project's selectors receive the global state, not the slice's own slice of it, so it has to be built in the shape defined by store.js: { bookings, catalogue, session }.
// src/features/bookings/selectors.test.js
import { describe, test, expect } from 'vitest';
import {
selectBookingIds, selectBookingById, selectSubmitState
} from './bookingsSlice.js';
import { selectActiveBookings } from './bookingsSelectors.js';
function globalState(bookings) {
return {
bookings,
catalogue: { searchTerm: '', sortOrder: 'model', bikes: [] },
session: { user: { id: 'usr-01', name: 'Ana Ribera', role: 'cliente' }, loading: false }
};
}
const STATE = globalState({
entities: {
'res-01': { id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01', hours: 2, status: 'activa' },
'res-02': { id: 'res-02', bicicletaId: 'bici-005', user: 'usr-01', hours: 1, status: 'cancelada' }
},
ids: ['res-01', 'res-02'],
loadState: 'success',
error: null,
submitState: 'idle'
});
describe('booking selectors', () => {
test('selectBookingIds returns the ids in order', () => {
expect(selectBookingIds(STATE)).toEqual(['res-01', 'res-02']);
});
test('selectBookingById returns the requested booking', () => {
expect(selectBookingById(STATE, 'res-01')).toMatchObject({ bicicletaId: 'bici-002' });
});
test('selectBookingById returns undefined if the id does not exist', () => {
expect(selectBookingById(STATE, 'res-99')).toBeUndefined();
});
test('selectActiveBookings filters out the cancelled ones', () => {
const active = selectActiveBookings(STATE);
expect(active).toHaveLength(1);
expect(active[0].id).toBe('res-01');
});
});Testing a createSelector's memoization
A memoized selector has one extra property that does deserve testing, because it's the whole reason it exists: returning the same reference if the inputs don't change. It's what prevents the re-renders from 07-05, and it's exactly what someone would break by swapping it for a plain function.
test('selectActiveBookings returns the SAME reference if the state has not changed', () => {
const first = selectActiveBookings(STATE);
const second = selectActiveBookings(STATE);
expect(second).toBe(first); // ← toBe: identity, not content
});
test('recalculates when the bookings change', () => {
const first = selectActiveBookings(STATE);
const otherState = globalState({
...STATE.bookings,
entities: { ...STATE.bookings.entities, 'res-03': { id: 'res-03', status: 'activa' } },
ids: [...STATE.bookings.ids, 'res-03']
});
const second = selectActiveBookings(otherState);
expect(second).not.toBe(first);
expect(second).toHaveLength(2);
});An important warning: since createSelector's memoization only keeps one result, interleaving calls with different states invalidates the cache each time. If you write this test and it fails unexpectedly, check that there isn't a call with a different state in between.
- Test doubles: spies, stubs, and mocks
A test double is any object that substitutes for a real dependency during a test, the same way a stunt double substitutes for an actor in a dangerous scene. The names get used loosely day to day, but the distinctions are useful:
| Type | What it does | When it's used | In Vitest |
|---|---|---|---|
| Spy | Wraps the real function and records the calls, without changing the behavior | Checking that something got called, while keeping what it does | vi.spyOn(obj, 'method') |
| Stub | Replaces the function with one that returns a fixed value | Forcing a specific response: an error, an empty list | vi.fn().mockReturnValue(x) |
| Mock | A stub that also verifies how it was called | Checking the interaction, not just the result | vi.fn() + toHaveBeenCalledWith |
| Fake | A simplified but functional alternative implementation | Replacing a database with an in-memory object | A hand-written class |
The rule that governs all of this, and that will come back in 09-04 with MSW:
Mock as little as possible. Every test double is a copy of reality that can drift out of sync with it. A test full of mocks ends up testing the mocks.
vi.fn(): checking that a callback was called
vi.fn(): checking that a callback was calledvi.fn() creates a mock function that does nothing, returns undefined, and records every call. It's the basic tool for verifying the project's callbacks: CicloUrbano's onX convention — onBook, onTypeChange, onCreateBooking — is precisely what gets checked with it.
// src/utils/monitoring.test.js
import { describe, test, expect, vi } from 'vitest';
describe('vi.fn in action', () => {
test('records whether it was called, how many times, and with what', () => {
const onBook = vi.fn();
onBook('bici-001');
onBook('bici-004');
expect(onBook).toHaveBeenCalled();
expect(onBook).toHaveBeenCalledTimes(2);
expect(onBook).toHaveBeenCalledWith('bici-001');
expect(onBook).toHaveBeenLastCalledWith('bici-004');
expect(onBook).toHaveBeenNthCalledWith(1, 'bici-001');
// Direct access to the call log, useful for complex assertions
expect(onBook.mock.calls).toEqual([['bici-001'], ['bici-004']]);
});
test('can return controlled values', () => {
const getPrice = vi.fn()
.mockReturnValueOnce(2.5) // first call
.mockReturnValueOnce(4.0) // second
.mockReturnValue(0); // the rest
expect(getPrice()).toBe(2.5);
expect(getPrice()).toBe(4.0);
expect(getPrice()).toBe(0);
});
test('can simulate a full implementation', () => {
const calculateTotal = vi.fn((pricePerHour, hours) => pricePerHour * hours);
expect(calculateTotal(2.5, 4)).toBe(10);
expect(calculateTotal).toHaveBeenCalledWith(2.5, 4);
});
});| Method | What it's for |
|---|---|
mockReturnValue(v) |
Always returns v |
mockReturnValueOnce(v) |
Returns v only next time; can be chained |
mockResolvedValue(v) |
Returns a promise resolved with v (async functions) |
mockRejectedValue(e) |
Returns a promise rejected with e: this is how errors get tested |
mockImplementation(fn) |
Replaces the body with fn |
mockClear() |
Clears the call log, keeps the implementation |
mockReset() |
Clears the call log and the implementation |
mockRestore() |
Restores the original function (only for vi.spyOn) |
About argument assertions: when the argument is an object, toHaveBeenCalledWith compares structurally, not by identity. And if you only care about part of the object, there are asymmetric matchers:
expect(onCreateBooking).toHaveBeenCalledWith(
expect.objectContaining({ bicicletaId: 'bici-001', hours: 2, status: 'activa' })
);
expect(onCreateBooking).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.stringMatching(/^res-/) })
);This last one is the right approach for an identifier generated with crypto.randomUUID(): checking the format, not the value, avoids having to spy on the crypto function.
vi.spyOn and vi.mock: substituting without breaking things
vi.spyOn and vi.mock: substituting without breaking thingsvi.spyOn: wrapping an existing method
vi.spyOn(object, 'method') replaces that method with a mock function that, by default, still calls the original. The most common case in React tests is silencing console.error, and it'll come up again in 09-04 when testing ErrorBoundary.
import { describe, test, expect, vi, afterEach } from 'vitest';
import { reportError } from './monitoring.js';
describe('reportError', () => {
afterEach(() => {
vi.restoreAllMocks(); // essential: restores console.error to itself
});
test('writes the error to the console in development', () => {
// mockImplementation(() => {}) silences the output: the console stays clean
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
reportError(new Error('Failed to load the fleet'), { component: 'CataloguePage' });
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toContain('Failed to load the fleet');
});
});vi.restoreAllMocks() in the afterEach is not optional. If you don't restore console.error, every later test in the same process goes silent, including React's legitimate warnings. It can be automated in the global configuration:
// vite.config.js, inside the test block
test: {
restoreMocks: true, // vi.restoreAllMocks() automatically after each test
clearMocks: true // vi.clearAllMocks() automatically after each test
}vi.mock: substituting an entire module
When what needs substituting is an entire module — because it makes requests, writes to an external service, or depends on the environment — you use vi.mock:
// src/utils/bookingsRegistry.test.js
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { reportError } from './monitoring.js';
import { saveBooking } from './bookingsRegistry.js';
// Replaces the ENTIRE monitoring.js module with mock functions.
// The call gets hoisted to the top of the file, before the imports,
// so the factory CANNOT close over variables defined outside it.
vi.mock('./monitoring.js', () => ({
reportError: vi.fn(),
reportEvent: vi.fn()
}));
describe('saveBooking', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('reports the error when the booking is not valid', () => {
saveBooking({ bicicletaId: '', hours: 0 });
expect(reportError).toHaveBeenCalledTimes(1);
expect(reportError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ origin: 'saveBooking' })
);
});
});Three details that cause errors if ignored:
vi.mockgets hoisted to the top of the file. Even if you write it after theimports, it runs before them. That's why the factory can't close over external variables: thevi.fn()s get declared directly inside it. If you need a reference outside, usevi.hoisted.- The mocked module replaces every one of its exports. If
monitoring.jsexported ten functions and you only declare two in the factory, the other eight becomeundefined. To keep the rest, combine it withimportActual:
vi.mock('./monitoring.js', async (importOriginal) => {
const original = await importOriginal();
return { ...original, reportError: vi.fn() }; // only one gets replaced
});vi.mockis the biggest hammer in the box. It replaces the real module with a copy that doesn't evolve with it: ifreportErrorchanges its signature tomorrow, the test keeps passing with the old signature. Hence the rule from section 11. In order of preference: pass the dependency as a parameter >vi.spyOnon a specific method >vi.mockthe entire module. And for the network, none of the three: MSW (09-04).
- Controlling time:
vi.useFakeTimers
vi.useFakeTimersTime is the biggest source of slow, flaky tests. vi.useFakeTimers() replaces setTimeout, setInterval, clearTimeout, Date, and performance.now with controlled implementations: the clock only moves forward when you tell it to.
| Function | What it does |
|---|---|
vi.useFakeTimers() |
Turns on fake timers |
vi.useRealTimers() |
Restores them to normal. Always in an afterEach |
vi.advanceTimersByTime(ms) |
Advances the clock by ms milliseconds and runs whatever came due |
vi.runAllTimers() |
Runs every pending timer at once |
vi.runOnlyPendingTimers() |
Runs the pending ones without chaining into whatever they schedule |
vi.setSystemTime(date) |
Pins Date.now() and new Date() to a specific date |
vi.getTimerCount() |
How many timers are pending |
Let's test the mechanism behind useDebounce at the function level, without React. The hook wraps a setTimeout that gets cancelled during cleanup; this is the logic, and it's what needs verifying:
// src/utils/debounce.js — the mechanism behind useDebounce, extracted as a function
export function debounce(action, delayMs = 400) {
let timeoutId = null;
function invoke(...args) {
clearTimeout(timeoutId); // cancels the pending call
timeoutId = setTimeout(() => action(...args), delayMs);
}
invoke.cancel = () => clearTimeout(timeoutId);
return invoke;
}// src/utils/debounce.test.js
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce.js';
describe('debounce (the mechanism behind useDebounce)', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test('does not call the action before the delay elapses', () => {
const search = vi.fn();
const debouncedSearch = debounce(search, 400);
debouncedSearch('urb');
vi.advanceTimersByTime(399);
expect(search).not.toHaveBeenCalled();
});
test('calls the action once the delay has elapsed', () => {
const search = vi.fn();
const debouncedSearch = debounce(search, 400);
debouncedSearch('urb');
vi.advanceTimersByTime(400);
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('urb');
});
test('typing six letters in a row produces ONE single call, with the last one', () => {
const search = vi.fn();
const debouncedSearch = debounce(search, 400);
// The user types "urbana" at 100ms per letter
for (const text of ['u', 'ur', 'urb', 'urba', 'urban', 'urbana']) {
debouncedSearch(text);
vi.advanceTimersByTime(100);
}
expect(search).not.toHaveBeenCalled(); // 400ms haven't passed since the last one yet
vi.advanceTimersByTime(400);
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('urbana');
});
test('cancelling prevents the pending call', () => {
const search = vi.fn();
const debouncedSearch = debounce(search, 400);
debouncedSearch('urb');
debouncedSearch.cancel();
vi.advanceTimersByTime(1000);
expect(search).not.toHaveBeenCalled();
});
});The third test is the one that justifies all of useDebounce from Module 5 and all the search-optimization work from Module 8, and it's the one that would be almost impossible to verify by hand: reproducibly typing six letters in under 400ms isn't something fingers can do. With fake timers, it's deterministic and takes one millisecond.
With fake timers, if you forget to advance the clock, the test waits forever. The symptom is a timed-out test. Testing useDebounce inside React, with renderHook, is covered in 09-04, because it requires combining fake timers with act.
- Running, filtering, and reading coverage
Watch mode
Vitest keeps listening, and when you save a file it reruns only the tests affected by that change, following the import graph. Saving validateBooking.js reruns validateBooking.test.js and BookingForm.test.jsx, but not the 40 booking tests. In watch mode there are useful keys:
| Key | What it does |
|---|---|
a |
Reruns all tests |
f |
Reruns only the ones that failed |
p |
Filters by file name |
t |
Filters by test name |
q |
Quit |
Filtering from code and from the command line
test.only('only this test runs in this file', () => { /* … */ });
test.skip('this one is skipped and shows up marked in the report', () => { /* … */ });
test.todo('pending: reject overlapping bookings for the same bike');
describe.only('this entire block', () => { /* … */ });
test.fails('this test MUST fail', () => { /* … */ });npx vitest run src/utils # only the files in that folder
npx vitest run -t "maintenance" # only tests whose name contains that
npx vitest run --reporter=verbose # the full tree, test by testAbout test.only: it's extremely useful while debugging and catastrophic if you forget it's there, because it silently disables the rest of the file and the suite stays green. The safeguard is a lint rule (vitest/no-focused-tests or jest/no-focused-tests) that turns it into an error. Turn it on.
test.todo is the right way to note what's missing: it shows up in the report as pending, it doesn't fail, and it doesn't lie about coverage the way a permanent test.skip would.
Reading a coverage report
% Coverage report from v8 ---------------------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ---------------------------|---------|----------|---------|---------|------------------- All files | 78.42 | 71.05 | 80.00 | 78.42 | utils | 96.15 | 94.44 | 100.00 | 96.15 | validateBooking.js | 100.00 | 100.00 | 100.00 | 100.00 | classNames.js | 100.00 | 100.00 | 100.00 | 100.00 | monitoring.js | 72.72 | 50.00 | 100.00 | 72.72 | 18-24 features/bookings | 91.30 | 88.88 | 100.00 | 91.30 | bookingsSlice.js | 91.30 | 88.88 | 100.00 | 91.30 | 47,63 components | 31.25 | 12.50 | 25.00 | 31.25 | BookingForm.jsx | 0.00 | 0.00 | 0.00 | 0.00 | 1-142 ---------------------------|---------|----------|---------|---------|-------------------
How to read this without falling into the trap from section 8.1 of the previous lesson:
- The most informative column is
% Branch. 100% statements with 50% branches means every line runs, but only one of the two paths of eachif. Inmonitoring.js, that 50% points to the production branch never being exercised. Uncovered Line #sis the to-do list. Lines 47 and 63 ofbookingsSlice.jsare, very likely, two guards (if (!booking) return;) that haven't been triggered yet. They deserve a test: they're business rules.- The 0% for
BookingForm.jsxis correct today, because components get tested in 09-03. It's not an alarm: it's a known, planned gap. - The HTML report at
coverage/index.htmlpaints the source code with uncovered lines in red and partial branches in yellow. It's far more useful than the table for deciding what to test.
And the ever-present rule: the goal isn't the number. The goal is for the red lines in the business logic to stop being red.
Common Mistakes and Tips
- Using
toBewith objects and arrays. The number-one beginner mistake.expect({ a: 1 }).toBe({ a: 1 })always fails. Rule: primitives withtoBe, structures withtoEqual, and deliberate identity withtoBeonly when the reference is the behavior under test (memoization, a reducer that must not copy). - Sharing state between tests with
beforeAllor a mutable constant. Produces tests that pass alone and fail in the suite, or the other way around.beforeEachrebuilds;beforeAllonly for what's expensive and immutable. - Forgetting
vi.useRealTimers()in theafterEach. Fake timers stay active and contaminate the rest of the file; a later test that waits for real hangs until it times out. Same goes forvi.restoreAllMocks()andconsole.error. - Leaving a
test.onlyin the code. Silently disables the rest of the file, and the suite stays green because it's empty. Turn on the lint rule that forbids it. - Testing the reducer's implementation instead of its result. Don't check that
state.ids.pushgot called; check that the returned state contains the new identifier. It's the principle from 09-01, applied to Redux. - Mocking too much. A
vi.mockof a module that belongs to the project itself is usually a sign of coupling. Before mocking, ask whether the dependency could come in as a parameter:validateBooking(data, bikes)receives the catalogue precisely for that reason, and that's why it's tested without any double. - Testing a pure function with more setup than necessary. If testing the reducer means building a full Redux store, you've lost the advantage: call it directly.
- Tip: write the failing test first. Even if you don't practice test-driven development, confirming the test fails before writing the code is the only way to know the test checks something. A test that's never been red might be checking nothing at all.
- Tip: when you fix a bug, write the test that reproduces it first. It confirms you've understood it and keeps it from coming back. It's the best moment to write a test, because you already have the concrete case in hand.
- Tip: if a test needs more than ten lines of setup, the code under test has too many dependencies. Difficulty testing something is a design signal, not an annoyance from the runner.
Exercises
Exercise 1. Write the suite for src/utils/classNames.js, the utility that composes class names while ignoring falsy values:
Cover at least: several names, a false value mixed in (the condition && styles.active case), undefined and null, calling with no arguments, and calling with a single name. Use test.each where it makes sense, and justify why the result gets compared with toBe rather than toEqual.
Exercise 2. The team adds a new rule to validateBooking: a bike of type carga cannot be booked for more than 8 hours. Write the tests before implementing it — including the edge cases — and then the minimal implementation that makes them pass. State which test in the existing suite might fail because of the change, and why.
Exercise 3. This test for bookingsSlice always passes, even if the reducer is broken. Explain why, and rewrite it so it verifies what it's meant to.
test('cancellation works', () => {
const state = {
entities: { 'res-01': { id: 'res-01', status: 'activa' } },
ids: ['res-01'], loadState: 'idle', error: null, submitState: 'idle'
};
const result = bookingsReducer(state, bookingCancelled('res-01'));
expect(result).toBeTruthy();
expect(result.entities).toBeDefined();
expect(Object.keys(result.entities)).toHaveLength(1);
});Solutions
Solution 1.
// src/utils/classNames.test.js
import { describe, test, expect } from 'vitest';
import { cx } from './classNames.js';
describe('cx', () => {
test.each([
[['card', 'featured'], 'card featured'],
[['card', false, 'active'], 'card active'],
[['card', undefined], 'card'],
[['card', null, 'active'], 'card active'],
[['card', '', 'active'], 'card active'],
[['card'], 'card'],
[[], '']
])('cx(...%p) returns %p', (input, expected) => {
expect(cx(...input)).toBe(expected);
});
test('mirrors the project\'s real usage', () => {
const styles = { control: 'control_a1', invalid: 'invalid_b2' };
const hasError = true;
expect(cx(styles.control, hasError && styles.invalid)).toBe('control_a1 invalid_b2');
expect(cx(styles.control, false && styles.invalid)).toBe('control_a1');
});
});It's compared with toBe because the result is a string, i.e., a primitive: identity and content coincide, and toBe also gives a clearer error message with the text diff. toEqual would work the same, but using the more specific matcher documents the type of the returned value.
Notice the last row: cx() with no arguments returns '', not undefined or a space. It's a real edge case, because that value ends up in className, and an undefined there would render the attribute literally in some scenarios.
Solution 2. First, the tests:
describe('8-hour limit for cargo bikes', () => {
test.each([
[1, true],
[8, true],
[9, false],
[24, false]
])('bici-003 (carga) with %i hours: valid? %p', (hours, isValid) => {
// bici-003 is in maintenance in the base catalogue; for this rule we need an AVAILABLE cargo bike
const catalogue = [{ id: 'bici-006', model: 'Cargo Max', type: 'carga', status: 'disponible', pricePerHour: 5.5 }];
const errors = validateBooking(validData({ bicicletaId: 'bici-006', hours }), catalogue);
if (isValid) {
expect(errors).not.toHaveProperty('hours');
} else {
expect(errors.hours).toBe('Cargo bikes can be booked for a maximum of 8 hours.');
}
});
test('an urban bike does allow 24 hours', () => {
expect(validateBooking(validData({ bicicletaId: 'bici-001', hours: 24 }), BIKES))
.not.toHaveProperty('hours');
});
});The minimal implementation, added after the general maximum check:
const MAX_CARGO_HOURS = 8;
// … inside the hours section, after the MAX_HOURS check
const chosen = bikes.find((b) => b.id === data.bicicletaId);
if (!errors.hours && chosen?.type === 'carga' && hours > MAX_CARGO_HOURS) {
errors.hours = 'Cargo bikes can be booked for a maximum of 8 hours.';
}Which existing test might fail: none of the ones using bici-001, since it's urban. But any future test that booked bici-003 for more than 8 hours expecting the generic 24-hour message would fail. And there's a design detail the suite forces you to resolve: the new check must not override the availability error, hence the !errors.hours, and hence the test case uses an available cargo bike. Writing the test first is what surfaced that decision before implementing it.
Solution 3. Why it always passes: all three assertions are tautological.
expect(result).toBeTruthy()passes for any object, even the untouched state.expect(result.entities).toBeDefined()passes as long as the reducer returns anything with that key.Object.keys(result.entities)).toHaveLength(1)counts the entities, which don't change on cancellation: cancelling marks, it doesn't remove. In other words, that assertion would pass just the same if the reducer did absolutely nothing.
None of the three looks at status, which is the only thing the action should change. On top of that, the name ("cancellation works") doesn't say what behavior is being verified.
Rewritten:
describe('bookingCancelled', () => {
const STATE = {
entities: { 'res-01': { id: 'res-01', bicicletaId: 'bici-002', status: 'activa' } },
ids: ['res-01'], loadState: 'idle', error: null, submitState: 'idle'
};
beforeEach(() => vi.setSystemTime(new Date('2026-05-04T12:30:00')));
afterEach(() => vi.useRealTimers());
test('marks the booking as cancelled and records the time', () => {
const result = bookingsReducer(STATE, bookingCancelled('res-01'));
expect(result.entities['res-01'].status).toBe('cancelada');
expect(result.entities['res-01'].cancelledAt).toBe('2026-05-04T12:30:00.000Z');
});
test('keeps the booking in the list: cancelling does not remove it', () => {
const result = bookingsReducer(STATE, bookingCancelled('res-01'));
expect(result.ids).toEqual(['res-01']);
});
test('does not mutate the state it receives', () => {
bookingsReducer(STATE, bookingCancelled('res-01'));
expect(STATE.entities['res-01'].status).toBe('activa');
});
});Now each test asserts one concrete, verifiable behavior, and any of them would go red if the reducer stopped doing its job. The second one, on top of that, documents a design decision — cancelling doesn't delete — that would otherwise live only in the head of whoever wrote it.
Conclusion
This lesson built the module's foundation without touching React, and showed why that part is the most cost-effective: validateBooking, the bookingsSlice reducer, and the selectors concentrate every one of CicloUrbano's business rules, and testing them means calling them and comparing.
The essentials. A test runner discovers, transforms, isolates, parallelizes, reports, watches, and measures. Jest is the ecosystem's mental model and Vitest implements the same API; the only real differences are the vi prefix instead of jest, where the configuration lives, and how the code gets transformed, so what you learned here works for both — and you have the recipe for jest.config.js with its moduleNameMapper, its Babel setup, and its jest-environment-jsdom, for the day you land on a project without Vite. The anatomy of a file is describe, test/it, and four hooks whose order is a stack: the befores from outside in, the afters from inside out; and the rule that follows from that is preferring beforeEach to beforeAll, because shared state is the number-one cause of tests that only fail in the full suite.
From the catalogue of matchers, the distinction that causes the most bugs is toBe vs. toEqual: identity against structure, the same comparison that governs memo in Module 8 and the selectors in Module 7. Primitives with toBe, objects with toEqual, toStrictEqual when the absence of a key is meaningful, and .not always paired with a positive assertion to anchor it — because toEqual({ startDate: '…' }) states at once that there is that error and that there's no other one.
The validateBooking suite established two reusable patterns: the validData(changes) factory, which makes it obvious which field each test is breaking, and the frozen clock with vi.setSystemTime, without which the date rule would expire. And test.each turned nine hours edge cases into a readable table, with the underlying lesson: the decimal case 2.5 is the one that catches an implementation that 0, 1, 24, and 25 would let through. Tests for normal values confirm what you already knew; tests for the boundaries find what you didn't.
The reducer closed out what was promised in 05-05 and 07-04. It's tested by calling it directly — no store, no Provider, no components — chaining the state from one call to the next to walk the activa → confirmada / cancelada cycle, and verifying what the screen doesn't show: the business guards, the absence of mutation, and — with toBe — that it returns the same reference for unrelated actions, which is what keeps the whole app from re-rendering. Selectors are tested the same way, building the global state in the shape of store.js, and the ones memoized with createSelector have their own necessary test: that they return the same reference if the inputs don't change.
About test doubles, you now tell apart spy, stub, mock, and fake, and you've mastered the three tools: vi.fn() to verify the project's onX callbacks with toHaveBeenCalledWith and asymmetric matchers like expect.objectContaining; vi.spyOn to wrap console.error without polluting the output, always with its restoreAllMocks; and vi.mock to substitute an entire module, with its three traps — hoisting, wholesale replacement of the exports, and silently drifting away from the real module. Hence the hierarchy: parameter > spyOn > mock, and for the network, none of the three. Fake timers turned into something deterministic what was impossible to reproduce by hand: six keystrokes in 500ms that must produce a single call with 'urbana'. And you know how to run in watch mode, filter with test.only/test.skip/test.todo — with the lint rule that prevents forgetting an only — and read a coverage report by looking at the branches column and the uncovered lines in the business logic.
What remains is the visible half. The rules are tested, but nobody has yet checked that the user sees the message "The maximum booking is 24 hours" next to the right field, or that the "Book" button is disabled for a bike in maintenance, or that clicking a filter notifies the parent with the chosen type. That's the territory of the next lesson, and where the testing trophy puts its greatest weight: real components, mounted in jsdom, queried the way a person would query them — by role, by label, by accessible text — and interacted with via userEvent. There you'll see why all the accessibility work from Module 3 was, without saying so, the preparation for this. The next lesson is Testing Components with React Testing Library.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
