The previous module closed with an uncomfortable confession: nine refactors performed on working code — the signature of BikeList, the state moved around, the value of two context providers rewritten, the routes split into nine chunks, a lazy-loading layer with its own network failures — and a single check across all of them: open the browser and look. It worked because the project is small and because the person touching the code remembered what to check. Neither of those two conditions survives growth. This module builds what was missing: an automated safety net that runs in seconds, checks what a human can't be expected to remember, and fails the moment behavior changes. This first lesson doesn't write any application tests yet: it sets the judgment. What a test is, what types exist, which ones pay off in a React interface, what's worth testing in CicloUrbano and what isn't, which metrics lie to you, and how to get the environment ready so the next four lessons can write test code from the first line.

Contents

  1. The real cost of refactoring without tests
  2. What is an automated test: Arrange, Act, Assert
  3. The three things a test gives you
  4. Test types: static, unit, integration, and end-to-end
  5. The testing pyramid vs. the testing trophy
  6. The module's guiding principle: behavior, not implementation
  7. What's worth testing in CicloUrbano — and what isn't
  8. False friends: coverage, brittle tests, and flaky tests
  9. How to name a test
  10. Setting up the environment: Vitest, jsdom, and Testing Library
  11. The first test: confirming the environment is alive
  12. The Module 9 strategy

  1. The real cost of refactoring without tests

Think back to the end of Module 8, and to what actually happened in each of those nine refactors. In every one of them, there was a moment right after saving the file when the state of the project was unknown. It got resolved by looking at the screen. Looking at the screen checks, at most, what's on screen at that instant: the catalogue with its five bikes and the type filter. It doesn't check what was on the other tab, the case of a zero-hour booking, what happens when json-server returns a 500, or whether the "Book" button is still disabled for bici-003, which is in maintenance.

This can be stated precisely. Whenever you touch code, the question is always the same:

What behaviors could this change have broken, and how do I know I haven't broken them?

Without tests, the answer to the second half is "I don't know," and the team replaces it with two strategies, both bad:

  • Don't touch the code. The file nobody dares to modify ends up being the one that needs it most. Technical debt isn't bad code: it's bad code that can't be changed.
  • Touch it and hope. The bug reaches the user, who reports it days later, by which point nobody remembers the change that caused it.

An automated test turns "I don't know" into "I'll check in four seconds." That's the difference, and it's a difference of degree so large it changes how code gets written: with a safety net, you refactor often and in small steps; without one, changes pile up and get rewritten all at once.

flowchart LR
    A["Change to the code"] --> B{"Are there tests?"}
    B -- No --> C["Partial manual check"]
    C --> D["Regression goes undetected"]
    D --> E["User reports the bug"]
    E --> F["Debugging with no context: days later"]
    B -- Yes --> G["npm test · seconds"]
    G --> H{"Does anything fail?"}
    H -- Yes --> I["Fixed while the change is still fresh"]
    H -- No --> J["Merged with confidence"]

The horizontal axis of that diagram is time, and that's where the savings are: the cost of fixing a bug grows with the distance between the moment it's introduced and the moment it's caught.

  1. What is an automated test: Arrange, Act, Assert

An automated test is, with no magic involved, a function that runs application code and throws an error if the result isn't what's expected. Nothing more. The test runner is in charge of finding those functions, calling them, catching the errors, and presenting a report.

Every test, whatever its type, has three parts. They're universally known as Arrange, Act, Assert (AAA).

Part What it does Example in CicloUrbano
Arrange Builds the scenario: input data, initial state, mocked dependencies A catalogue with bici-001 available and bici-003 in maintenance
Act Runs exactly one action: call the function, click the button, submit the form Call validateBooking({ bicicletaId: 'bici-003', … }, catalogue)
Assert States what should have happened The result contains an error on bicicletaId with the text "is not available"
// Structure of any test, with the three parts marked
test('rejects a bike in maintenance', () => {
  // 1. ARRANGE
  const bikes = [{ id: 'bici-003', model: 'Cargo Max', status: 'mantenimiento' }];
  const data = { bicicletaId: 'bici-003', startDate: '2030-01-01T10:00', hours: 2, terms: true };

  // 2. ACT
  const errors = validateBooking(data, bikes);

  // 3. ASSERT
  expect(errors.bicicletaId).toContain('is not available');
});

Three rules that follow directly from this structure, worth internalizing before writing anything:

  • One test, one action. If the Act block does three things, when the test fails you won't know which of the three broke it. Split it up.
  • Arrange must be explicit and local. Data that comes from another file, a previous test, or shared state turns a failure into a mystery. Each test builds its own scenario.
  • Assert on the result, not the steps. The test above doesn't check that bikes.find got called; it checks what the function returns. This is the principle from section 6, in miniature.

  1. The three things a test gives you

It's worth separating the three benefits, because each one justifies a different type of test and explains decisions made later on.

3.1. It lets you refactor without fear

This is the main benefit, and the one that motivates this module. A refactor, by definition, changes the implementation without changing the behavior. If you have a test suite that describes behavior rather than implementation, refactoring becomes a mechanical procedure: you change the code, run the tests, and if they all pass, the refactor is correct.

Applied to what you already did: if BikeList had had a test saying "given five bikes, render five cards, and clicking 'Book' on the first one notifies with bici-001," the signature change and the introduction of useCallback would have been verified on the spot. And if memo had stopped propagating a field, the test would have failed.

The catch is in the fine print: it only works if the test doesn't depend on the implementation. A test that checks internal state names or render counts breaks with every refactor, and then the safety net turns into a burden. Hence the emphasis in section 6.

3.2. It's executable documentation

A well-written test file answers the question "what does this actually do?" better than any comment, for a simple reason: comments lie over time, and tests don't, because if they lie, they fail.

✓ validateBooking
  ✓ returns an empty object when all the data is valid
  ✓ requires choosing a bike
  ✓ rejects a bike that doesn't exist in the catalogue
  ✓ rejects a bike in maintenance
  ✓ rejects a start date earlier than now
  ✓ rejects 0 hours and accepts 1
  ✓ rejects 25 hours and accepts 24
  ✓ requires accepting the terms

That output is the specification of CicloUrbano's booking business rules, generated by the code itself. Anyone new to the team can read it in thirty seconds.

3.3. It catches regressions before the user does

A regression is behavior that used to work and no longer does. It's the most expensive kind of bug, because nobody's looking for it: it's found by accident, usually in production, and usually in the part of the app nobody was working on.

Tests reverse that dynamic: every behavior tested once stays guarded forever, at no marginal cost. It's the only way an application can grow without its risk surface growing along with it.

  1. Test types: static, unit, integration, and end-to-end

Not all tests cost the same or give the same value. This table is the map for the entire module, and it's worth returning to at the start of each lesson.

Type What it tests Speed Maintenance cost Confidence it gives What it catches that the others don't
Static (ESLint, TypeScript) The code without running it: syntax, types, rules Instant (as you type) Very low Low, but free Typos, unused variables, badly called hooks, non-existent props
Unit A single function or an isolated module Very high (milliseconds) Low if the module is pure Low: the unit works, but the whole system is unverified Logic errors and edge cases hard to reproduce through the UI
Integration Several pieces together: a component with its children, state, and providers High (tens to hundreds of ms) Medium High: it's how the app is actually used Broken wiring between pieces that work fine on their own
End-to-end (e2e) The whole application in a real browser Low (seconds per test) High Maximum Real routing failures, CSS that hides a button, sessions, real network, multiple chained screens

A few clarifications that often get missed:

  • Static tests are tests. ESLint with eslint-plugin-react-hooks today catches an entire family of bugs that in 2018 required tests: a useEffect with incomplete dependencies, a hook inside an if. Configuring it well is the most profitable investment in the project, and it's a prerequisite, not an alternative.
  • The line between unit and integration is blurry, and it doesn't matter. When you test BikeCard, which renders StatusBadge inside it, is that unit or integration? It's integration, and that's correct. Arguing about the label is wasted time; what matters is the criterion in the next section.
  • Confidence isn't proportional to the number of tests, but to their type. A thousand unit tests for helper functions don't guarantee the app starts up. One e2e test that books a bike does.

  1. The testing pyramid vs. the testing trophy

For two decades, the dominant model was the testing pyramid (Mike Cohn, 2009): lots of unit tests at the base, some integration tests in the middle, very few end-to-end tests at the top. The justification was economic: unit tests were cheap and fast; integration tests were slow and brittle.

flowchart TB
    subgraph PYRAMID["Classic pyramid"]
        direction TB
        P3["E2E · very few"]
        P2["Integration · some"]
        P1["Unit · a huge number"]
        P3 --- P2 --- P1
    end
    subgraph TROPHY["Testing trophy (modern front-end)"]
        direction TB
        T4["E2E · few, critical flows only"]
        T3["Integration · THE MAJORITY"]
        T2["Unit · just enough: pure logic and edge cases"]
        T1["Static · linting and types, free and continuous"]
        T4 --- T3 --- T2 --- T1
    end

Kent C. Dodds proposed the testing trophy for today's front-end, and the change in shape reflects three concrete facts:

  1. Integration tests have stopped being expensive. jsdom builds a full in-memory DOM in milliseconds, and Testing Library lets you interact with it the way a person would. What required spinning up a browser in 2010 costs 50ms today.
  2. Static analysis has absorbed much of what unit tests used to cover. A mistyped value or a badly called hook no longer needs a test: the linter and the type checker catch it first.
  3. In a UI, nearly every real bug is in the wiring, not in the units. The component works, the reducer works, the hook works… and the screen is empty because the selector returns undefined. Unit tests for each piece pass, all three of them. The integration test fails — which is exactly what you want.

From this comes the operating rule for this module, the one that will be applied to CicloUrbano:

Most of the weight falls on component integration tests. Unit tests are reserved for pure logic with edge cases — validation, reducers, selectors, utilities — and end-to-end tests for three or four critical business flows.

  1. The module's guiding principle: behavior, not implementation

If you take away just one idea from this module, make it this one:

Test what the user sees and does, not how you wrote it on the inside.

The practical way to tell whether a test respects the principle is the refactor test: if you can rewrite the inside of the component without changing anything the user perceives, and the test fails, the test is wrong.

Look at it through two versions of the same check on TypeSelector. Only the contrast matters here; the mechanics of render and screen are covered in 09-03.

// ❌ BAD: checks the implementation
test('updates the selectedType state when Electric is clicked', () => {
  const component = mountAndSpyOnState(<TypeSelector />);
  component.click('Electric');
  // Asserts on the NAME of an internal variable
  expect(component.state.selectedType).toBe('electrica');
});
// ✅ GOOD: checks the observable behavior
test('marks the clicked filter as active and notifies the chosen type', async () => {
  const user = userEvent.setup();
  const onTypeChange = vi.fn();
  render(<TypeSelector type="todos" onTypeChange={onTypeChange} />);

  await user.click(screen.getByRole('button', { name: 'Electric' }));

  expect(onTypeChange).toHaveBeenCalledWith('electrica');
});

Now apply a perfectly legitimate change: rename selectedType to activeType, or replace useState with useReducer, or — as actually happened in Module 7 — lift that state to Redux and pass the type in as a prop.

Change to the implementation The "BAD" test The "GOOD" test
Renaming selectedTypeactiveType Fails (false positive) Passes
useStateuseReducer Fails Passes
State lifted to Redux / to the URL Fails Passes
The button stops notifying the parent Passes (a real bug goes undetected!) Fails

That last row says it all: the implementation-coupled test doesn't just break when it shouldn't, it also lets the bug that actually matters slip through. It fails when it shouldn't and stays silent when it should fail. It's worse than having no test, because it also burns maintenance time.

The list of things that are never tested, which will come back in 09-03 with examples:

  • The internal state of a component and the names of its variables.
  • CSS class names (styles.active is a hash generated by CSS Modules).
  • How many times a component has rendered (that's what the Profiler from 08-05 is for — a diagnostic tool, not a verification one).
  • That a particular hook or a particular library method was called.
  • Private methods and non-exported functions: if they deserve testing, they deserve to be exported.

  1. What's worth testing in CicloUrbano — and what isn't

Testing everything is impossible, and testing at random is pointless. The criterion that works combines two axes: how much it hurts if it breaks and how much the test costs. Applied to the code that already exists in the project:

Type of CicloUrbano code Concrete examples Tested? With what type of test Lesson
Pure utilities validateBooking, availability.js, cx Yes, thoroughly Unit, with every edge case 09-02
Reducers and selectors bookingsSlice, catalogueSlice, createSelector Yes, thoroughly Unit: they're pure functions, they don't need React 09-02
Presentational components StatusBadge, BikeCard, Panel Yes, just enough Light integration: what it renders and what it notifies 09-03
Forms with rules BookingForm Yes, priority Integration: fill in, submit, see errors 09-03
Custom hooks useToggle, useDebounce, useLocalStorage Yes, if they have their own logic Unit, with renderHook 09-04
Pages with server data CataloguePage, StationDetailPage Yes, all three states Integration with the mocked network (MSW) 09-04
Mutations useCreateBooking and its invalidation Yes Integration with MSW 09-04
Complete business flows Signing in · booking · cancelling Yes, only those three End-to-end with Cypress 09-05
Styles and layout CSS Modules, :root variables, dark theme Not with code tests Visual review; at most, visual regression
Third-party code React Router, TanStack Query, Redux Toolkit No Already tested by their authors
Logic-free wrappers Panel, if it just renders children inside a <section> Not worth it
Constants and mock data data/domain.js No Testing a constant is tautological

Two more tie-breaking criteria for when you're unsure:

  • Has it ever failed? Every real bug that reaches production deserves a test that reproduces it before it gets fixed. That test is what guarantees it won't come back.
  • Is it on the money path? In CicloUrbano, creating a booking is the money path. Switching to dark mode isn't. The testing budget is allocated accordingly.

  1. False friends: coverage, brittle tests, and flaky tests

8.1. Coverage as a metric

Code coverage measures what percentage of the code ran during the tests, usually broken down into four dimensions:

Metric What it measures
Lines Percentage of lines executed
Statements Same, but per statement; differs when there are several on one line
Branches Percentage of if, ?:, &&, switch paths taken. The most informative one
Functions Percentage of functions called at least once

What it's actually good for: finding what you haven't tested. You open the report, see that the 500-error branch of CataloguePage is red, and decide whether it deserves a test. That use is excellent.

What it's not good for: as a target. This test gives 100% coverage of validateBooking and checks absolutely nothing:

// 100% coverage, zero value
test('validateBooking does not blow up', () => {
  validateBooking({ bicicletaId: 'bici-001', startDate: '2030-01-01T10:00', hours: 2, terms: true }, []);
});

It runs the function, walks through lines… and doesn't have a single assertion. Coverage measures execution, not verification. That's why 100% isn't the goal: chasing it pushes you toward writing filler tests for trivial wrappers and testing code that doesn't deserve it, and the time comes out of the budget for the tests that actually matter. A reasonable range for a healthy project sits between 70% and 85%, with one condition: that percentage has to include the business logic. A 90% that leaves validateBooking uncovered is worth less than a 60% that covers it completely.

8.2. Brittle tests

A brittle test is one that fails when changes that break nothing are made. It's exactly the one from section 6, and its usual causes are always the same:

  • Assertions on internal details (state, CSS classes, exact DOM structure).
  • Selectors coupled to markup: "the third <div> inside the second <section>."
  • Full literal text, punctuation included, that breaks when you fix a typo.
  • Dependence on test order: test B only passes if A ran first.

The telltale symptom: every pull request needs to "fix the tests." Once that happens, the team stops trusting failures, and a test nobody believes in no longer protects anything.

8.3. Flaky tests

A flaky test is one that passes or fails against the same code, with nothing changed. It's the worst of the failure modes, because it destroys the one property that makes a suite useful: that a failure means something.

Common cause How it shows up Correct fix
Waiting a fixed amount of time (setTimeout(500)) Fails on a slow machine or in CI Wait for what's expected to appear, with findBy* / waitFor (09-04)
Shared state between tests Only fails when the whole suite runs, not alone Isolate: a fresh cache, a fresh store, localStorage cleared in every test
Dependence on the real clock Fails at midnight or when the hour changes Fake timers and fixed dates (09-02)
Dependence on execution order Fails when running in parallel Each test builds its full scenario
Real network in a test Fails when the API is slow or down Intercept the network with MSW (09-04)

And the most important rule about them: a flaky test doesn't get retried — it gets fixed or deleted. Marking it "retry three times" hides a real isolation bug that, sooner or later, will become an application bug.

  1. How to name a test

A test's name is the only thing you see when it fails in a CI log at three in the morning. It has to be enough to understand what broke, without opening the file.

The formula that works best describes the expected behavior, not the mechanism:

<subject> + what it does + under what circumstance

❌ Poor name ✅ Useful name
test('works') test('returns an empty object when all the data is valid')
test('validateBooking 2') test('rejects a 25-hour booking because it exceeds the maximum')
test('render') test('shows the Book button disabled when the bike is in maintenance')
test('setState') test('notifies the parent with the chosen type when a filter is clicked')

Project conventions, so the five lessons stay consistent:

  • Names are written in English, like all text in the project; describe, test, and expect aren't translated — they're API.
  • describe blocks group by subject: the module or component name, as-is (describe('validateBooking', …), describe('BikeCard', …)).
  • No "should": test('rejects…') in the present tense. It's shorter and reads better in the report.
  • A nested describe when tests share a circumstance: describe('when the bike is in maintenance', …).

  1. Setting up the environment: Vitest, jsdom, and Testing Library

CicloUrbano is built with Vite, and the natural test runner for a Vite project is Vitest: it shares the same configuration, the same module resolution system, and the same transforms, so there's no second build pipeline to maintain. And — this matters for the next lesson — it implements the same API as Jest, the ecosystem's standard.

10.1. Installation

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
Package What it's for
vitest The runner: finds test files, runs them, and produces the report
jsdom A JavaScript implementation of the DOM: provides a document and a window outside the browser
@testing-library/react render, screen, and the queries for testing components the way a person uses them
@testing-library/jest-dom DOM-specific matchers: toBeInTheDocument, toBeDisabled, toHaveValue
@testing-library/user-event Simulates real interactions: clicking, typing, tabbing

Everything goes in devDependencies (-D): none of it ships in the deployed bundle.

10.2. Configuration in vite.config.js

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],

  test: {
    // 1) Environment: an in-memory DOM, because we're testing components
    environment: 'jsdom',

    // 2) describe / test / expect available without importing them, like in Jest
    globals: true,

    // 3) File that runs ONCE before each test file
    setupFiles: './src/tests/setup.js',

    // 4) What gets included in the coverage report
    coverage: {
      reporter: ['text', 'html'],
      include: ['src/**/*.{js,jsx}'],
      exclude: ['src/tests/**', 'src/main.jsx', 'src/**/*.test.{js,jsx}']
    }
  }
});

Point by point:

  1. environment: 'jsdom' is what lets render(<BikeCard />) have a document to render into. Without it, the default environment is node, and any DOM access throws document is not defined. For testing pure functions only, node is faster; it can be set per file with a // @vitest-environment node comment.
  2. globals: true makes describe, test, expect, beforeEach, and vi available without importing them. It's what gives code compatibility with Jest and what lets @testing-library/jest-dom hook in. Without this option, everything has to be imported from vitest in every file.
  3. setupFiles points to the shared setup file, covered in the next section. That's where the matchers get registered, and, in 09-04, where MSW's mock server will start.
  4. coverage excludes what doesn't make sense to measure: the test utilities themselves, the main.jsx entry point (which only calls createRoot), and .test files.

10.3. The setup file: src/tests/setup.js

// src/tests/setup.js
// Runs before EVERY test file (setupFiles in vite.config.js)

// 1) Registers the DOM matchers: toBeInTheDocument, toBeDisabled, toHaveValue…
import '@testing-library/jest-dom/vitest';

// 2) Global conveniences for CicloUrbano's test environment
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

// Testing Library cleans up on its own when `globals: true`, but making it
// explicit documents the intent and guards against a future config change.
afterEach(() => {
  cleanup();
  localStorage.clear();   // isolates useLocalStorage tests
});

The import is @testing-library/jest-dom/vitest, with the suffix: that variant calls expect.extend on Vitest's expect. If you import plain @testing-library/jest-dom in Vitest, the matchers may not get registered and toBeInTheDocument will show up as "not a function."

localStorage.clear() in the afterEach isn't decoration: useLocalStorage and ThemeProvider write there, and without cleanup one test would inherit the dark theme left behind by the previous one. It's a direct application of the prevention described in 8.3.

10.4. Module file structure

Test files sit next to the code they test, with shared utilities in a folder of their own:

src/
├── components/
│   ├── BikeCard.jsx
│   ├── BikeCard.test.jsx              ← next to the component
│   ├── BookingForm.jsx
│   └── BookingForm.test.jsx
├── utils/
│   ├── validateBooking.js
│   └── validateBooking.test.js
├── hooks/
│   ├── useDebounce.js
│   └── useDebounce.test.js
└── tests/                              ← shared utilities, no tests of their own
    ├── setup.js                        (10.3, and MSW in 09-04)
    ├── utils.jsx                       (the `renderWithProviders` helper, 09-03)
    ├── handlers.js                     (MSW handlers, 09-04)
    └── server.js                       (MSW's setupServer, 09-04)

Placing the test next to the code, instead of in a distant __tests__ folder, has three measurable advantages: you can see at a glance what's tested and what isn't, the import path is './BikeCard.jsx' instead of '../../components/BikeCard.jsx', and when a component gets moved or deleted, its test travels or disappears with it.

10.5. The package.json scripts

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "api": "json-server --watch db.json --port 3001",
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "coverage": "vitest run --coverage"
  }
}
Script What it does When to use it
npm test Watch mode: keeps listening and reruns only what's affected on save While coding. It's Vitest's default mode
npm run test:run A single pass, exits with code 0 or 1 Continuous integration and pre-commit hooks
npm run test:ui A web UI with the test tree, the DOM, and timings Debugging a failure that doesn't make sense from the console
npm run coverage Generates the report in the console and in coverage/index.html Every so often, to look for gaps (section 8.1)

test:ui requires npm install -D @vitest/ui, and coverage requires npm install -D @vitest/coverage-v8. Vitest will prompt for it in the console the first time you run the script.

  1. The first test: confirming the environment is alive

Before writing a single test for the application, it's worth confirming the pipeline works. This test doesn't check CicloUrbano: it checks Vitest, jsdom, and the matchers.

// src/tests/environment.test.js
import { describe, test, expect } from 'vitest';

describe('test environment', () => {
  test('the test runner works', () => {
    expect(2 + 2).toBe(4);
  });

  test('jsdom provides an in-memory DOM', () => {
    // If `environment` weren't 'jsdom', this line would throw "document is not defined"
    document.body.innerHTML = '<h1>CicloUrbano</h1>';

    expect(document.querySelector('h1')).toBeInTheDocument();
    expect(document.querySelector('h1')).toHaveTextContent('CicloUrbano');
  });
});

What each one verifies:

  • The first confirms Vitest finds the file, runs it, and evaluates an assertion. If this one fails, the problem is in the configuration, not in your code.
  • The second checks two things at once: that environment: 'jsdom' is active, because document exists; and that the jest-dom matchers are registered, because toBeInTheDocument and toHaveTextContent aren't from Vitest — they come from the setup file.
$ npm test

 ✓ src/tests/environment.test.js (2)
   ✓ test environment (2)
     ✓ the test runner works
     ✓ jsdom provides an in-memory DOM

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  09:14:32
   Duration  412ms

With that output on screen, the environment is ready. This test can be deleted later — it doesn't add anything long-term — but it's a good first step, because it separates "my test is wrong" from "my configuration is wrong," which are two very different problems that get debugged very differently.

  1. The Module 9 strategy

This is the plan, and it answers, point by point, the gap left by Module 8:

Lesson What it adds Which CicloUrbano code
09-01 (this one) Criteria, test types, working environment The configuration
09-02 The runner, assertions, and test doubles; pure functions, no React validateBooking, bookingsSlice and its selectors, monitoring.js
09-03 Components with Testing Library: queries, interaction, providers StatusBadge, BikeCard, TypeSelector, BookingForm
09-04 Async code, timers, mocked network with MSW, and custom hooks CataloguePage, useCreateBooking, useDebounce, useLocalStorage, ErrorBoundary
09-05 End-to-end in a real browser with Cypress, and continuous integration The three critical flows and the full workflow

And in Module 11, lesson 11-04 will apply all of this to the final project. Here you learn the technique; there you run it against a complete application.

Common Mistakes and Tips

  • Writing tests just to bump up coverage. This is the mistake that costs the most time and delivers the least. Coverage is a map for finding gaps, not a grade. A 72% that covers the business logic is worth more than a 95% padded with wrapper tests.
  • Testing internal state "because it's easier." It usually is, and that's exactly why you should resist it: an easy test that breaks with every refactor has negative value. If testing the behavior is expensive, that's almost always a sign the component is doing too much — and that signal is useful.
  • Starting with end-to-end tests. They give the most confidence and cost the most. Starting there produces a slow, flaky suite that the team eventually disables. Start with pure logic and component integration; e2e comes last, and only for critical flows.
  • Confusing "there are no tests" with "everything needs testing right now." In an existing project, the strategy that works is: test every bug before fixing it, test every new feature, and test the areas you touch. Coverage grows wherever the project moves, which is exactly where it's needed.
  • Leaving tests commented out or with a permanent test.skip. A disabled test is a stored lie: it looks like there's a safety net where there isn't one. Fix it or delete it — Git history keeps what got deleted.
  • Not running tests in continuous integration. A suite that only runs when someone remembers to protects nothing. In 09-05 you'll see the minimal GitHub Actions workflow that runs them on every change.
  • Tip: install first, write later. Get the environment from section 10 working with the trivial test from section 11 before trying to test anything real. Debugging a configuration and a test at the same time triples the effort.
  • Tip: when a test fails, read it like a bug report. If the name and the message don't tell you what broke, the test is the problem. Improve it right then, while the context is still in your head.

Exercises

Exercise 1. Classify each of these CicloUrbano behaviors by the type of test it corresponds to (static, unit, integration, or end-to-end) and justify why the level immediately below doesn't fit:

a) validateBooking rejects a 25-hour booking. b) BookingForm shows the message "The maximum booking is 24 hours" next to the duration field when 25 is entered and the form is submitted. c) Ana Ribera signs in at /acceso, books bici-001, and sees the booking at /reservas. d) The useEffect in BikeSearch declares onSearch in its dependencies. e) selectVisibleBikes returns the urban bikes sorted by price when state has type: 'urbana' and sortOrder: 'price'.

Exercise 2. A teammate presents this test for BikeCard and claims it's reassuring because it covers 100% of the component. Identify four distinct problems and rewrite the test descriptions that should replace it (just the names, not the implementation).

test('BikeCard', () => {
  const { container } = render(
    <BikeCard bike={{ id: 'bici-001', model: 'Classic Urban', status: 'disponible', pricePerHour: 2.5 }} />
  );
  expect(container.querySelector('.card_a3f9x')).toBeTruthy();
  expect(container.querySelectorAll('button').length).toBe(2);
  expect(container.innerHTML).toContain('Classic Urban');
});

Exercise 3. The CicloUrbano team has a test that fails roughly one run in ten in continuous integration and always passes locally. It checks that, after creating a booking, the "Booking created" notice appears. A team member suggests wrapping it in retry(3). Explain why that's a bad idea, list three possible causes of the flakiness given the project's tooling, and describe what you would check first and why.

Solutions

Solution 1.

Case Type Why not the level below
a Unit It's pure logic with no DOM or React. Going lower would leave only static analysis, which can't know the maximum is 24 hours: that's a business rule, not a type
b Integration A unit test of validateBooking confirms the error is generated, but not that the form displays it or attaches it to the right field. The typical bug here is wiring: the error exists and isn't rendered because touched wasn't updated
c End-to-end It chains three screens, real navigation, session, and persistence. An integration test of each page would pass even if the redirect after sign-in led to the wrong route
d Static eslint-plugin-react-hooks catches this on save, for free and without running anything. Writing a test for this would be throwing money away
e Unit A selector is a pure function of (state) => result. Mounting components to verify it adds cost, noise in diagnosis, and no extra confidence

Solution 2. The four problems:

  1. The name doesn't describe anything. test('BikeCard') doesn't say what behavior is being verified, so its failure in CI carries no information. And the describe should carry that name, not the test.
  2. Assertion on a CSS Modules class. .card_a3f9x is a generated identifier: it changes with every build and every Vite version. Also, the mere existence of a div with that class isn't a behavior the user perceives.
  3. Counting buttons with querySelectorAll. This checks DOM structure, not function. If a legitimate third button is added, the test fails even though nothing is broken; and if the "Book" button stops being disabled during maintenance — the real bug that matters — the test keeps passing.
  4. Several unrelated checks in one test, and no screen. It uses container.querySelector and innerHTML instead of accessible queries, and when it fails, all you know is that "BikeCard" failed, not which of the three things broke.

The descriptions that should replace it:

describe('BikeCard')
  test('shows the model, station, and price per hour')
  test('lets you book an available bike and notifies with its id')
  test('disables the Book button when the bike is in maintenance')
  test('notifies with the id when "View details" is clicked')

Solution 3. Why retry(3) is a bad idea: it fixes nothing, it hides the symptom, and, above all, it disables the signal. If the test fails one time in ten because of a real race condition, that same race condition affects users; papering over it with retries turns a detected production bug into an invisible one. It also poisons the team's judgment: once "sometimes they fail" is accepted, nobody looks twice at a red test again.

Three possible causes given the project's tooling:

  1. Waiting on a fixed delay instead of waiting on a result. If the test uses a fixed delay after submitting the form, on the slowest CI machine the mutation hasn't resolved yet by the time the notice is checked. Fixed by waiting for the notice with findByText, which retries until it appears (09-04).
  2. Shared state between tests. A QueryClient reused across files caches an earlier test's booking list; depending on execution order, the list already contains the booking and the notice never fires. Fixed by creating a fresh client per test with retry: false.
  3. The network isn't intercepted. If the test hits the real json-server, it depends on the process being up, its latency, and db.json not having been modified by another test. Fixed with MSW.

What I'd check first: run the file on its own, then inside the full suite. It's the cheapest diagnostic, and it splits the two families of causes at once. If it passes alone and fails in the suite, the problem is isolation (causes 2 and 3). If it fails intermittently in both cases, the problem is waiting (cause 1). Without that split, any fix is a shot in the dark.

Conclusion

This lesson put judgment before tooling, which is the right order: without judgment, a test suite turns into a burden you have to "fix" on every pull request.

The essentials to take away. An automated test is nothing more than a function with three parts — Arrange, Act, Assert — and it delivers three distinct things: it lets you refactor without fear (exactly the gap left by the nine refactors in Module 8), it works as executable documentation that can't lie without failing, and it catches regressions before a user reports them. There are four levels — static, unit, integration, and end-to-end — and each catches bugs the others miss: the linter catches the badly declared hook, the unit test catches the 25-hour edge case, the integration test catches broken wiring between pieces that work fine on their own, and e2e catches the CSS that hides the Book button. The balance of weight is no longer the classic pyramid but the trophy: in modern front-end work, most tests are integration tests, because jsdom has made them cheap, because static analysis has absorbed much of what unit tests used to cover, and because that's where the real bugs live.

Above all stands the module's guiding principle: test the visible behavior, not the implementation. The test that asserts on selectedType fails when a variable gets renamed and stays silent when the component stops notifying its parent — it fails when it shouldn't and stays quiet when it should fail; the one that asserts onTypeChange receives 'electrica' survives useState, useReducer, and lifting the state to Redux, and only breaks when something is actually broken. That's where the list of things never tested comes from: internal state, CSS Modules class names, render counts, and third-party library internals. And the CicloUrbano allocation: thoroughly for the pure utilities, the reducers, and the selectors; with integration for the components and the form; with MSW for the pages with data; with Cypress for only three critical flows; and nothing for styles, constants, and third-party code.

You also know the false friends now. Coverage is a map for finding gaps, not a target: it measures execution, not verification, and a test with not a single assertion can still hit 100%. Brittle tests fail on harmless changes and erode the suite's credibility. Flaky tests are worse, because they destroy what a red result means, and they're never retried: they get fixed or deleted. And you know how to name a test so its failure explains itself, with the formula subject + what it does + under what circumstance.

Lastly, the environment is set up and verified: Vitest with environment: 'jsdom', globals: true, and setupFiles: './src/tests/setup.js'; the @testing-library/jest-dom/vitest matchers registered; localStorage cleared between tests; .test.jsx files sitting next to the code, with shared utilities in src/tests/; and the npm test, npm run test:ui, and npm run coverage scripts. The trivial test in environment.test.js confirms in 400ms that the whole pipeline works, which is what separates "my test is wrong" from "my configuration is wrong."

With judgment set and the environment alive, it's time to write real tests. The next lesson deliberately stays outside React: the runner from the inside, the anatomy of a test file with describe and its hooks, the catalogue of assertions — starting with the difference between toBe and toEqual, which causes more confusion than any other — test doubles with vi.fn() and vi.mock, and controlling time with fake timers. All of it applied to validateBooking and the bookingsSlice reducer, finally closing what was promised back in 05-05 and 07-04: that a pure reducer is tested by simply calling it. And as the syllabus dictates, it will start from the Jest model, with the equivalence table that lets you carry what you've learned to any project. The next lesson is Unit Testing with Jest.

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