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
- The real cost of refactoring without tests
- What is an automated test: Arrange, Act, Assert
- The three things a test gives you
- Test types: static, unit, integration, and end-to-end
- The testing pyramid vs. the testing trophy
- The module's guiding principle: behavior, not implementation
- What's worth testing in CicloUrbano — and what isn't
- False friends: coverage, brittle tests, and flaky tests
- How to name a test
- Setting up the environment: Vitest, jsdom, and Testing Library
- The first test: confirming the environment is alive
- The Module 9 strategy
- 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.
- 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.findgot called; it checks what the function returns. This is the principle from section 6, in miniature.
- 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.
- 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-hookstoday catches an entire family of bugs that in 2018 required tests: auseEffectwith incomplete dependencies, a hook inside anif. 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 rendersStatusBadgeinside 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.
- 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:
- Integration tests have stopped being expensive.
jsdombuilds 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. - 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.
- 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.
- 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 selectedType → activeType |
Fails (false positive) | Passes |
useState → useReducer |
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.activeis 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.
- 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.
- 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.
- 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, andexpectaren't translated — they're API. describeblocks 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
describewhen tests share a circumstance:describe('when the bike is in maintenance', …).
- 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:
environment: 'jsdom'is what letsrender(<BikeCard />)have adocumentto render into. Without it, the default environment isnode, and any DOM access throwsdocument is not defined. For testing pure functions only,nodeis faster; it can be set per file with a// @vitest-environment nodecomment.globals: truemakesdescribe,test,expect,beforeEach, andviavailable without importing them. It's what gives code compatibility with Jest and what lets@testing-library/jest-domhook in. Without this option, everything has to be imported fromvitestin every file.setupFilespoints 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.coverageexcludes what doesn't make sense to measure: the test utilities themselves, themain.jsxentry point (which only callscreateRoot), and.testfiles.
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.
- 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, becausedocumentexists; and that thejest-dommatchers are registered, becausetoBeInTheDocumentandtoHaveTextContentaren'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 412msWith 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.
- 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:
- 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 thedescribeshould carry that name, not thetest. - Assertion on a CSS Modules class.
.card_a3f9xis a generated identifier: it changes with every build and every Vite version. Also, the mere existence of adivwith that class isn't a behavior the user perceives. - 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. - Several unrelated checks in one test, and no
screen. It usescontainer.querySelectorandinnerHTMLinstead 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:
- 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). - Shared state between tests. A
QueryClientreused 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 withretry: false. - The network isn't intercepted. If the test hits the real
json-server, it depends on the process being up, its latency, anddb.jsonnot 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
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
