The previous lesson proved that validateBooking rejects 25 hours and that the bookingsSlice reducer marks a booking as cancelled without deleting it. None of that guarantees that a CicloUrbano user sees the error message next to the duration field, or that the "Book" button is disabled for a bike in maintenance, or that clicking the "Electric" filter notifies the parent. That other half — the visible one, the one the user touches — is what this lesson covers, and it's where the testing trophy puts its greatest weight.

The tool is React Testing Library, and its value isn't in the code it saves but in the discipline it imposes: it forces you to find elements the way a person finds them — by role, by label, by text — instead of by CSS classes or DOM structure. That has a consequence worth announcing up front: all the accessibility work from Module 3 was, without saying so, the preparation for this. The label htmlFors, the roles, the accessible names, the aria-describedby that ties an error to its field, the role="alert" on the messages… all of that, justified back there for screen readers, is exactly the same mechanism Testing Library uses to find things. An accessible component is an easy component to test, and an inaccessible one is nearly impossible to test well. It isn't a coincidence: it's the same principle, twice.

Contents

  1. The philosophy of Testing Library
  2. jsdom: what it gives you and what it doesn't
  3. render and screen, and cleanup between tests
  4. The three query families: getBy, queryBy, findBy
  5. Query priority, and why getByRole comes first
  6. Debugging a failing query
  7. Interaction: userEvent vs. fireEvent
  8. jest-dom assertions
  9. StatusBadge: a presentational component
  10. BikeCard: props, callbacks, and states
  11. TypeSelector: testing a controlled component
  12. BookingForm: the full case
  13. The renderWithProviders utility, with the real providers
  14. Components that depend on the route
  15. What you should never test

  1. The philosophy of Testing Library

Testing Library boils down to a sentence from its author, Kent C. Dodds, worth keeping in mind while writing every query:

The more your tests resemble the way your software is used, the more confidence they can give you.

Everything else follows from that. A CicloUrbano user doesn't know a class called .card_a3f9x exists, or that the internal state is called chosenType, or that the second child of the third div holds the price. What a user perceives is: there's a button that says "Book," there's a field labeled "Duration (hours)," there's a text that says "In maintenance." And what a user does is: click, type, tab.

That's why the library offers no way to reach a component's state, props, or internal methods. It isn't a gap: it's the main feature. The previous standard tool, Enzyme, did allow it (wrapper.state(), wrapper.instance(), wrapper.find(MyComponent)), and the result was millions of brittle tests that broke with every refactor. Testing Library removed the temptation by removing the possibility.

What it does offer:

Testing Library gives you Testing Library does not give you
Rendering a component into a real jsdom DOM Access to state or props
Finding elements the way a person or a screen reader would Access to the component instance
Simulating real interactions (userEvent) Shallow rendering
Waiting for something to appear or disappear Counting renders
Assertions on the DOM (jest-dom) Finding by component name

That "no shallow rendering" deserves a note. In Enzyme it was common to render a component without its children, replacing them with placeholders. Testing Library always renders the full tree, and rightly so: if BikeCard paints a StatusBadge inside it, what matters is that the user sees "In maintenance," not that an element called StatusBadge exists. That turns almost every component test into an integration test, which is exactly what the trophy from 09-01 wants.

  1. jsdom: what it gives you and what it doesn't

jsdom is an implementation of the DOM and HTML standards written in pure JavaScript, run under Node. When vite.config.js declares environment: 'jsdom', every test file gets a window, a document, a localStorage, a history, and the whole DOM API, without opening a browser.

It's fast — mounting a component costs a few dozen milliseconds — but it's a simulation, and knowing its limits explains quite a few surprises:

Works in jsdom Does not work in jsdom
DOM structure, attributes, events Real layout: there's no layout engine
localStorage, sessionStorage getBoundingClientRect() returns all zeros
history.pushState and the navigation API window.location.assign and real navigation
fetch (Node 18+) IntersectionObserver, ResizeObserver, matchMedia (need to be mocked)
crypto.randomUUID CSS animations and transitions
Computing inline-declared styles Full CSS Modules cascade, or external stylesheets
Keyboard, mouse, and focus events Scrolling (scrollTo does nothing)

Practical consequences for CicloUrbano:

  • toBeVisible() doesn't check real visibility. It checks display: none, visibility: hidden, hidden, and opacity: 0 declared inline or in styles jsdom has processed. An element covered by another one with position: absolute is considered visible. That kind of failure is only ever caught by an end-to-end test (09-05).
  • CSS Modules classes exist as strings, but apply no style. One more reason, alongside the one in section 15, never to assert on them.
  • If a component uses IntersectionObserver — typical of lazy-loaded images — it has to be provided in config.js, or the test will fail with "is not defined."
  • Real navigation doesn't exist. That's why components that route are tested with createMemoryRouter (section 14), which keeps the route in memory.

  1. render and screen, and cleanup between tests

import { render, screen } from '@testing-library/react';
import StatusBadge from './StatusBadge.jsx';

test('shows the status text', () => {
  render(<StatusBadge status="disponible" />);
  expect(screen.getByText('Available')).toBeInTheDocument();
});

What each piece does:

  • render(element) creates a container <div>, appends it to document.body, and mounts the React tree there. It returns an object with utilities, of which in practice only three get used: rerender (re-render with different props), unmount (unmount, useful for testing effect cleanups), and container (the root node, which is almost never needed).
  • screen is an object with every query already bound to document.body. It's the recommended way: screen.getByRole(...) instead of destructuring const { getByRole } = render(...). There are two reasons: you don't have to maintain a growing list of destructured queries per test, and screen also finds what gets rendered outside the container, like a modal mounted through a portal — exactly the case of Modal and BookingDialog in CicloUrbano.

On cleanup: Testing Library unmounts the tree and empties the body automatically after every test, as long as the environment has the global hooks (globals: true in Vitest, which is the case here). Without that cleanup, the second test would find two "Book" buttons — its own and the previous test's — and getByRole would fail with "found multiple elements." This was already made explicit in src/tests/setup.js back in 09-01, along with localStorage.clear().

  1. The three query families: getBy, queryBy, findBy

This is the first decision behind every line of a test, and getting it wrong produces confusing error messages. There are three families, and each answers a different question:

Family If it finds If it doesn't find If it finds several Async? When to use it
getBy… Returns the element Throws an error with the DOM dumped Throws No "This must be here right now"
queryBy… Returns the element Returns null Throws No "This must not be here"
findBy… Returns a resolved promise Rejects after 1000 ms Rejects Yes "This will appear" (async)

And each has a plural variant, which returns an array and doesn't fail when it finds several:

Plural If it finds none
getAllBy… Throws
queryAllBy… Returns []
findAllBy… Rejects after the timeout

The usage rules, with the typical mistakes:

// ✅ It exists: getBy. If it's missing, the error includes the full DOM and debugs itself
expect(screen.getByRole('button', { name: 'Book' })).toBeInTheDocument();

// ✅ It doesn't exist: queryBy. It's the ONLY family that can return null without failing
expect(screen.queryByRole('button', { name: 'Cancel booking' })).not.toBeInTheDocument();

// ❌ WRONG: getBy throws before reaching the assertion; the error will say "not found,"
//           which is confusing when what you actually wanted was to confirm it's absent
expect(screen.getByRole('button', { name: 'Cancel booking' })).not.toBeInTheDocument();

// ✅ It will appear after a load: findBy (and it's awaited)
expect(await screen.findByText('Classic Urban')).toBeInTheDocument();

// ❌ WRONG: without await, the assertion receives a promise, which is always "truthy"
expect(screen.findByText('Classic Urban')).toBeInTheDocument();

This lesson will use getBy and queryBy almost exclusively, since there's no network asynchrony yet: findBy is the star of 09-04.

One nuance about getAllBy, which shows up as soon as you test a list:

test('paints one card per bike', () => {
  render(<BikeList bikes={BIKES} />);

  // The models are <h3> headings inside each <article>
  expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(5);
});

  1. Query priority, and why getByRole comes first

Testing Library defines an explicit order of preference, and following it isn't purism: every step you go down moves the test further from what the user perceives and closer to implementation details.

# Query What it looks for When to use it
1 getByRole The accessibility role, usually with { name } Whenever possible. It's what a screen reader sees
2 getByLabelText The element associated with a <label> Form fields. The natural case for the label htmlFor from 03-06
3 getByPlaceholderText The placeholder attribute Only if the field has no label — which is already an accessibility failure
4 getByText Text content Non-interactive elements: paragraphs, messages, headings
5 getByDisplayValue A field's current value Checking an already-filled form
6 getByAltText An image's alt Images
7 getByTitle The title attribute Unreliable: not announced consistently
8 getByTestId data-testid Last resort, when there's nothing semantic to hold on to

Why getByRole is the first choice

Because a query by role checks two things at once:

screen.getByRole('button', { name: 'Book' })
  1. That an element with a button role exists — a <button>, or something with role="button" — meaning it's genuinely clickable and focusable.
  2. That its accessible name is "Book," the text a screen reader would announce.

If someone replaces the <button> with a <div onClick>, this query fails. And it should fail, because that change breaks the keyboard and screen readers — exactly the failure fixed in 03-06 with the clickable card. A test written with roles is also an accessibility test, for free.

The accessible name is computed, in this order: aria-labelledby, aria-label, the element's text content, the associated <label>, title. That's why it works the same for all three of these:

<button>Book</button>
<button aria-label="Book">🚲</button>
<button aria-labelledby="action-title">🚲</button>

The most common roles in CicloUrbano:

HTML element Implicit role Project example
<button> button "Book," "View details," ThemeButton
<a href> link The links in Breadcrumbs and Header
<h1><h6> heading (with level) The model in BikeCard is a level-3 heading
<input type="text"> textbox The search field
<input type="checkbox"> checkbox "I accept the terms"
<input type="number"> spinbutton "Duration (hours)"
<select> combobox The form's "Bike"
<option> option Every available bike
<ul> / <li> list / listitem BikeList
<form> with an accessible name form BookingForm
<nav> navigation The main navigation
<article> article Every BikeCard
Element with role="alert" alert The form's error messages
Element with role="status" status The aria-live region for notices

Notice that <input type="number"> has role spinbutton, not textbox: it's one of the most misleading ones.

Useful getByRole options

screen.getByRole('button', { name: 'Book' })                  // exact name
screen.getByRole('button', { name: /book/i })                 // regular expression: survives case changes
screen.getByRole('heading', { level: 3 })                      // only the h3s
screen.getByRole('button', { name: 'Book', hidden: true })     // includes elements hidden from accessibility
screen.getAllByRole('listitem')                                // every <li>
screen.getByRole('checkbox', { checked: true })                // by its state

The regular expression variant deserves a comment: { name: /book/i } survives a change in casing, or the design adding an icon next to the label. It's how you avoid pinning yourself to literal text when the text can change without the behavior changing.

When it's legitimate to drop down to data-testid

getByTestId is the last rung, and using it too soon cancels out the library's main advantage: a data-testid doesn't check accessibility, doesn't check semantics, and doesn't break when it should. But there are three cases where it's the right answer:

  1. Elements with no role or stable text: a layout container, a chart, a canvas. In CicloUrbano, PageSkeleton is exactly that: it has no text because it's a gray silhouette.
  2. Dynamic text that changes often for editorial reasons and would make the test brittle.
  3. End-to-end tests, where data-testid is the explicit contract between the interface and the tests. This is argued in full in 09-05.
// Legitimate: the skeleton has no text and no role
<div className={styles.skeleton} data-testid="esqueleto-pagina" aria-hidden="true" />
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();

Before writing a data-testid, ask yourself: how would a blind person find this element? If there's no answer, the problem isn't the test: it's the component.

  1. Debugging a failing query

When getByRole finds nothing, Testing Library dumps the entire DOM into the error message. That dump is the main debugging tool, and it's worth learning to read it:

TestingLibraryElementError: Unable to find an accessible element with the role "button"
and name "Book"

Here are the accessible roles:
  article:
    Name "":
    <article class="card_a3f9x" />
  heading:
    Name "Classic Urban":
    <h3 />
  button:
    Name "View details":
    <button type="button" />
    Name "Book a bike":        ← the real name is different!
    <button type="button" />

There's the diagnosis: the button exists and its role is correct, but its accessible name is "Book a bike," not "Book." The fix is { name: /book/i } or the full name.

The three debugging tools, in order of usefulness:

import { render, screen, logRoles } from '@testing-library/react';

test('debugging', () => {
  const { container } = render(<BikeCard bike={BIKE} />);

  // 1) Dump the full DOM, formatted and colored
  screen.debug();

  // 2) Dump just a piece of it
  screen.debug(screen.getByRole('article'));

  // 3) List EVERY available role and its accessible name: the most useful of the three
  logRoles(container);
});
Tool What it gives you When
screen.debug() The rendered HTML, formatted "Did it even render?"
screen.debug(element) Just that subtree When the DOM is large and the dump is unreadable
logRoles(container) The role tree with its accessible names "Why isn't it finding my button?" Solves most cases
Testing Playground A web UI that suggests the best query for each element When writing the first test for a new component

The Testing Playground is used two ways: as a browser extension over the running app, or from inside the test with screen.logTestingPlaygroundURL(), which prints a link with the current DOM already loaded. You point at an element and it tells you the recommended query, ordered by priority. It's the fastest way to internalize the table from section 5.

One configuration detail: by default screen.debug() truncates the dump at 7,000 characters. For large pages:

// vite.config.js, inside test
test: { environment: 'jsdom', globals: true, setupFiles: '…' }
// or ad hoc in the test
screen.debug(undefined, 30000);

  1. Interaction: userEvent vs. fireEvent

There are two ways to simulate an interaction, and the difference matters more than it looks.

fireEvent fires one DOM event, exactly the one you ask for:

fireEvent.click(button);       // fires a single 'click' event
fireEvent.change(field, { target: { value: 'urbana' } });   // a single 'change'

userEvent simulates the full sequence of events that action produces in a real browser:

await user.click(button);
// fires, in order: pointerover, pointerenter, pointermove, pointerdown,
// mousedown, focus, pointerup, mouseup, click
fireEvent userEvent
What it fires An isolated event The full real sequence
Focus Doesn't move it Moves it, like a real click
Disabled elements Fires anyway Does nothing, like in a browser
Typing text Sets the value all at once Key by key, with keydown/keypress/input/keyup
Detects accessibility failures No Yes (pointer-events: none, hidden elements)
API Synchronous Asynchronous: must be awaited
When to use it Rare cases userEvent doesn't cover By default, always

The row that settles it is the one about disabled elements. With fireEvent.click(disabledButton) the handler runs and the test passes, even though in the real app that click does nothing: the test lies. userEvent reproduces browser behavior and doesn't call the handler, which is exactly what needs verifying on BikeCard with a bike in maintenance.

Same with typing: fireEvent.change sets the value in one shot, so a component with useDebounce or with keystroke-level validation doesn't behave as it does in production. userEvent.type types for real.

The userEvent API

import userEvent from '@testing-library/user-event';

test('a full interaction', async () => {
  // setup() must be called BEFORE render, once per test
  const user = userEvent.setup();
  render(<BookingForm bikes={BIKES} />);

  await user.click(screen.getByRole('button', { name: 'Book' }));
  await user.type(screen.getByLabelText('Duration (hours)'), '3');
  await user.clear(screen.getByLabelText('Duration (hours)'));
  await user.selectOptions(screen.getByLabelText('Bike'), 'bici-001');
  await user.tab();                                     // moves focus to the next element
  await user.keyboard('{Escape}');                       // presses Escape
  await user.keyboard('urbana{Enter}');                  // types and presses Enter
  await user.hover(screen.getByRole('article'));
  await user.dblClick(screen.getByRole('button', { name: 'View details' }));
});
Method What it simulates
click(el) A full click, focus included
dblClick(el) A double click
type(el, text) Typing key by key into a focused field
clear(el) Selecting everything and deleting it
selectOptions(select, value) Choosing one or several options
tab() Advancing focus. tab({ shift: true }) goes back
keyboard('{Enter}') Standalone keystrokes
hover(el) / unhover(el) Moving the pointer in and out
upload(input, file) Uploading a file

Why everything is asynchronous and has to be awaited. Since version 14, userEvent returns promises for two reasons: internally it introduces pauses between the events in the sequence to resemble a real person, and it wraps updates in act so React processes the state change before returning control. If you forget the await, the assertion runs before React has repainted and you see the old state. With user.type the effect is even more visible: letters go missing.

// ❌ Without await: the assertion runs before React updates
user.click(button);
expect(onBook).toHaveBeenCalled();     // fails intermittently

// ✅ With await
await user.click(button);
expect(onBook).toHaveBeenCalled();

Mechanical rule: if the line starts with user., it gets an await.

  1. jest-dom assertions

@testing-library/jest-dom, registered in src/tests/setup.js since 09-01, adds matchers that speak DOM. Without them you'd have to write expect(el.disabled).toBe(true); with them, expect(el).toBeDisabled(), which also produces a far clearer error message.

Matcher Checks Example in CicloUrbano
toBeInTheDocument() The element is in the document expect(screen.getByText('Available')).toBeInTheDocument()
toBeVisible() It's there and visible (with the limits from section 2) The panel after opening the accordion
toBeDisabled() / toBeEnabled() The disabled or aria-disabled attribute The "Book" button on a bike in maintenance
toHaveValue(v) A field's value expect(hoursField).toHaveValue(2)
toHaveDisplayValue(v) A select's displayed value The chosen option
toBeChecked() A checkbox or radio is checked "I accept the terms"
toHaveTextContent(t) Contains that text expect(card).toHaveTextContent('€2.50')
toHaveAttribute(a, v) Has the attribute expect(field).toHaveAttribute('aria-invalid', 'true')
toHaveAccessibleName(n) Its accessible name is that expect(button).toHaveAccessibleName('Book')
toHaveAccessibleDescription(d) Its accessible description (via aria-describedby) The error message tied to its field
toHaveClass(c) Has that class Avoid with CSS Modules (section 15)
toHaveFocus() Has focus After user.tab()
toBeRequired() Is required The form's fields
toBeInvalid() / toBeValid() aria-invalid or native validation The field with an error
toBeEmptyDOMElement() Has no children The aria-live region before the first notice

The two that pay off the most and get used the least are toHaveAccessibleName and toHaveAccessibleDescription, because they check directly what a screen reader would announce. The second one is the right way to check BookingForm's aria-describedby, and it gets used in section 12.

  1. StatusBadge: a presentational component

Let's start with the simplest case. StatusBadge receives a status and paints it through three channels: color (a class), symbol (aria-hidden), and text.

// src/components/StatusBadge.test.jsx
import { render, screen } from '@testing-library/react';
import StatusBadge from './StatusBadge.jsx';

describe('StatusBadge', () => {
  test.each([
    ['disponible',    'Available'],
    ['alquilada',     'Rented'],
    ['mantenimiento', 'In maintenance']
  ])('with status "%s" shows the text "%s"', (status, text) => {
    render(<StatusBadge status={status} />);
    expect(screen.getByText(text)).toBeInTheDocument();
  });

  test('shows a fallback text for an unknown status', () => {
    render(<StatusBadge status="teletransportada" />);
    expect(screen.getByText('Unknown status')).toBeInTheDocument();
  });

  test('uses "disponible" when it gets no prop', () => {
    render(<StatusBadge />);
    expect(screen.getByText('Available')).toBeInTheDocument();
  });

  test('the decorative symbol is hidden from assistive technologies', () => {
    render(<StatusBadge status="mantenimiento" />);

    // The badge's accessible name must NOT include the symbol:
    // that's why it carries aria-hidden, per 03-06
    expect(screen.getByText('In maintenance')).toBeInTheDocument();
    expect(screen.queryByText('🔧')).not.toBeInTheDocument();
  });
});

Notes on the decisions here:

  • test.each for the three statuses avoids three identical tests and turns adding a fourth status into adding one row.
  • The unknown-status case tests the component's ?? UNKNOWN fallback. It's a branch a user should never see, and precisely for that reason no one would check it by hand.
  • The last test checks accessibility, not appearance. queryByText('🔧') returns null because the <span aria-hidden="true"> is excluded from the accessibility tree and text queries respect that. If someone removed the aria-hidden, this test would fail and would flag that a screen reader would start reading "wrench In maintenance."
  • No CSS class gets checked. Whether the color is green or red isn't verifiable in jsdom, and it isn't this test's job.

  1. BikeCard: props, callbacks, and states

Two new things show up here: checking that the parent gets notified, and checking a conditional behavior.

// src/components/BikeCard.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import BikeCard from './BikeCard.jsx';

const AVAILABLE = {
  id: 'bici-001', model: 'Classic Urban', type: 'urbana',
  status: 'disponible', stationId: 'est-01', pricePerHour: 2.5
};

const IN_MAINTENANCE = {
  id: 'bici-003', model: 'Cargo Max', type: 'carga',
  status: 'mantenimiento', stationId: 'est-02', pricePerHour: 5.5
};

// Prop factory: the callbacks are mocked and returned so you can assert on them
function renderCard(bike, extra = {}) {
  const props = {
    bike,
    stationName: 'Main Square',
    onSelect: vi.fn(),
    onBook: vi.fn(),
    ...extra
  };
  render(<BikeCard {...props} />);
  return props;
}

describe('BikeCard', () => {
  test('shows the model, the station, the status, and the price per hour', () => {
    renderCard(AVAILABLE);

    expect(screen.getByRole('heading', { level: 3, name: 'Classic Urban' })).toBeInTheDocument();
    expect(screen.getByText('Main Square')).toBeInTheDocument();
    expect(screen.getByText('Available')).toBeInTheDocument();
    // Intl's formatter is what actually renders the price, so the test targets the digits
    expect(screen.getByText(/€2\.50\s*\/\s*hour/)).toBeInTheDocument();
  });

  test('notifies with the id when "View details" is clicked', async () => {
    const user = userEvent.setup();
    const { onSelect, onBook } = renderCard(AVAILABLE);

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

    expect(onSelect).toHaveBeenCalledTimes(1);
    expect(onSelect).toHaveBeenCalledWith('bici-001');
    expect(onBook).not.toHaveBeenCalled();      // the other one doesn't fire by mistake
  });

  test('notifies with the id when booking an available bike', async () => {
    const user = userEvent.setup();
    const { onBook } = renderCard(AVAILABLE);

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

    expect(onBook).toHaveBeenCalledWith('bici-001');
  });

  describe('when the bike is not available', () => {
    test('disables the "Book" button', () => {
      renderCard(IN_MAINTENANCE);
      expect(screen.getByRole('button', { name: 'Book' })).toBeDisabled();
    });

    test('clicking "Book" doesn\'t notify the parent', async () => {
      const user = userEvent.setup();
      const { onBook } = renderCard(IN_MAINTENANCE);

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

      expect(onBook).not.toHaveBeenCalled();
    });

    test('"View details" still works', async () => {
      const user = userEvent.setup();
      const { onSelect } = renderCard(IN_MAINTENANCE);

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

      expect(onSelect).toHaveBeenCalledWith('bici-003');
    });
  });
});

What to take away from this block:

  • The renderCard factory plays the same role here as validData did in 09-02: each test states only what makes it distinct, and the mocked callbacks stay available to assert on.
  • The "clicking doesn't notify" test is what justifies userEvent. With fireEvent.click on a disabled button, the handler would run and onBook would have been called: the test would fail even though the code is correct. It's the concrete case from the table in section 7.
  • The negative assertion expect(onBook).not.toHaveBeenCalled() in the "View details" test looks redundant, but it catches a real and common bug: two handlers swapped. It follows 09-02's rule of pairing the positive with the negative.
  • The price is asserted with a regular expression, not the exact string '€2.50 / hour'. Intl.NumberFormat decides the exact formatting, and pinning a test to a hard-coded string invites a confusing, fragile failure. The regular expression with \s* is immune to it.
  • At no point is it checked that the component is memoized with memo. The memo from 08-02 is an optimization, not a behavior: testing it would mean testing the implementation.

  1. TypeSelector: testing a controlled component

TypeSelector became controlled back in Module 4: it no longer stores the type itself, it receives it via props and notifies changes. That distinction completely changes what needs testing.

// src/components/TypeSelector.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import TypeSelector from './TypeSelector.jsx';

describe('TypeSelector', () => {
  test('shows one button per available type', () => {
    render(<TypeSelector chosenType="todos" onTypeChange={vi.fn()} />);

    expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Urban' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Electric' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Cargo' })).toBeInTheDocument();
  });

  test('notifies the parent with the chosen type when a filter is clicked', async () => {
    const user = userEvent.setup();
    const onTypeChange = vi.fn();
    render(<TypeSelector chosenType="todos" onTypeChange={onTypeChange} />);

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

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

  test('reflects the active type it receives via props', () => {
    render(<TypeSelector chosenType="carga" onTypeChange={vi.fn()} />);

    // The active state is expressed with aria-pressed, not a CSS class
    expect(screen.getByRole('button', { name: 'Cargo', pressed: true })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Urban', pressed: false })).toBeInTheDocument();
  });

  test('does NOT change the active filter on its own: it\'s a controlled component', async () => {
    const user = userEvent.setup();
    render(<TypeSelector chosenType="todos" onTypeChange={vi.fn()} />);

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

    // The parent hasn't changed the prop, so "All" is still the active one.
    // This is correct, and it's THE defining trait of a controlled component.
    expect(screen.getByRole('button', { name: 'All', pressed: true })).toBeInTheDocument();
  });

  test('goes back to "todos" when Escape is pressed', async () => {
    const user = userEvent.setup();
    const onTypeChange = vi.fn();
    render(<TypeSelector chosenType="electrica" onTypeChange={onTypeChange} />);

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

    expect(onTypeChange).toHaveBeenLastCalledWith('todos');
  });
});

The fourth test is the one that teaches the most. It could look like a bug — "I click Urban and it doesn't get marked active" — but it's exactly the contract of a controlled component: the source of truth lives in the parent. Documenting it with a test stops someone from "fixing" the component by reintroducing internal state and breaking the sync with Redux and the URL.

And notice how the active filter is checked: with { pressed: true }, which queries the aria-pressed attribute. Not a word about styles.active. If the design changes the active button's color, the test stays green; if the button stops announcing its state, it fails.

On useTransition: TypeSelector wraps the change in a transition so it doesn't block typing (08-03). In jsdom, transitions resolve synchronously inside the act that userEvent already wraps things in, so it requires no special treatment. It's a good reminder of the principle: the optimization is invisible to the test because it's invisible to the user.

  1. BookingForm: the full case

This is the component that benefits most from an integration test, because it combines state, derived values, accessibility, and an outward-facing callback. And because, as it stands, no unit test can guarantee the error is seen.

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

const BIKES = [
  { id: 'bici-001', model: 'Classic Urban', type: 'urbana',    status: 'disponible',    stationId: 'est-01', pricePerHour: 2.5 },
  { id: 'bici-003', model: 'Cargo Max',     type: 'carga',     status: 'mantenimiento', stationId: 'est-02', pricePerHour: 5.5 },
  { id: 'bici-005', model: 'Electric Pro',  type: 'electrica', status: 'disponible',    stationId: 'est-02', pricePerHour: 4.0 }
];

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

  afterEach(() => vi.useRealTimers());

  test('only offers the available bikes', () => {
    render(<BookingForm bikes={BIKES} />);

    const select = screen.getByLabelText('Bike');
    // 2 available + the initial empty option
    expect(screen.getAllByRole('option')).toHaveLength(3);
    expect(select).toHaveDisplayValue('— Choose a bike —');
    expect(screen.queryByRole('option', { name: /Cargo Max/ })).not.toBeInTheDocument();
  });

  test('shows no errors before the fields are touched', () => {
    render(<BookingForm bikes={BIKES} />);
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });
});

useFakeTimers({ shouldAdvanceTime: true }) deserves an explanation: the system date gets frozen so the "not in the past" rule is deterministic, but the clock is left to advance on its own, because userEvent introduces internal waits between events and with a fully stopped clock it would just hang. It's a combination worth knowing: fixed date + advancing clock.

Submitting empty and seeing all the errors

test('submitting empty shows all four errors and never calls the parent', async () => {
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  const onCreateBooking = vi.fn();
  render(<BookingForm bikes={BIKES} onCreateBooking={onCreateBooking} />);

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

  // role="alert" on every message: that's how a screen reader user finds them
  const errors = screen.getAllByRole('alert');
  expect(errors).toHaveLength(4);

  expect(screen.getByText(/Choose a bike/)).toBeInTheDocument();
  expect(screen.getByText(/Tell us when the booking starts/)).toBeInTheDocument();
  expect(screen.getByText(/You must accept the terms/)).toBeInTheDocument();

  expect(onCreateBooking).not.toHaveBeenCalled();
});

Querying the errors with getAllByRole('alert') instead of by text is a deliberate choice: it checks at once that the messages exist and that they're marked as alerts, which is what makes a screen reader announce them the moment they appear. Once again, it's accessibility and testing at the same time.

The error tied to its field through aria-describedby

This is the component's most valuable test, and the one that ties directly back to 03-06:

test('ties the duration error to the field through its accessible description', async () => {
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  render(<BookingForm bikes={BIKES} />);

  const hoursField = screen.getByLabelText('Duration (hours)');

  await user.clear(hoursField);
  await user.type(hoursField, '25');
  await user.tab();                          // leaving the field marks it as "touched"

  // 1) The field is announced as invalid
  expect(hoursField).toBeInvalid();
  expect(hoursField).toHaveAttribute('aria-invalid', 'true');

  // 2) And its accessible description includes the message: that's what
  //    a screen reader reads when the field gets focus. Checks the full aria-describedby.
  expect(hoursField).toHaveAccessibleDescription(/The maximum booking is 24 hours/);
});

toHaveAccessibleDescription resolves the aria-describedby, follows the ids — including the string 'hours-help hours-error' — and checks the resulting text. A single assertion verifies that the error exists, that it's in the DOM, that it has the right id, and that the field references that id. Checking it with getByText alone would only have verified the first of those, and the actual most common failure — a visible error tied to the wrong field — would have slipped through unnoticed.

The full happy path

test('creates the booking with the entered data and clears the form', async () => {
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  const onCreateBooking = vi.fn();
  render(
    <BookingForm bikes={BIKES} userId="usr-01" onCreateBooking={onCreateBooking} />
  );

  await user.selectOptions(screen.getByLabelText('Bike'), 'bici-005');

  const dateField = screen.getByLabelText('Booking start');
  await user.type(dateField, '2026-05-04T10:00');

  const hoursField = screen.getByLabelText('Duration (hours)');
  await user.clear(hoursField);
  await user.type(hoursField, '3');

  await user.click(screen.getByRole('checkbox', { name: /I accept .*terms/ }));

  // The derived total appears once the data is valid: €4.00 × 3 h = €12.00
  expect(screen.getByText(/€12\.00/)).toBeInTheDocument();

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

  expect(onCreateBooking).toHaveBeenCalledTimes(1);
  expect(onCreateBooking).toHaveBeenCalledWith(
    expect.objectContaining({
      id: expect.stringMatching(/^res-[a-z0-9]{8}$/),
      bicicletaId: 'bici-005',
      user: 'usr-01',
      startDate: '2026-05-04T10:00',
      hours: 3,
      status: 'activa'
    })
  );

  // After creating it, the form goes back to its initial state
  expect(screen.getByLabelText('Bike')).toHaveValue('');
  expect(screen.getByRole('checkbox', { name: /I accept .*terms/ })).not.toBeChecked();
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

Important details:

  • expect.objectContaining with stringMatching for the id. The id is generated with crypto.randomUUID(), so it can't be compared against a fixed value. What gets checked is the format, which is what's actually part of the contract. It's 09-02's asymmetric matcher, applied here.
  • The assertion on the total (€12.00) checks a derived value: pricePerHour × hours. It confirms the form computes it correctly without needing to expose the calculation.
  • Checking that the form clears itself is a behavior a user perceives, and one that breaks easily when the submit handler gets refactored.
  • getByRole('checkbox', { name: /I accept .*terms/ }) works because the checkbox sits inside its <label>, so the label's text is its accessible name. Without that wrapping — the accessibility bug fixed in 03-06 — you'd have to fall back to a data-testid.

  1. The renderWithProviders utility, with the real providers

All the examples so far test standalone components. As soon as one consumes useTheme, useSelector, or useQuery, a bare render blows up:

Error: useTheme must be used inside a <ThemeProvider>
Error: could not find react-redux context value
Error: No QueryClient set, use QueryClientProvider

There are two ways out. The bad one: mocking the hooks with vi.mock('react-redux'). The good one: wrapping the component with the real providers. And there are three solid reasons to prefer the second:

Mocking the providers Using the real providers
The test stops checking the integration with Redux/Query, which is where the bugs live It checks the full wiring
The mock has to be kept in sync with the real API There's nothing to keep in sync
A badly written selector still passes the test A badly written selector breaks it
The component might require props the mock hides It's tested the way it's used

That's why the project defines its own renderWithProviders:

// src/tests/utils.jsx
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryRouter, RouterProvider } from 'react-router';

import bookingsReducer from '../features/bookings/bookingsSlice.js';
import catalogueReducer from '../features/catalogue/catalogueSlice.js';
import sessionReducer from '../features/session/sessionSlice.js';
import { ThemeProvider } from '../contexts/ThemeContext.jsx';
import { NoticesProvider } from '../contexts/NoticesContext.jsx';

/**
 * Creates a NEW store per test, with the real reducers.
 * `initialState` lets you put the app into whatever scenario you want to test.
 */
export function createTestStore(initialState = {}) {
  return configureStore({
    reducer: {
      bookings: bookingsReducer,
      catalogue: catalogueReducer,
      session: sessionReducer
    },
    preloadedState: initialState
  });
}

/**
 * Creates a NEW QueryClient per test, with no retries and no persistent cache.
 */
export function createTestClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,           // without this, an error test takes 3 retries to show up
        gcTime: Infinity,       // the cache doesn't get garbage-collected during the test
        staleTime: 0
      },
      mutations: { retry: false }
    }
  });
}

/**
 * Renders a component with ALL of CicloUrbano's real providers.
 *
 * Options:
 *  - initialState: preloaded state for the Redux store
 *  - store / client: your own instances, if the test needs to inspect them
 *  - route: initial route for the in-memory router (defaults to '/')
 *  - routes: a custom route definition, for testing navigation
 *
 * Returns the same thing as `render` PLUS `store` and `client`, so you can
 * dispatch actions or inspect state from the test.
 */
export function renderWithProviders(element, options = {}) {
  const {
    initialState,
    store = createTestStore(initialState),
    client = createTestClient(),
    route = '/',
    routes,
    ...renderOptions
  } = options;

  const routeDefinitions = routes ?? [{ path: '*', element }];
  const router = createMemoryRouter(routeDefinitions, { initialEntries: [route] });

  function Wrapper() {
    return (
      <QueryClientProvider client={client}>
        <Provider store={store}>
          <ThemeProvider>
            <NoticesProvider>
              <RouterProvider router={router} />
            </NoticesProvider>
          </ThemeProvider>
        </Provider>
      </QueryClientProvider>
    );
  }

  return {
    ...render(<Wrapper />, renderOptions),
    store,
    client,
    router
  };
}

// Everything from Testing Library gets re-exported so tests can import from a single place
export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';

Decisions worth understanding:

  • The order of the providers mirrors main.jsx: QueryClientProvider > Provider > Providers > RouterProvider. If the test used a different order, it could pass with wiring that fails in production.
  • A new store and client on every call. This is the direct prevention against the flaky tests from 09-01: a shared QueryClient leaks cached data from one test to the next, and a shared store carries over bookings created in earlier tests.
  • retry: false is mandatory. With the default retries, an error test would wait through three attempts with exponential backoff before showing the message, and it would time out. It's the number-one cause of "my error test hangs."
  • preloadedState lets you set the scenario. Testing an operator's screen is renderWithProviders(<WorkshopPage />, { initialState: { session: { user: MARC, loading: false } } }), with nothing mocked.
  • store, client, and router are returned so you can assert on the resulting state when needed. Sparingly: asserting on the store is asserting on the implementation, and it's only justified when the effect isn't visible on screen.
  • The re-export at the end lets every test file import everything from '../tests/utils.jsx' instead of spreading imports across three packages.

Usage:

import { renderWithProviders, screen, userEvent } from '../tests/utils.jsx';
import BookingsPanel from './BookingsPanel.jsx';

test('shows the bookings for the signed-in user', () => {
  renderWithProviders(<BookingsPanel />, {
    initialState: {
      session: { user: { id: 'usr-01', name: 'Ana Ribera', role: 'cliente' }, loading: false },
      bookings: {
        entities: { 'res-01': { id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01', hours: 2, status: 'activa' } },
        ids: ['res-01'], loadState: 'success', error: null, submitState: 'idle'
      }
    }
  });

  expect(screen.getByRole('heading', { name: /Your bookings/ })).toBeInTheDocument();
  expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
flowchart TB
    P["renderWithProviders(element, options)"] --> Q["QueryClientProvider · new client, retry: false"]
    Q --> R["Provider · new store with preloadedState"]
    R --> S["ThemeProvider"]
    S --> T["NoticesProvider"]
    T --> U["RouterProvider · createMemoryRouter(initialEntries)"]
    U --> V["The component under test"]

  1. Components that depend on the route

Any component using useParams, useNavigate, useSearchParams, <Link>, or <Outlet> needs a router. createMemoryRouter keeps the history in memory, without touching the browser URL — which doesn't really exist in jsdom anyway.

A component that reads a route parameter

import { renderWithProviders, screen } from '../tests/utils.jsx';
import BikeDetailPage from './BikeDetailPage.jsx';

test('shows the bike indicated in the URL', () => {
  renderWithProviders(null, {
    route: '/bicicletas/bici-002',
    routes: [{ path: '/bicicletas/:bicicletaId', element: <BikeDetailPage /> }]
  });

  expect(screen.getByRole('heading', { name: 'Electric Pro' })).toBeInTheDocument();
});

A component that reads the query string

The ?tipo= filter on CataloguePage has lived in the URL since Module 6:

test('applies the type filter that arrives in the URL', () => {
  renderWithProviders(<CataloguePage />, { route: '/?tipo=electrica' });

  expect(screen.getByRole('button', { name: 'Electric', pressed: true })).toBeInTheDocument();
});

A component that navigates

When the action produces a navigation, what gets checked is the destination, not that useNavigate was called:

test('"View details" leads to the bike\'s detail page', async () => {
  const user = userEvent.setup();

  const { router } = renderWithProviders(null, {
    route: '/',
    routes: [
      { path: '/', element: <CataloguePage /> },
      { path: '/bicicletas/:bicicletaId', element: <h1>Bike details</h1> }
    ]
  });

  await user.click(screen.getAllByRole('button', { name: 'View details' })[0]);

  // Two equivalent checks; the first is what the user sees
  expect(screen.getByRole('heading', { name: 'Bike details' })).toBeInTheDocument();
  expect(router.state.location.pathname).toBe('/bicicletas/bici-001');
});

The destination route is replaced by a minimal component (<h1>Bike details</h1>) on purpose: the test verifies the navigation, and mounting the real page would drag in its queries, its dependencies, and its failures, turning a navigation bug into a data-loading bug and ruining the diagnosis.

Compared with the alternative you see often — mocking useNavigate with vi.mock('react-router') and checking it was called with '/bicicletas/bici-001'— this way is better for the usual reason: the mock checks that navigation was requested; the in-memory router checks that navigation happened, including building the right URL and there being a route to serve it.

  1. What you should never test

An operational recap of the principle from 09-01, now with names attached:

❌ Don't test Why ✅ Test this instead
Internal state (chosenType, touched) It's implementation; RTL doesn't even allow it What the state produces on screen
CSS class names (styles.active) They're hashes generated by CSS Modules; they change on their own and apply no style in jsdom The semantic attribute: aria-pressed, aria-invalid, disabled
The number of renders It's performance, not behavior Measure with the Profiler (08-05)
That useSelector or useNavigate got called A library detail The result: what got painted, the route reached
That a component is wrapped in memo An optimization invisible to the user Nothing: it isn't behavior
The exact DOM structure (container.querySelector('div > p')) It breaks with any layout change Queries by role and by text
Non-exported functions If they deserve a test, export them Their effect through the public API
That React Router redirects, that Query caches Third-party code, already tested How your code uses them

And the most reliable warning sign: if you have to read a component's code to know what to query in the test, the test is coupled. A good component test gets written while looking at the screen, not the .jsx file.

Common Mistakes and Tips

  • Using getBy to check something doesn't exist. getBy throws when it doesn't find a match, so the test fails with a misleading message before it ever reaches the .not. For absences, always queryBy.
  • Forgetting await on userEvent. It produces intermittent failures, dropped letters while typing, and act warnings. Rule: any line starting with user. gets an await.
  • Calling userEvent.setup() after render. It has to go first, because setup installs the event configuration on the document. And once per test, never at the describe scope.
  • Reaching for fireEvent out of habit. It lets clicks through on disabled buttons and doesn't move focus, so it validates behaviors that don't happen in production. Only for what userEvent doesn't cover.
  • Comparing formatted text against literal strings. Intl.NumberFormat decides its own punctuation and spacing, and that's an implementation detail you shouldn't hard-code. Use regular expressions with \s*, or toHaveTextContent.
  • Asserting on CSS Modules classes. The real name is card_a3f9x, it changes on every build, and it represents nothing visible in jsdom. If the state matters, express it with an ARIA attribute and check it by role.
  • Starting with getByTestId. It's convenient, and it switches off half the library's value. Only go down the priority ladder when the higher rungs don't exist, and ask yourself first whether the real problem is that the component isn't accessible.
  • Wrapping everything in act by hand. render and userEvent already do it. If the act warning shows up, the cause is usually an unawaited async update, and it's explained in full in 09-04.
  • Tip: write the first assertion before the interaction. Checking the initial state — "there are no errors," "the button is disabled" — documents the starting point and makes it obvious what the interaction changes.
  • Tip: when a query fails, start with logRoles(container). It solves most cases in ten seconds, and along the way it shows you what roles your markup actually has, which are often not the ones you assumed.
  • Tip: if testing a component requires ten providers and fifteen props, the component is doing too much. How hard a test is to write is a design signal, same as in 09-02.

Exercises

Exercise 1. Write the test suite for BikeSearch, which receives the onSearch prop and contains a text field labeled "Search bikes." Cover: that the field appears with its label, that it starts empty, that typing "urbana" makes the field show that value, and that clicking the "Clear" button empties the field and notifies with the empty string. Do not test the useDebounce delay: explain in a comment why that part belongs to 09-04, and what it would take to test it here.

Exercise 2. This StationCard test is written in the wrong style. Identify five problems, rewrite it following query priority, and explain what real bug your version would catch that the original lets through.

test('StationCard', () => {
  const { container } = render(
    <StationCard station={{ id: 'est-01', name: 'Main Square', district: 'Downtown', docks: 20 }} />
  );
  expect(container.querySelector('.station-card')).toBeTruthy();
  expect(container.querySelectorAll('p')[0].textContent).toBe('Downtown');
  expect(container.querySelectorAll('p')[1].textContent).toBe('20 docks');
  fireEvent.click(container.querySelector('button'));
  expect(container.querySelector('.detail')).toBeTruthy();
});

Exercise 3. RequireRole protects WorkshopPage: if the signed-in user doesn't have the operario role, it redirects to /sin-permisos. Write two tests using renderWithProviders — one for Ana Ribera (cliente) and one for Marc Solé (operario) — and explain why using the real providers is superior to mocking useSelector in this specific case.

Solutions

Solution 1.

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

describe('BikeSearch', () => {
  test('shows a labeled, empty search field', () => {
    render(<BikeSearch onSearch={vi.fn()} />);

    const field = screen.getByRole('textbox', { name: 'Search bikes' });
    expect(field).toBeInTheDocument();
    expect(field).toHaveValue('');
  });

  test('reflects what the user types', async () => {
    const user = userEvent.setup();
    render(<BikeSearch onSearch={vi.fn()} />);

    const field = screen.getByRole('textbox', { name: 'Search bikes' });
    await user.type(field, 'urbana');

    expect(field).toHaveValue('urbana');
  });

  test('"Clear" empties the field and notifies with the empty string', async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
    render(<BikeSearch onSearch={onSearch} />);

    const field = screen.getByRole('textbox', { name: 'Search bikes' });
    await user.type(field, 'urbana');
    await user.click(screen.getByRole('button', { name: 'Clear' }));

    expect(field).toHaveValue('');
    expect(onSearch).toHaveBeenLastCalledWith('');
  });

  // This does NOT test that `onSearch` fires 400 ms after typing stops.
  // That behavior depends on a timer inside a React effect, so it requires combining
  // `vi.useFakeTimers()` with `advanceTimersByTime` INSIDE an `act`, and coordinating it
  // with userEvent's internal waits. That's 09-04's territory, where `useDebounce` gets
  // tested with `renderHook`. The pure mechanism was already tested in 09-02 with the
  // `delay` function, with no React involved.
});

Solution 2. The five problems:

  1. The name doesn't describe any behavior. test('StationCard') should be a describe, with tests that say what's being checked.
  2. container.querySelector('.station-card') asserts on a CSS class. With CSS Modules that name doesn't even exist as-is, and even if it did, it isn't something the user perceives.
  3. querySelectorAll('p')[0] and [1] pin the test to the order of the paragraphs in the markup. Swapping district and docks for design reasons would break the test without breaking anything real.
  4. fireEvent.click on querySelector('button'). Two mistakes in one line: it picks "whatever the first button is," regardless of which one, and it uses fireEvent, which would fire the handler even if the button were disabled.
  5. Five unrelated checks in a single test. When it fails, the report only says "StationCard," without saying whether it was the information, the interaction, or the detail that broke.

Rewritten:

describe('StationCard', () => {
  const STATION = { id: 'est-01', name: 'Main Square', district: 'Downtown', docks: 20 };

  test('shows the name, the district, and the number of docks', () => {
    render(<StationCard station={STATION} />);

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

  test('the detail is hidden until it\'s requested', () => {
    render(<StationCard station={STATION} />);

    expect(screen.getByRole('button', { name: /View details/ })).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByRole('region', { name: /Details for Main Square/ })).not.toBeInTheDocument();
  });

  test('shows the detail when "View details" is clicked', async () => {
    const user = userEvent.setup();
    render(<StationCard station={STATION} />);

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

    expect(screen.getByRole('region', { name: /Details for Main Square/ })).toBeVisible();
    expect(screen.getByRole('button', { name: /View details/ })).toHaveAttribute('aria-expanded', 'true');
  });
});

The bug the new version catches and the original doesn't: the button losing its aria-expanded announcement. The original checks that an element with class .detail shows up; if someone removes the aria-expanded or turns the button into a <div onClick>, the original stays green and accessibility breaks silently. The new version fails, because getByRole('button', …) demands a real button role and the assertion demands the attribute. It would also catch the detail being visible from the start, something the original never checks at all.

Solution 3.

import { renderWithProviders, screen } from '../tests/utils.jsx';
import RequireRole from './RequireRole.jsx';
import WorkshopPage from '../pages/WorkshopPage.jsx';

const ANA  = { id: 'usr-01', name: 'Ana Ribera', email: '[email protected]', role: 'cliente' };
const MARC = { id: 'usr-02', name: 'Marc Solé', email: '[email protected]', role: 'operario' };

const ROUTES = [
  {
    path: '/taller',
    element: <RequireRole role="operario"><WorkshopPage /></RequireRole>
  },
  { path: '/sin-permisos', element: <h1>Forbidden</h1> }
];

describe('RequireRole over WorkshopPage', () => {
  test('lets an operator through', () => {
    renderWithProviders(null, {
      route: '/taller',
      routes: ROUTES,
      initialState: { session: { user: MARC, loading: false, error: null } }
    });

    expect(screen.getByRole('heading', { name: /Workshop/ })).toBeInTheDocument();
    expect(screen.queryByRole('heading', { name: 'Forbidden' })).not.toBeInTheDocument();
  });

  test('redirects a customer to /sin-permisos', () => {
    const { router } = renderWithProviders(null, {
      route: '/taller',
      routes: ROUTES,
      initialState: { session: { user: ANA, loading: false, error: null } }
    });

    expect(screen.getByRole('heading', { name: 'Forbidden' })).toBeInTheDocument();
    expect(router.state.location.pathname).toBe('/sin-permisos');
  });
});

Why the real providers are superior here: RequireRole doesn't read the role directly, but through the selector selectIsOperator, which 07-04 chose to derive instead of store. If useSelector were mocked to return true, the test would skip right past the exact piece that can break: whether the selector correctly derives the role from the user. With the real store and preloadedState, the whole chain gets exercised — user in state → selector → guard's decision → the router's redirect — which is where the real bug lives. And there's a second reason: with the mock, you wouldn't be checking where it redirects, only that it decided to redirect; with createMemoryRouter you verify the actual destination, which is what the user experiences.

Conclusion

With this lesson, CicloUrbano finally has tests for what the user sees and touches, and the weight of the 09-01 trophy sits where it belongs.

The essentials. Testing Library's philosophy is a deliberate constraint: it gives no access to state or props, because the more a test resembles real usage, the more confidence it delivers. It always renders the full tree, with no shallow rendering, so almost every component test is really an integration test. It runs on jsdom, which gives you DOM, events, localStorage, and history in milliseconds, but not real layout, getBoundingClientRect, a CSS cascade, or real navigation: that's why toBeVisible doesn't catch a covered-up button, and why Cypress is needed (09-05).

You know how to choose between the three query families: getBy when something must be there, queryBy — and only queryBy — when something must not be there, and findBy when it will appear. And you know the priority ladder, with getByRole on the first rung because it checks two things at once: that the element has the right role, and that its accessible name is the expected one. That's the connection the introduction promised: the label htmlFors, the roles, the accessible names, and the aria-describedby from 03-06 weren't a separate task — they were the handholds the tests now use. A <div onClick> in place of a <button> breaks the test, and it should. data-testid stays a legitimate last resort — elements with no semantics, like PageSkeleton — and an explicit contract in end-to-end tests. And when a query fails, you already have the debugging order: read the DOM dump, screen.debug(), logRoles(container), and the Testing Playground.

For interaction, userEvent always, because it simulates the full sequence of events, moves focus, and — decisively — fires nothing on a disabled element, unlike fireEvent, which would validate behaviors impossible in production. All its calls are asynchronous: if the line starts with user., it gets an await. And jest-dom's assertions give you a vocabulary that speaks interface, with two underused gems: toHaveAccessibleName and toHaveAccessibleDescription.

CicloUrbano's four cases climbed in difficulty. StatusBadge tested pure presentation and, along the way, that the decorative symbol stays hidden from the screen reader. BikeCard introduced callbacks with vi.fn() and the case that justifies userEvent: clicking "Book" on a bike in maintenance must not notify the parent. TypeSelector taught you how to test a controlled component: it checks that it notifies with 'electrica' and that it does not change the filter on its own, and the active state gets queried with { pressed: true }, not a class. And BookingForm closed the loop from Module 3: four role="alert"s when submitting empty, and above all toHaveAccessibleDescription, which in a single assertion checks that the message exists, that it has the right id, and that the field references it — the visible-but-wrongly-tied error that no other check catches.

The infrastructure is now settled: src/tests/utils.jsx with createTestStore, createTestClient, and the project's own renderWithProviders, which wraps the component with the real providers in the same order as main.jsx and returns store, client, and router. Using the real providers instead of mocking them is what makes a badly written selector break the test instead of sneaking through. And retry: false on the QueryClient isn't a minor detail: without it, any error test hangs waiting through three retries. For routes, createMemoryRouter with initialEntries lets you test parameters (/bicicletas/bici-002), query strings (?tipo=electrica), and real navigation, checking the destination reached instead of whether useNavigate got called.

And the list of what never gets tested now has real names attached: internal state, CSS Modules classes, render counts, calls into library hooks, memo, DOM structure, and non-exported functions. With the warning sign that sums it all up: if you need to read the .jsx file to know what to query, the test is coupled.

What's still missing is the ground where most real-world tests fail: asynchrony. Everything in this lesson was synchronous; the moment CataloguePage asks the API for bikes, the assertion will run before the response arrives, and the dreaded act(...) warning will show up. The next lesson explains that warning properly, introduces the waiting tools (findBy, waitFor, waitForElementToBeRemoved), and sets up MSW to intercept the network at the protocol level, with src/tests/handlers.js and src/tests/server.js, so the interface's three states — loading, success, and error — can be triggered at will. It also covers testing TanStack Query's mutations and custom hooks with renderHook. The next lesson is Testing Async Code and Mocking APIs.

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