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

  1. What a test runner is and what it does for you
  2. Jest and Vitest: the same model, two implementations
  3. Configuring Jest in a project that doesn't use Vite
  4. Anatomy of a test file: describe, test, and the hooks
  5. Assertions: the matcher catalogue
  6. toBe vs. toEqual: the difference that causes the most bugs
  7. The validateBooking suite, rule by rule
  8. Edge cases and test.each
  9. Testing the bookingsSlice reducer as a pure function
  10. Testing the selectors
  11. Test doubles: spies, stubs, and mocks
  12. vi.fn(): checking that a callback was called
  13. vi.spyOn and vi.mock: substituting without breaking things
  14. Controlling time: vi.useFakeTimers
  15. Running, filtering, and reading coverage

  1. 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.

  1. 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.

  1. 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' }]
  ]
};
// src/tests/setup.js — in Jest, without the /vitest suffix
import '@testing-library/jest-dom';

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.

  1. Anatomy of a test file: 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 beforeEach to beforeAll. beforeAll creates 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 describe but 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.

  1. 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.

  1. toBe vs. toEqual: the difference that causes the most bugs

const 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 object
  • toBe uses Object.is, comparison by identity. For primitives — strings, numbers, booleans — identity and content coincide, so expect('activa').toBe('activa') works. For objects and arrays, it compares references.
  • toEqual walks 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' });  // ❌ FAILS

toEqual 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.

  1. The validateBooking suite, rule by rule

Here'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 against Date.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 to 2026-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.

  1. Edge cases and test.each

Edge 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: %p prints the value as-is (useful for telling '' apart from 'two'), %s converts it to a string, %i to 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 valid

With 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.

  1. Testing the bookingsSlice reducer as a pure function

This 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:

  1. The exact shape of the state after the action, including submitState and error, which only show up indirectly in the UI.
  2. That prepare generates the identifier in the agreed format, res- plus eight characters. That's why crypto.randomUUID gets spied on: without pinning it, the identifier would differ on every run and there'd be nothing to compare against.
  3. 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 of createSlice and 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)"]

  1. 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.

  1. 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.

  1. vi.fn(): checking that a callback was called

vi.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.

  1. vi.spyOn and vi.mock: substituting without breaking things

vi.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:

  1. vi.mock gets hoisted to the top of the file. Even if you write it after the imports, it runs before them. That's why the factory can't close over external variables: the vi.fn()s get declared directly inside it. If you need a reference outside, use vi.hoisted.
  2. The mocked module replaces every one of its exports. If monitoring.js exported ten functions and you only declare two in the factory, the other eight become undefined. To keep the rest, combine it with importActual:
vi.mock('./monitoring.js', async (importOriginal) => {
  const original = await importOriginal();
  return { ...original, reportError: vi.fn() };   // only one gets replaced
});
  1. vi.mock is the biggest hammer in the box. It replaces the real module with a copy that doesn't evolve with it: if reportError changes 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.spyOn on a specific method > vi.mock the entire module. And for the network, none of the three: MSW (09-04).

  1. Controlling time: vi.useFakeTimers

Time 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.

  1. Running, filtering, and reading coverage

Watch mode

npm test          # vitest, 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 test

About 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

npm run coverage
 % 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 each if. In monitoring.js, that 50% points to the production branch never being exercised.
  • Uncovered Line #s is the to-do list. Lines 47 and 63 of bookingsSlice.js are, 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.jsx is 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.html paints 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 toBe with objects and arrays. The number-one beginner mistake. expect({ a: 1 }).toBe({ a: 1 }) always fails. Rule: primitives with toBe, structures with toEqual, and deliberate identity with toBe only when the reference is the behavior under test (memoization, a reducer that must not copy).
  • Sharing state between tests with beforeAll or a mutable constant. Produces tests that pass alone and fail in the suite, or the other way around. beforeEach rebuilds; beforeAll only for what's expensive and immutable.
  • Forgetting vi.useRealTimers() in the afterEach. 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 for vi.restoreAllMocks() and console.error.
  • Leaving a test.only in 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.push got 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.mock of 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:

export function cx(...names) {
  return names.filter(Boolean).join(' ');
}

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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved