Every test in the previous lesson was synchronous: you rendered a component with its props, clicked a button, and checked the result in the very same turn of the event loop. The real app doesn't work that way. CataloguePage asks http://localhost:3001/bicicletas for the bikes and paints a skeleton while it waits; useCreateBooking sends a POST and then invalidates two queries; useDebounce delays 400 ms; ErrorBoundary catches a failure that happens after the first render. The moment time enters the equation, a test written like the previous ones fails — or, worse, passes by accident.

This lesson covers the ground where most front-end tests break. First, Testing Library's waiting tools and the act(...) warning, which almost everyone silences without understanding it. Then, MSW, which intercepts the network at the protocol level and lets you trigger a 500 error, an empty list, or a slow response at will, to check any screen's three states: loading, success, and error. And finally custom hooks with renderHook, including the tricky case of combining fake timers with React.

Contents

  1. Why asynchrony breaks a naive test
  2. The waiting tools: findBy, waitFor, and waitForElementToBeRemoved
  3. The act(...) warning, properly explained
  4. Mocking the network: why at the protocol level, not the module level
  5. MSW v2: installation and CicloUrbano's handlers
  6. server.js and the hookup in setup.js
  7. Testing components with TanStack Query
  8. The interface's three states: loading, success, and error
  9. server.use: overriding a handler for a specific test
  10. Testing a full mutation
  11. renderHook: testing custom hooks
  12. useDebounce: fake timers inside React
  13. useLocalStorage and browser storage
  14. Testing a component that navigates after an operation
  15. Testing ErrorBoundary and silencing expected noise
  16. How not to write flaky tests

  1. Why asynchrony breaks a naive test

// ❌ This test ALWAYS fails
test('shows the bikes in the catalogue', () => {
  renderWithProviders(<CataloguePage />);
  expect(screen.getByText('Classic Urban')).toBeInTheDocument();
});
TestingLibraryElementError: Unable to find an element with the text: Classic Urban

<body>
  <div>
    <div data-testid="esqueleto-pagina" aria-hidden="true" />
  </div>
</body>

The DOM dump explains it all: at the instant the assertion runs, the component has rendered exactly once and is in its isPending state, painting the skeleton. The request has been sent, but the response hasn't arrived; it'll arrive at some point in a future microtask, and by then the test will already be over.

Here's the real sequence, worth having straight:

sequenceDiagram
    participant T as Test
    participant R as React
    participant Q as TanStack Query
    participant N as Network (MSW)

    T->>R: renderWithProviders(<CataloguePage />)
    R->>Q: useBikes()
    Q->>N: GET /bicicletas
    R-->>T: first render · isPending · skeleton
    Note over T: ❌ getByText fails HERE
    N-->>Q: 200 · [ …5 bikes… ]
    Q->>R: data available
    R-->>T: second render · list painted
    Note over T: ✅ this is where it would be

There are two ways to fix it, and only one is correct:

// ❌ WRONG: waiting a fixed amount of time
await new Promise((r) => setTimeout(r, 100));
expect(screen.getByText('Classic Urban')).toBeInTheDocument();

// ✅ RIGHT: waiting for it to APPEAR
expect(await screen.findByText('Classic Urban')).toBeInTheDocument();

Why a manual setTimeout is banned in this project, with three independent reasons: it's slow (it always waits the full 100 ms, even if the data arrives in 3); it's flaky (on a busy CI machine, 100 ms might not be enough, and the test fails one run in twenty); and it doesn't say what it's waiting for (the day it fails, nobody will know whether the problem is the delay or the app). Waiting for a result, on the other hand, ends the moment the element appears and fails with a message that names what it was looking for.

  1. The waiting tools: findBy, waitFor, and waitForElementToBeRemoved

Testing Library offers three, and choosing the right one simplifies the code a lot.

Tool Waits for… Returns When to use it
findBy* / findAllBy* An element to appear The element (a promise) The default choice. Covers 80% of cases
waitFor(fn) Any assertion to stop throwing Whatever fn returns When what changes isn't an element's presence
waitForElementToBeRemoved(el) An element to disappear undefined Checking that the loading indicator goes away

All of them retry every 50 ms up to a default 1,000 ms, and all of them fail with a useful message that includes the DOM.

// 1) findBy: the most common one
const heading = await screen.findByRole('heading', { name: 'Electric Pro' });

// 2) findAllBy: a list that arrives from the server
const cards = await screen.findAllByRole('article');
expect(cards).toHaveLength(5);

// 3) waitFor: when the assertion isn't "an element exists"
await waitFor(() => {
  expect(onCreateBooking).toHaveBeenCalledTimes(1);
});

// 4) waitForElementToBeRemoved: the skeleton goes away
await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));

// 5) Adjusting the timeout when it's justified
const slow = await screen.findByText('Ready', {}, { timeout: 3000 });

findBy is getBy + waitFor combined, literally: internally it does waitFor(() => getBy(...)). That's why, whenever you're waiting for an element to appear, findBy is shorter and produces better error messages.

waitFor's two rules

Rule 1: no side effects inside waitFor. The function runs many times — until it stops throwing — so any action that causes a change gets repeated:

// ❌ WRONG: the click would run several times
await waitFor(async () => {
  await user.click(screen.getByRole('button', { name: 'Book' }));
  expect(onBook).toHaveBeenCalled();
});

// ✅ RIGHT: the effect outside, the assertion inside
await user.click(screen.getByRole('button', { name: 'Book' }));
await waitFor(() => expect(onBook).toHaveBeenCalled());

Rule 2: one assertion per waitFor. Put three in, and the function retries until all three pass together, and when it fails you won't know which one was the problem. Worse: if the first passes and the third never does, the error message will point at the third, but you'll have burned a full second retrying all three.

// ❌ Needlessly brittle and slow
await waitFor(() => {
  expect(screen.getByText('Classic Urban')).toBeInTheDocument();
  expect(screen.getByText('Electric Pro')).toBeInTheDocument();
  expect(screen.getAllByRole('article')).toHaveLength(5);
});

// ✅ Wait once, assert the rest synchronously
await screen.findByText('Classic Urban');
expect(screen.getByText('Electric Pro')).toBeInTheDocument();
expect(screen.getAllByRole('article')).toHaveLength(5);

The pattern from the second version is the one used throughout this lesson: one wait at the start, synchronous assertions after. Once the first element has arrived, the render has already happened and the rest is sitting in the DOM.

  1. The act(...) warning, properly explained

Sooner or later this message shows up, and the usual reaction — wrapping things in act until it shuts up — is almost always the wrong one:

Warning: An update to CataloguePage inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...)

What act is

act is a React function that bounds a block of work: "run this, and don't give me back control until you've processed every state update, run every effect, and applied the changes to the DOM." In the browser, React batches updates whenever it sees fit; in a test, that would be a race against the assertion. act synchronizes the two worlds.

What triggers the warning

Always the same thing: a state change that happens outside the test's control, meaning after the test has finished its synchronous block. The specific cases:

Situation Why it shows up Fix
A request resolves and updates state after the last assertion The test ended before the response did await screen.findBy… or await waitFor(…)
A setTimeout inside an effect fires with nobody waiting for it The timer fires outside act Fake timers and act(() => vi.advanceTimersByTime(n))
A hook's updater gets called from the test No act wrapping it act(() => result.current.toggle())
An async effect updates state after unmount A real leak in the app Fix the component: cancel with AbortSignal

What NOT to do

// ❌ Wrapping everything by hand
await act(async () => {
  render(<CataloguePage />);
});
await act(async () => {
  await user.click(button);
});

It's unnecessary and counterproductive. render, userEvent, findBy, and waitFor already wrap their work in act; adding more layers achieves nothing and masks the real problem, which is almost always "you forgot to wait for something."

// ✅ The cause was a missing wait
render(<CataloguePage />);
await screen.findByText('Classic Urban');   // the warning goes away

The only legitimate hand-written use of act is the one from section 11: calling a hook's updater directly when it's tested with renderHook, where there's neither a component nor an interaction wrapping it.

The hardest version: the warning after the test is over

Sometimes the warning shows up after the test has already passed, in the report. That means the app kept updating state when it shouldn't have. That isn't a problem with the test: it's a leak in the app — an effect that doesn't cancel its request on unmount — and it has to be fixed in the component. useBikes receives queryFn's signal precisely for this reason (07-06), and that decision is what keeps this warning from showing up here.

  1. Mocking the network: why at the protocol level, not the module level

There are four ways to keep a test from calling the real API. They aren't equivalent.

Strategy How What actually gets tested Problem
Mocking the API module vi.mock('./queries/api.js') The component, with a fake API The URL construction, the serialization, and the HTTP status handling never get tested. If the real API changes, the test stays green
Mocking global fetch global.fetch = vi.fn() A bit more: the URL is at least visible You have to hand-reimplement Response, ok, json(), headers… for every case
Intercepting the network (MSW) One handler per route The whole path: URL, method, body, headers, status codes Requires setting up a server once
The real API (json-server) Nothing gets mocked Everything, including the database Slow, needs a running process, shares state across tests

MSW (Mock Service Worker) is the project's choice because it intercepts at the request level, not the module level. In Node it does it by intercepting the network interfaces; in the browser, with a service worker. The application code never knows: fetch genuinely gets called, with its genuine URL, and gets back a genuine Response.

The concrete advantages for CicloUrbano:

  • The test exercises useBikes in full, including building http://localhost:3001/bicicletas?stationId=est-01, checking response.ok, and response.json(). A typo in the URL breaks the test, as it should.
  • The same handlers work in development, if you ever want to work without a backend, and in the Cypress tests.
  • There's nothing to keep in sync. If useCreateBooking changes its method from POST to PUT, the handler doesn't respond and the test fails. With vi.mock it would have stayed green.
  • Triggering errors is trivial: returning a 500 is one line, and reproducing it with the real API would mean killing json-server mid-test.

  1. MSW v2: installation and CicloUrbano's handlers

npm install -D msw

The handlers are the description of the fake API: what each route responds with. In MSW v2 the API is http.get/http.post/… and HttpResponse.

// src/tests/handlers.js
import { http, HttpResponse } from 'msw';

const API = 'http://localhost:3001';

// CicloUrbano's fictional domain, in one place and exported
// so tests can assert against the same data.
export 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 },
  { id: 'bici-004', model: 'Classic Urban', type: 'urbana',    status: 'disponible',    stationId: 'est-03', pricePerHour: 2.5 },
  { id: 'bici-005', model: 'Electric Pro',  type: 'electrica', status: 'disponible',    stationId: 'est-02', pricePerHour: 4.0 }
];

export const STATIONS = [
  { id: 'est-01', name: 'Main Square',    district: 'Downtown',  docks: 20 },
  { id: 'est-02', name: 'North Park',     district: 'North',     docks: 15 },
  { id: 'est-03', name: 'Central Station', district: 'Riverside', docks: 30 }
];

export const BOOKINGS = [
  {
    id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01',
    startDate: '2026-05-04T09:00', hours: 2, status: 'activa'
  }
];

export const handlers = [
  // GET /bicicletas  ·  supports ?stationId= and ?tipo=, like json-server
  http.get(`${API}/bicicletas`, ({ request }) => {
    const url = new URL(request.url);
    const stationId = url.searchParams.get('stationId');
    const type = url.searchParams.get('tipo');

    let result = BIKES;
    if (stationId) result = result.filter((b) => b.stationId === stationId);
    if (type && type !== 'todos') result = result.filter((b) => b.type === type);

    return HttpResponse.json(result);
  }),

  // GET /bicicletas/:bicicletaId
  http.get(`${API}/bicicletas/:bicicletaId`, ({ params }) => {
    const bike = BIKES.find((b) => b.id === params.bicicletaId);
    if (!bike) {
      return new HttpResponse(null, { status: 404 });
    }
    return HttpResponse.json(bike);
  }),

  http.get(`${API}/estaciones`, () => HttpResponse.json(STATIONS)),

  http.get(`${API}/estaciones/:estacionId`, ({ params }) => {
    const station = STATIONS.find((e) => e.id === params.estacionId);
    return station
      ? HttpResponse.json(station)
      : new HttpResponse(null, { status: 404 });
  }),

  http.get(`${API}/reservas`, ({ request }) => {
    const user = new URL(request.url).searchParams.get('user');
    const result = user ? BOOKINGS.filter((r) => r.user === user) : BOOKINGS;
    return HttpResponse.json(result);
  }),

  // POST /reservas  ·  returns what it received plus its id, like json-server
  http.post(`${API}/reservas`, async ({ request }) => {
    const newBooking = await request.json();
    return HttpResponse.json({ id: newBooking.id ?? 'res-nueva', ...newBooking }, { status: 201 });
  }),

  // PATCH /reservas/:id  ·  confirming and cancelling
  http.patch(`${API}/reservas/:reservaId`, async ({ params, request }) => {
    const changes = await request.json();
    const booking = BOOKINGS.find((r) => r.id === params.reservaId);
    if (!booking) return new HttpResponse(null, { status: 404 });
    return HttpResponse.json({ ...booking, ...changes });
  })
];

The key pieces of this API:

Element What it does
http.get(url, resolver) Declares what to respond with to a GET on that URL
:param in the route A variable segment; arrives in params
({ request, params }) The resolver's context: the request and the route parameters
HttpResponse.json(data) A 200 response with a JSON body
HttpResponse.json(d, { status }) With a different status code
new HttpResponse(null, { status: 500 }) A response with no body
await request.json() The request body, to verify what was sent

Two deliberate decisions:

  • The data gets exported. That way a test can write expect(await screen.findAllByRole('article')).toHaveLength(BIKES.length) without duplicating the number 5 in twenty places.
  • The handlers mirror json-server's behavior, including the query-string filters and the POST's 201. The more the mock resembles the real API, the fewer the chances of the test passing while the app fails.

  1. server.js and the hookup in setup.js

// src/tests/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers.js';

// In Node (Vitest), setupServer is used. In the browser it would be setupWorker.
export const server = setupServer(...handlers);
// src/tests/setup.js  ·  the module's complete version
import '@testing-library/jest-dom/vitest';
import { afterEach, afterAll, beforeAll } from 'vitest';
import { cleanup } from '@testing-library/react';
import { server } from './server.js';

beforeAll(() => {
  // onUnhandledRequest: 'error' → any request with no handler BREAKS the test.
  // That's the point: an unexpected request is a failure, not something to ignore.
  server.listen({ onUnhandledRequest: 'error' });
});

afterEach(() => {
  cleanup();
  localStorage.clear();
  // Undoes the server.use() calls from the test that just finished:
  // without this, a forced 500 in one test would contaminate every test after it.
  server.resetHandlers();
});

afterAll(() => {
  server.close();
});

The three hooks are an inseparable trio, and each one prevents a specific failure:

Hook What breaks without it
server.listen() in beforeAll Without it, requests go out for real: the test depends on json-server being up
server.resetHandlers() in afterEach Without it, a server.use with a 500 error stays active and breaks later tests in an apparently random way
server.close() in afterAll Without it, the Node process can hang when the suite finishes

And onUnhandledRequest: 'error' deserves its own defense: it turns any request with no handler into a failure. It might look severe, but it's the only way to find out a component is calling an endpoint nobody anticipated. With the default option ('warn'), that request would go out to the real network, take however long it takes, and probably fail with an incomprehensible message.

flowchart LR
    A["Component"] -->|"fetch('http://localhost:3001/bicicletas')"| B["Node's network layer"]
    B --> C{"Does MSW have a handler?"}
    C -- Yes --> D["Resolver in handlers.js"]
    D --> E["HttpResponse.json(BIKES)"]
    E --> A
    C -- No --> F["onUnhandledRequest: 'error'<br/>the test fails"]

  1. Testing components with TanStack Query

renderWithProviders (09-03) already creates a fresh QueryClient on every call, with retry: false. Both decisions are essential, and it's worth understanding why.

One client per test. The QueryClient is a cache. If it's shared across tests, the second one finds the first one's data already cached and doesn't make the request, so:

  • A test that wants to check the loading state never sees it: the data is already there.
  • A test that forces a 500 with server.use doesn't observe it: the cached response gets served instead.
  • The result depends on execution order, which is the definition of a flaky test.

retry: false. By default, queryClient retries twice with exponential backoff (07-06). In an error test, that means waiting for the first failure, one second, the second failure, two seconds… and blowing through the 1,000 ms timeout long before the message shows up. It's the number-one cause of "my error test hangs and I don't understand why."

// src/tests/utils.jsx (a reminder from section 13 of 09-03)
export function createTestClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: Infinity, staleTime: 0 },
      mutations: { retry: false }
    }
  });
}

  1. The interface's three states: loading, success, and error

Every screen that requests data has three states, and all three deserve a test. The error one is the one nobody checks by hand, and the one that fails the most.

// src/pages/CataloguePage.test.jsx
import { renderWithProviders, screen, waitForElementToBeRemoved, userEvent } from '../tests/utils.jsx';
import { server } from '../tests/server.js';
import { BIKES } from '../tests/handlers.js';
import { http, HttpResponse, delay } from 'msw';
import CataloguePage from './CataloguePage.jsx';

const API = 'http://localhost:3001';

describe('CataloguePage', () => {
  test('shows the skeleton while the bikes are loading', () => {
    renderWithProviders(<CataloguePage />);

    // SYNCHRONOUS assertion, right after rendering: nothing has arrived yet at this point
    expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
    expect(screen.queryByRole('article')).not.toBeInTheDocument();
  });

  test('replaces the skeleton with the list once the data arrives', async () => {
    renderWithProviders(<CataloguePage />);

    await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));

    expect(screen.getAllByRole('article')).toHaveLength(BIKES.length);
    expect(screen.getByRole('heading', { name: 'Cargo Max' })).toBeInTheDocument();
  });

  test('shows every bike with its status and price', async () => {
    renderWithProviders(<CataloguePage />);

    await screen.findByRole('heading', { name: 'Cargo Max' });

    // One single wait; the rest is already in the DOM and gets checked synchronously
    expect(screen.getByText('In maintenance')).toBeInTheDocument();
    expect(screen.getAllByText('Available')).toHaveLength(3);
    expect(screen.getByText(/€5\.50/)).toBeInTheDocument();
  });
});

The first test is the only one in this lesson with a synchronous assertion right after rendering, and it's correct precisely because it's checking the initial state: at that instant the request is in flight and the component paints the skeleton. If someone removed the loading state and the screen went blank, this test would catch it.

  1. server.use: overriding a handler for a specific test

The handlers in handlers.js are the happy path. For everything else, server.use(...) adds a handler that takes priority over the base ones and lasts only until the afterEach's resetHandlers().

Triggering a server error

test('shows an error message and a retry button if the API fails', async () => {
  server.use(
    http.get(`${API}/bicicletas`, () => new HttpResponse(null, { status: 500 }))
  );

  renderWithProviders(<CataloguePage />);

  // The message is what the user sees; the "500" is a detail that shouldn't leak through
  expect(await screen.findByRole('alert')).toHaveTextContent(/couldn't load/i);
  expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument();
  expect(screen.queryByRole('article')).not.toBeInTheDocument();
});

Recovering after the error

This is the test that genuinely justifies the retry button, and it's uncomfortable to do by hand:

test('the retry button asks for the data again and paints the list', async () => {
  // First attempt: fails. `use` handlers with `{ once: true }` get consumed
  // after responding once, so the second attempt falls through to the base one.
  server.use(
    http.get(`${API}/bicicletas`, () => new HttpResponse(null, { status: 500 }), { once: true })
  );

  const user = userEvent.setup();
  renderWithProviders(<CataloguePage />);

  await screen.findByRole('alert');

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

  expect(await screen.findByRole('heading', { name: 'Cargo Max' })).toBeInTheDocument();
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

The empty-list case

A state that always gets forgotten, and one that produces blank screens in production:

test('shows its own message when there are no bikes', async () => {
  server.use(
    http.get(`${API}/bicicletas`, () => HttpResponse.json([]))
  );

  renderWithProviders(<CataloguePage />);

  expect(await screen.findByText(/No bikes match the filter/)).toBeInTheDocument();
  expect(screen.queryByRole('article')).not.toBeInTheDocument();
});

A slow response

MSW's delay lets you check that the loading state holds up for as long as the wait lasts:

test('keeps the skeleton up while the response is slow', async () => {
  server.use(
    http.get(`${API}/bicicletas`, async () => {
      await delay(200);
      return HttpResponse.json(BIKES);
    })
  );

  renderWithProviders(<CataloguePage />);

  expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
  // And it does arrive eventually: waiting for the result, not the 200 ms
  expect(await screen.findByRole('heading', { name: 'Cargo Max' })).toBeInTheDocument();
});

Notice that not even here does anything wait a fixed amount of time. delay(200) lives in the handler, on the mock server's side; the test keeps waiting for the result. It's the difference between controlling latency and guessing at it.

Checking the query-string filter

test('only asks for the bikes of the type given in the URL', async () => {
  renderWithProviders(<CataloguePage />, { route: '/?tipo=electrica' });

  await screen.findByRole('heading', { name: 'Electric Pro' });

  // The handler filters by ?tipo=, so only the two electric bikes should arrive
  expect(screen.getAllByRole('article')).toHaveLength(2);
  expect(screen.queryByRole('heading', { name: 'Cargo Max' })).not.toBeInTheDocument();
});

This test travels the whole chain: the URL → useSearchParams → the query key → the request URL → the handler's filter → the painted list. No unit test can cover that, and it's exactly the kind of wiring that breaks during a refactor.

  1. Testing a full mutation

A mutation has three things to verify, and the third is the one that gets forgotten most: what gets sent, what gets shown, and what gets refreshed afterward.

// src/pages/NewBookingPage.test.jsx
import { renderWithProviders, screen, userEvent, waitFor } from '../tests/utils.jsx';
import { server } from '../tests/server.js';
import { http, HttpResponse, delay } from 'msw';
import NewBookingPage from './NewBookingPage.jsx';

const API = 'http://localhost:3001';

const ANA_SESSION = {
  session: {
    user: { id: 'usr-01', name: 'Ana Ribera', email: '[email protected]', role: 'cliente' },
    loading: false,
    error: null
  }
};

describe('NewBookingPage', () => {
  beforeEach(() => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
    vi.setSystemTime(new Date('2026-05-04T08:00:00'));
  });
  afterEach(() => vi.useRealTimers());

  test('sends the booking with the right body and reports success', async () => {
    // A spy on the sent body: the handler captures it so it can be asserted on
    let receivedBody = null;
    server.use(
      http.post(`${API}/reservas`, async ({ request }) => {
        receivedBody = await request.json();
        return HttpResponse.json({ ...receivedBody }, { status: 201 });
      })
    );

    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
    renderWithProviders(<NewBookingPage />, { initialState: ANA_SESSION });

    // The selector's bikes arrive from the API: they have to be waited for
    await screen.findByRole('option', { name: /Classic Urban/ });

    await user.selectOptions(screen.getByLabelText('Bike'), 'bici-001');
    await user.type(screen.getByLabelText('Booking start'), '2026-05-04T10:00');
    await user.clear(screen.getByLabelText('Duration (hours)'));
    await user.type(screen.getByLabelText('Duration (hours)'), '3');
    await user.click(screen.getByRole('checkbox', { name: /I accept .*terms/ }));

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

    // 1) What got sent
    await waitFor(() => expect(receivedBody).not.toBeNull());
    expect(receivedBody).toMatchObject({
      bicicletaId: 'bici-001',
      user: 'usr-01',
      startDate: '2026-05-04T10:00',
      hours: 3,
      status: 'activa'
    });

    // 2) What the user sees
    expect(await screen.findByRole('status')).toHaveTextContent(/Booking created/);
  });

  test('disables the button while it\'s submitting', async () => {
    server.use(
      http.post(`${API}/reservas`, async ({ request }) => {
        await delay(100);
        return HttpResponse.json(await request.json(), { status: 201 });
      })
    );

    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
    renderWithProviders(<NewBookingPage />, { initialState: ANA_SESSION });
    await fillValidForm(user);

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

    expect(screen.getByRole('button', { name: /Creating/ })).toBeDisabled();
    expect(await screen.findByRole('status')).toHaveTextContent(/Booking created/);
  });

  test('shows the error without losing the form data if the submission fails', async () => {
    server.use(
      http.post(`${API}/reservas`, () => new HttpResponse(null, { status: 500 }))
    );

    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
    renderWithProviders(<NewBookingPage />, { initialState: ANA_SESSION });
    await fillValidForm(user);

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

    expect(await screen.findByRole('alert')).toHaveTextContent(/The booking couldn't be created/);
    // And the important part: the user's work isn't lost
    expect(screen.getByLabelText('Duration (hours)')).toHaveValue(3);
  });
});

Checking the invalidation

The part almost nobody tests, and the whole reason useCreateBooking's onSuccess exists: after creating a booking, the list reloads.

test('the bookings list refreshes after creating a new one', async () => {
  let bookingRequests = 0;
  const bookings = [];

  server.use(
    http.get(`${API}/reservas`, () => {
      bookingRequests += 1;
      return HttpResponse.json(bookings);
    }),
    http.post(`${API}/reservas`, async ({ request }) => {
      const newBooking = await request.json();
      bookings.push(newBooking);          // the "server" actually stores it
      return HttpResponse.json(newBooking, { status: 201 });
    })
  );

  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  renderWithProviders(<BookingsPage />, { initialState: ANA_SESSION });

  expect(await screen.findByText(/You don't have any bookings/)).toBeInTheDocument();
  expect(bookingRequests).toBe(1);

  await user.click(screen.getByRole('button', { name: /New booking/ }));
  await fillValidForm(user);
  await user.click(screen.getByRole('button', { name: 'Create booking' }));

  // Invalidating keys.bookings.all() triggers a second request…
  await waitFor(() => expect(bookingRequests).toBe(2));
  // …and the booking shows up in the list
  expect(await screen.findByText('Classic Urban')).toBeInTheDocument();
});

The stateful handler (bookings.push) turns MSW into a miniature real server. It's what lets you verify the full cycle: create, invalidate, reload, paint. And checking the number of requests is legitimate here, because the reload after the mutation is observable behavior: if it disappears, the user sees a stale list.

  1. renderHook: testing custom hooks

A hook can't be called outside a component. renderHook mounts a minimal component whose only job is to call the hook and expose its value in result.current.

// src/hooks/useToggle.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import { useToggle } from './useToggle.js';

describe('useToggle', () => {
  test('starts as false by default', () => {
    const { result } = renderHook(() => useToggle());
    const [value] = result.current;
    expect(value).toBe(false);
  });

  test('respects the initial value it receives', () => {
    const { result } = renderHook(() => useToggle(true));
    expect(result.current[0]).toBe(true);
  });

  test('toggle flips the value', () => {
    const { result } = renderHook(() => useToggle(false));

    // act: without it, React warns that the update isn't wrapped,
    // and result.current wouldn't reflect the new value
    act(() => result.current[1].toggle());
    expect(result.current[0]).toBe(true);

    act(() => result.current[1].toggle());
    expect(result.current[0]).toBe(false);
  });

  test('turnOn and turnOff set the value regardless of the previous one', () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => result.current[1].turnOn());
    act(() => result.current[1].turnOn());
    expect(result.current[0]).toBe(true);

    act(() => result.current[1].turnOff());
    expect(result.current[0]).toBe(false);
  });

  test('the three actions keep their identity across renders', () => {
    const { result } = renderHook(() => useToggle(false));
    const initialActions = result.current[1];

    act(() => result.current[1].toggle());

    // useCallback with [] stabilizes them: that's the hook's promise (05-06)
    expect(result.current[1]).toBe(initialActions);
  });
});

This is the only legitimate hand-written use of act promised back in section 3: calling a state updater directly, with neither an interaction nor a request in between. Without act, React doesn't process the update before returning control, and result.current keeps the old value.

The last test deserves attention: it verifies the identity stability that useCallback with empty dependencies guarantees. It's observable behavior, not implementation, because an effect that receives toggle in its dependencies depends on that stability to avoid re-running in a loop. If someone removes the useCallback, this test fails and explains why.

Useful renderHook options

// Changing the hook's props between renders
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
  initialProps: { value: 'urbana' }
});
expect(result.current).toBeUndefined();

rerender({ value: 'electrica' });
expect(result.current).toBe('urbana');       // the previous value

// Wrapping the hook with providers: for hooks that use context or Redux
const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider });
expect(result.current.theme).toBe('claro');

  1. useDebounce: fake timers inside React

09-02 tested the delay mechanism as a pure function. Now it's the hook's turn, and that's where the difficulty of combining fake timers with React's updates shows up.

// src/hooks/useDebounce.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { useDebounce } from './useDebounce.js';

describe('useDebounce', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  test('returns the initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('urbana', 400));
    expect(result.current).toBe('urbana');
  });

  test('doesn\'t propagate the new value before the delay is up', () => {
    const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
      initialProps: { v: 'urb' }
    });

    rerender({ v: 'urbana' });

    // The effect has scheduled the timer, but it hasn't fired yet
    expect(result.current).toBe('urb');

    act(() => vi.advanceTimersByTime(399));
    expect(result.current).toBe('urb');
  });

  test('propagates the value once the delay is met', () => {
    const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
      initialProps: { v: 'urb' }
    });

    rerender({ v: 'urbana' });

    // act wraps the clock advance: the setTimeout fires INSIDE act,
    // so React processes the resulting setState before returning control
    act(() => vi.advanceTimersByTime(400));

    expect(result.current).toBe('urbana');
  });

  test('six rapid changes produce a single final value', () => {
    const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
      initialProps: { v: 'u' }
    });

    for (const v of ['ur', 'urb', 'urba', 'urban', 'urbana']) {
      rerender({ v });
      act(() => vi.advanceTimersByTime(100));   // 100 ms between keystrokes
    }

    // 500 ms have passed in total, but never 400 straight without a change
    expect(result.current).toBe('u');

    act(() => vi.advanceTimersByTime(400));
    expect(result.current).toBe('urbana');
  });

  test('the pending timer gets cancelled on unmount', () => {
    const { rerender, unmount } = renderHook(({ v }) => useDebounce(v, 400), {
      initialProps: { v: 'urb' }
    });

    rerender({ v: 'urbana' });
    unmount();

    // The effect's cleanup has called clearTimeout: nothing is left pending
    expect(vi.getTimerCount()).toBe(0);
  });
});

The three rules for this combination:

  1. act(() => vi.advanceTimersByTime(n)), always together. Advancing the clock fires a setTimeout that calls setDebouncedValue; without act, that update falls outside React's control, result.current doesn't refresh, and the warning from section 3 shows up.
  2. vi.useRealTimers() in afterEach, no exceptions. Fake timers that survive a test hang the ones that follow.
  3. Fake timers and userEvent don't get along by default. userEvent waits using real timers between events; if you've frozen them, it locks up. The two fixes already used in this lesson: vi.useFakeTimers({ shouldAdvanceTime: true }), or passing userEvent.setup({ advanceTimers: vi.advanceTimersByTime }).

The last test, the unmount one, verifies the effect's cleanup — the return () => clearTimeout(id) from 05-02. It's a test for a memory leak, and there's no other reasonable way to check it.

  1. useLocalStorage and browser storage

jsdom provides a working localStorage, so there's nothing to mock: it just needs to be cleared between tests, which setup.js already does.

// src/hooks/useLocalStorage.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { useLocalStorage } from './useLocalStorage.js';

describe('useLocalStorage', () => {
  afterEach(() => {
    localStorage.clear();
    vi.restoreAllMocks();
  });

  test('uses the initial value when nothing is stored', () => {
    const { result } = renderHook(() => useLocalStorage('ciclourbano:tema', 'claro'));
    expect(result.current[0]).toBe('claro');
  });

  test('reads the stored value on the first render, with no flicker', () => {
    localStorage.setItem('ciclourbano:tema', JSON.stringify('oscuro'));

    const { result } = renderHook(() => useLocalStorage('ciclourbano:tema', 'claro'));

    // 'oscuro' on the FIRST value: the read happens in the lazy initializer (05-06),
    // not in an effect. If it were in an effect, we'd see 'claro' here.
    expect(result.current[0]).toBe('oscuro');
  });

  test('saves the new value to storage', () => {
    const { result } = renderHook(() => useLocalStorage('ciclourbano:tema', 'claro'));

    act(() => result.current[1]('oscuro'));

    expect(result.current[0]).toBe('oscuro');
    expect(JSON.parse(localStorage.getItem('ciclourbano:tema'))).toBe('oscuro');
  });

  test('falls back to the initial value if what\'s stored isn\'t valid JSON', () => {
    localStorage.setItem('ciclourbano:tema', '{{{this is not json');

    const { result } = renderHook(() => useLocalStorage('ciclourbano:tema', 'claro'));

    expect(result.current[0]).toBe('claro');    // the try/catch does its job
  });

  test('doesn\'t break if storage throws while writing (quota or private mode)', () => {
    vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
      throw new DOMException('QuotaExceededError');
    });

    const { result } = renderHook(() => useLocalStorage('ciclourbano:tema', 'claro'));

    // The app keeps working even if it can't persist
    expect(() => act(() => result.current[1]('oscuro'))).not.toThrow();
    expect(result.current[0]).toBe('oscuro');
  });
});

The last two tests are the whole reason the try/catchs from 05-06 exist, defended back then as "not paranoia." Triggering an exhausted quota by hand means filling up the browser's storage; with vi.spyOn(Storage.prototype, 'setItem') it's three lines. And the corrupted-JSON one reproduces a real case: an earlier version of the app stored something else under the same key.

  1. Testing a component that navigates after an operation

This combines the navigation from 09-03 with this lesson's asynchrony: after creating the booking, the app takes the user to /reservas.

test('goes to /reservas after creating the booking', async () => {
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

  const { router } = renderWithProviders(null, {
    route: '/reservas/nueva',
    initialState: ANA_SESSION,
    routes: [
      { path: '/reservas/nueva', element: <NewBookingPage /> },
      { path: '/reservas', element: <h1>Your bookings</h1> }
    ]
  });

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

  // Waiting for the DESTINATION, not for a fixed time
  expect(await screen.findByRole('heading', { name: 'Your bookings' })).toBeInTheDocument();
  expect(router.state.location.pathname).toBe('/reservas');
});

The destination route is replaced by a minimal component for the same reason as in 09-03: the test verifies that navigation happens, and mounting the real page would drag in its own queries and turn a navigation bug into a data bug.

  1. Testing ErrorBoundary and silencing expected noise

ErrorBoundary is the project's only class (04-05). Testing it has one quirk: React always writes the error to the console, even when the boundary catches it correctly. If you don't silence it, the suite's output fills up with red traces that look like failures and aren't.

// src/components/ErrorBoundary.test.jsx
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import ErrorBoundary from './ErrorBoundary.jsx';

// A component that blows up on demand
function FailingComponent({ fail = true }) {
  if (fail) throw new Error('The fleet isn\'t available');
  return <p>Correct content</p>;
}

describe('ErrorBoundary', () => {
  let consoleSpy;

  beforeEach(() => {
    // React writes out the caught error: it's EXPECTED noise, not a failure
    consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  test('lets its children through when there\'s no error', () => {
    render(
      <ErrorBoundary title="Something went wrong">
        <FailingComponent fail={false} />
      </ErrorBoundary>
    );

    expect(screen.getByText('Correct content')).toBeInTheDocument();
    expect(consoleSpy).not.toHaveBeenCalled();
  });

  test('shows the booking interface when a child throws', () => {
    render(
      <ErrorBoundary title="CicloUrbano isn't available right now">
        <FailingComponent />
      </ErrorBoundary>
    );

    expect(screen.getByRole('heading', { name: /isn't available/ })).toBeInTheDocument();
    expect(screen.queryByText('Correct content')).not.toBeInTheDocument();
  });

  test('reports the error to the monitoring service', () => {
    const onLog = vi.fn();

    render(
      <ErrorBoundary title="Error" onLog={onLog}>
        <FailingComponent />
      </ErrorBoundary>
    );

    expect(onLog).toHaveBeenCalledTimes(1);
    expect(onLog).toHaveBeenCalledWith(
      expect.objectContaining({ message: 'The fleet isn\'t available' }),
      expect.anything()      // the component info React passes as the second argument
    );
  });

  test('the retry button remounts the children', async () => {
    const user = userEvent.setup();

    function Container() {
      const [fail, setFail] = useState(true);
      return (
        <>
          <button type="button" onClick={() => setFail(false)}>Fix</button>
          <ErrorBoundary title="Error">
            <FailingComponent fail={fail} />
          </ErrorBoundary>
        </>
      );
    }

    render(<Container />);
    expect(screen.getByRole('heading', { name: 'Error' })).toBeInTheDocument();

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

    expect(screen.getByText('Correct content')).toBeInTheDocument();
  });
});

Two important notes on the silencing:

  • It only gets silenced in the tests that trigger the error on purpose, never globally in setup.js. A global console.error would also disable React's legitimate warnings — duplicate keys, misused hooks, invalid props — which are valuable information.
  • vi.restoreAllMocks() in afterEach is mandatory. Without it, the console stays muted for the rest of the file.

And look at the first test: expect(consoleSpy).not.toHaveBeenCalled() checks that the happy path produces no warning at all. It's a cheap check that catches duplicate keys and other React warnings that would otherwise go unnoticed.

  1. How not to write flaky tests

An operational recap of everything above, as a set of rules:

Rule Why How it's applied in CicloUrbano
Never wait a fixed amount of time Always slow, sometimes not enough findBy*, waitFor, waitForElementToBeRemoved
Wait for what's visible, not what happens inside Internal state can change without the interface reflecting it await screen.findByRole('heading', …)
A new client and store per test A shared cache makes the result depend on order renderWithProviders always creates them
retry: false on queries Retries blow through the timeout in error tests createTestClient
resetHandlers() after every test A server.use with a 500 would contaminate the next ones setup.js's afterEach
onUnhandledRequest: 'error' An unexpected request goes out to the real network and fails incomprehensibly server.listen
Clear storage between tests useLocalStorage and the theme persist localStorage.clear() in afterEach
Freeze the date, not the whole clock Rules that depend on "now" expire setSystemTime + shouldAdvanceTime: true
One assertion per waitFor, no side effects inside The function retries many times Wait once, assert synchronously after
Don't share mutable variables between tests Execution order starts to matter A fresh scenario in beforeEach

And the usual diagnosis when a flaky test shows up: run it alone, then inside the suite. If it passes alone and fails accompanied, it's an isolation problem (cache, handlers, storage). If it fails intermittently in both cases, it's a waiting problem.

Common Mistakes and Tips

  • Forgetting await before findBy. The assertion receives a promise, which is always truthy, so expect(promise).toBeInTheDocument() throws a cryptic error or passes by accident. Rule: findBy always with await.
  • Using getBy right after render while expecting server data. Always fails, because the first render is the loading one. It's the mistake from section 1, and the DOM dump gives it away: the skeleton shows up.
  • Putting interactions inside waitFor. They run as many times as the function retries. The click outside, the assertion inside.
  • Silencing the act warning by wrapping everything by hand. The warning is a symptom; the cause is usually a missing wait. Add the wait and the warning disappears on its own.
  • Sharing the QueryClient across tests. Produces the module's most baffling failure: tests that pass alone and fail together, or the other way around. renderWithProviders avoids this, but don't sidestep it by creating the client at the describe scope.
  • Forgetting resetHandlers(). A server.use with a 500 outlives the test and breaks others that have nothing to do with it. It's in setup.js; don't remove it.
  • Mocking fetch instead of using MSW. Forces you to reimplement Response, ok, json(), and the headers, and leaves the URL construction untested — exactly where the typos live.
  • Only testing the happy path. The error state and the empty list are what produce blank screens in production, and they're the easiest to test with server.use. If you're only going to add one test to a page, make it the error one.
  • Tip: when an async test fails, look at the DOM dump next to the error. It usually says exactly what state things were left in: skeleton (the data never arrived), alert (an error arrived), empty (nothing got mounted).
  • Tip: use stateful handlers for mutations. An array the POST fills and the GET reads turns MSW into a miniature real server, and lets you test the full invalidation cycle.
  • Tip: export the data from handlers.js and assert against it. toHaveLength(BIKES.length) doesn't break the day someone adds a sixth bike to the scenario.

Exercises

Exercise 1. Write the suite for StationDetailPage at route /estaciones/est-01. Cover all four scenarios: the loading state, success (name "Main Square", district "Downtown", 20 docks, and the two bikes at that station), a 500 error from the stations endpoint, and a 404 when the id doesn't exist. State which waiting tool you use in each case and why.

Exercise 2. This test sometimes passes and sometimes fails. Identify four problems and rewrite it.

test('creates a booking', async () => {
  const client = new QueryClient();
  render(
    <QueryClientProvider client={client}>
      <NewBookingPage />
    </QueryClientProvider>
  );

  await new Promise((r) => setTimeout(r, 300));

  fireEvent.change(screen.getByLabelText('Bike'), { target: { value: 'bici-001' } });
  fireEvent.click(screen.getByText('Create booking'));

  await waitFor(() => {
    expect(screen.getByText('Booking created')).toBeInTheDocument();
    expect(screen.getByLabelText('Bike')).toHaveValue('');
  });
});

Exercise 3. Write the tests for useOnlineStatus, the hook that returns true or false based on navigator.onLine and subscribes to the browser's online and offline events. Cover: the initial value, the reaction to each event, and that the listeners get removed on unmount. Hint: browser events get fired with window.dispatchEvent(new Event('offline')), and navigator.onLine gets replaced with vi.spyOn(navigator, 'onLine', 'get').

Solutions

Solution 1.

// src/pages/StationDetailPage.test.jsx
import { renderWithProviders, screen, waitForElementToBeRemoved } from '../tests/utils.jsx';
import { server } from '../tests/server.js';
import { http, HttpResponse, delay } from 'msw';
import StationDetailPage from './StationDetailPage.jsx';

const API = 'http://localhost:3001';

const ROUTES = [{ path: '/estaciones/:estacionId', element: <StationDetailPage /> }];

describe('StationDetailPage', () => {
  test('shows the skeleton while loading', () => {
    server.use(
      http.get(`${API}/estaciones/:estacionId`, async () => {
        await delay(100);
        return HttpResponse.json({ id: 'est-01', name: 'Main Square', district: 'Downtown', docks: 20 });
      })
    );

    renderWithProviders(null, { route: '/estaciones/est-01', routes: ROUTES });

    // SYNCHRONOUS assertion: it's the initial state, nothing to wait for
    expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
  });

  test('shows the station\'s data and its fleet', async () => {
    renderWithProviders(null, { route: '/estaciones/est-01', routes: ROUTES });

    // waitForElementToBeRemoved: checks the loading → content TRANSITION
    await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));

    expect(screen.getByRole('heading', { name: 'Main Square' })).toBeInTheDocument();
    expect(screen.getByText('Downtown')).toBeInTheDocument();
    expect(screen.getByText(/20 docks/)).toBeInTheDocument();

    // est-01 has bici-001 and bici-002 in handlers.js's scenario
    expect(screen.getAllByRole('article')).toHaveLength(2);
    expect(screen.getByRole('heading', { name: 'Electric Pro' })).toBeInTheDocument();
  });

  test('shows an error message if the API fails', async () => {
    server.use(
      http.get(`${API}/estaciones/:estacionId`, () => new HttpResponse(null, { status: 500 }))
    );

    renderWithProviders(null, { route: '/estaciones/est-01', routes: ROUTES });

    // findByRole: waits for the alert to APPEAR
    expect(await screen.findByRole('alert')).toHaveTextContent(/couldn't load the station/i);
    expect(screen.queryByRole('article')).not.toBeInTheDocument();
  });

  test('shows "station not found" on a 404', async () => {
    renderWithProviders(null, { route: '/estaciones/est-99', routes: ROUTES });

    // The base handler already returns 404 for a nonexistent id: no server.use needed
    expect(await screen.findByText(/That station doesn't exist/)).toBeInTheDocument();
  });
});

The waiting tools and why:

Scenario Tool Why
Loading None (synchronous) It's the state immediately after render
Success waitForElementToBeRemoved Checks the full transition: the skeleton goes away
Error findByRole('alert') Waits for a specific element to appear
404 findByText Same, and no server.use needed because the base handler already covers it

Solution 2. The four problems:

  1. new QueryClient() with default options. It retries failed queries, so any error test would blow through the timeout. And since it's created here without retry: false, the mutation retries too.
  2. await new Promise(r => setTimeout(r, 300)). A fixed wait: always slow, sometimes not enough, and it doesn't say what it's waiting for. It's the direct cause of the flakiness.
  3. fireEvent instead of userEvent. fireEvent.change sets the value without firing the real sequence, and fireEvent.click would press the button even if it were disabled during submission. Also, the form doesn't get filled in fully, so validation would reject it.
  4. Two assertions inside a single waitFor, and getByText('Create booking') instead of a role-based query. The first makes the failure undiagnosable; the second would match any loose text that happens to coincide, not necessarily the button.

Rewritten:

test('creates the booking and clears the form', async () => {
  const user = userEvent.setup();
  renderWithProviders(<NewBookingPage />, { initialState: ANA_SESSION });

  // Waiting for the DATA, not for a fixed time: the options arrive from the API
  await screen.findByRole('option', { name: /Classic Urban/ });

  await user.selectOptions(screen.getByLabelText('Bike'), 'bici-001');
  await user.type(screen.getByLabelText('Booking start'), '2026-05-04T10:00');
  await user.clear(screen.getByLabelText('Duration (hours)'));
  await user.type(screen.getByLabelText('Duration (hours)'), '2');
  await user.click(screen.getByRole('checkbox', { name: /I accept .*terms/ }));

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

  // One single wait; the rest synchronous
  expect(await screen.findByRole('status')).toHaveTextContent(/Booking created/);
  expect(screen.getByLabelText('Bike')).toHaveValue('');
});

renderWithProviders supplies the QueryClient with retry: false, the store with Ana's session, and the router, solving the first problem without writing anything.

Solution 3.

// src/hooks/useOnlineStatus.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { useOnlineStatus } from './useOnlineStatus.js';

describe('useOnlineStatus', () => {
  afterEach(() => vi.restoreAllMocks());

  test('returns true when the browser is online', () => {
    vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);

    const { result } = renderHook(() => useOnlineStatus());

    expect(result.current).toBe(true);
  });

  test('returns false when the browser starts out offline', () => {
    vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);

    const { result } = renderHook(() => useOnlineStatus());

    expect(result.current).toBe(false);
  });

  test('switches to false on receiving the offline event', () => {
    vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
    const { result } = renderHook(() => useOnlineStatus());

    // act: the event triggers a setState outside of any interaction
    act(() => {
      window.dispatchEvent(new Event('offline'));
    });

    expect(result.current).toBe(false);
  });

  test('switches back to true on receiving the online event', () => {
    vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
    const { result } = renderHook(() => useOnlineStatus());

    act(() => {
      window.dispatchEvent(new Event('online'));
    });

    expect(result.current).toBe(true);
  });

  test('removes the listeners on unmount', () => {
    const removeListenerSpy = vi.spyOn(window, 'removeEventListener');

    const { unmount } = renderHook(() => useOnlineStatus());
    unmount();

    expect(removeListenerSpy).toHaveBeenCalledWith('online', expect.any(Function));
    expect(removeListenerSpy).toHaveBeenCalledWith('offline', expect.any(Function));
  });
});

Notes on the decisions:

  • vi.spyOn(navigator, 'onLine', 'get') with the third argument 'get' intercepts the property's getter, which is what lets you control it: navigator.onLine is read-only, and a direct assignment doesn't work.
  • act around dispatchEvent is necessary because the listener calls a state updater outside of any userEvent interaction. It's the same case as section 11.
  • The last test is the exception that proves the rule. Spying on removeEventListener is asserting on the implementation, and it's generally avoided. It's justified here because a leaked listener has no observable manifestation from outside the component, and it's a real bug that accumulates when the screen gets mounted and unmounted many times. When there's no observable behavior to check and the risk is real, it's acceptable to drop down a rung — but it's worth knowing you're doing it.

Conclusion

This lesson has covered the ground where most front-end tests break, and it's left the project's whole simulated-network infrastructure in place.

The essentials. A naive test over async code always fails, because the assertion runs on the first render, while the component is still painting the skeleton. The fix is never a manual setTimeout — slow, flaky, and silent about what it's waiting for — but waiting for the result with Testing Library's three tools: findBy* by default, waitFor when what changes isn't an element's presence, and waitForElementToBeRemoved to check that the loading indicator goes away. With waitFor's two rules: no side effects inside, and a single assertion per call. The pattern repeated throughout the lesson is one wait at the start, synchronous assertions after.

The act(...) warning is no longer a mystery: it means a state change happened outside the test's control, almost always because a wait was missing. render, userEvent, findBy, and waitFor already wrap their work in act, so wrapping more by hand masks the cause instead of fixing it. The only legitimate explicit use of act is calling a hook's updater directly in renderHook, or firing a fake timer.

For the network, the decision is to intercept at the protocol level, not the module level: vi.mocking the API layer leaves the URL, the serialization, and the status codes untested, and stays green when the real API changes. MSW v2 makes fetch get called for real and receive a real Response. src/tests/handlers.js is now settled — with http.get/http.post/http.patch for /bicicletas, /bicicletas/:id, /estaciones, /estaciones/:id, and /reservas, mirroring json-server's query-string filters and exporting BIKES, STATIONS, and BOOKINGS — and so is src/tests/server.js with setupServer. The hookup in setup.js is an inseparable trio: listen({ onUnhandledRequest: 'error' }) in beforeAll, resetHandlers() in afterEach, and close() in afterAll; without the second one, a forced 500 contaminates later tests in an apparently random way.

With server.use, you can trigger at will the scenarios nobody checks by hand: a 500 error with its message and its retry button, a { once: true } handler to verify that the retry recovers, an empty list with its own message, and a slow response with delay — controlling latency on the mock server and always waiting for the result, never the clock. For TanStack Query, the two rules are a fresh QueryClient per test (a shared cache makes the result depend on execution order) and retry: false (with the default retries, every error test blows through the timeout). And a mutation gets tested in full: what body gets sent — captured in the handler — what the user sees while it's sending and once it finishes, what happens if it fails, and above all that the list refreshes after the invalidation, counting requests against a stateful handler that acts as a miniature server.

Custom hooks get tested with renderHook, which exposes the value in result.current and supports rerender with new props and a wrapper with providers. useToggle showed the use of act and a test genuinely worth writing about identity: that the actions stay stable across renders, because an effect not re-running in a loop depends on it. useDebounce combined fake timers with React — act(() => vi.advanceTimersByTime(n)) always together, useRealTimers in afterEach, and shouldAdvanceTime: true or advanceTimers so userEvent doesn't lock up — including the test that the pending timer gets cancelled on unmount. useLocalStorage justified its try/catches by triggering corrupted JSON and an exhausted quota with vi.spyOn(Storage.prototype, 'setItem'). And ErrorBoundary taught you to silence expected console noise only in the tests that trigger the error, never globally, with restoreAllMocks as mandatory.

All of it boils down to the anti-flakiness table: never a fixed wait, wait for what's visible, a fresh client and store per test, retry: false, resetHandlers, onUnhandledRequest: 'error', clean storage, a frozen date with an advancing clock, one assertion per waitFor, and no shared mutable variables. And the diagnosis for a flaky test: run it alone, then accompanied; if it passes alone and fails in the suite, it's an isolation problem; if it fails intermittently either way, it's a waiting problem.

Even with all this, there are things jsdom simply can't see. A "Book" button covered by a cookie banner with position: fixed is still clickable in these tests. A redirect after sign-in that lands on the wrong route would go unnoticed if every page gets tested separately. A session that doesn't persist across a reload doesn't show up in an in-memory router. For that you need a real browser, the whole app running, and json-server actually responding. The next lesson sets up Cypress, decides which three CicloUrbano flows are worth that cost — signing in, booking, and cancelling — explains why cy.get(...) doesn't return an element and why await is never used, controls the network with cy.intercept, isolates tests with cy.session and a resettable database, and closes with a GitHub Actions workflow that runs the whole module on every change. The next lesson is End-to-End Testing with Cypress.

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