Skip to content
wannacode

Testing components

In short: Component tests with Testing Library and Vitest check what a person sees: they find elements by role, label and text and click like a user.

Problem

The test is green, the button is broken

Someone wrote a test for the product card. Run it.

React

The report says "Tests: 1 passed, 0 failed". Now open ProductCard.jsx: the button has no onClick. The customer presses "Add to cart" and nothing happens, and the test is green.

The test finds the button by its class (.add) and checks its text. Nobody presses it. The opposite happens too: rename the class to btn-add and the test fails, while the person in front of the screen sees no difference. The test follows how the code is written, not what it does.

The idea

Test what a person sees

If you wrote tests in the JavaScript course, the idea is the same: code that calls your code and compares the result. The difference is that the result of a component is a page. So the test does what a person in front of it does:

  1. Draw the component: render.
  2. Find the element the way a person finds it: by role and name, by label, by text.
  3. Act like a person: user.click, user.type.
  4. Check what is visible: expect(…).toBeInTheDocument().

There are two tools. Vitest runs the tests (test, expect), and Testing Library draws the component and searches the page. These are component tests.

import { render, screen } from '@testing-library/react';
render(<ProductCard product={bread} />);
screen.getByRole('button', { name: 'Add to cart' });

The order in which Testing Library advises you to search:

  • getByRole with a name: a button, a link, a heading, a field. This is how a screen reader searches, so the test checks accessibility too. A button without a name is not found.
  • getByLabelText: a field by its label.
  • getByText: text that is not in a button or a heading.
  • getByTestId only at the end, when nothing else works. The data-testid attribute is not visible to the person.

See it

See how the test runs

The four steps on one card. Then the same test against a broken version.

test('the button adds to the cart', async () => {
const user = userEvent.setup();
render(<ProductCard product={bread} />);
const button = screen.getByRole('button', {
name: 'Add to cart',
});
await user.click(button);
expect(screen.getByText('In the cart: 1'))
.toBeInTheDocument();
});
Step by step, in words
  1. test() gives one check a name. Vitest runs every test in the file and reports which passed.
  2. render draws ProductCard into an empty page, as the browser would. No App, no rest of the shop: only this component.
  3. The test looks for the button the way a person does: the role "button" and the name "Add to cart". The class and the order in the code do not matter.
  4. user.click presses the button like a person: pointer events, then click. The component updates and shows "In the cart: 1".
  5. expect checks what the person sees: the text is on the page. The test passes.
  6. Now the same test against a broken version: the button is not wired to anything. The click reaches the button, and nothing changes.
  7. expect cannot find "In the cart: 1", and the test fails. Green on the right code and red on the broken one: that is a test you can trust.

The test does not know whether the code has a className, how many <div> are around the button or what the function is called. It knows what is on the screen. That is why it survives a refactoring and fails on a fault.

Try it

The first real test

Two tests: the card shows the name and the price, and the button adds to the cart. Run them. Then remove onClick={add} in ProductCard.jsx and run again: the second test fails.

React

userEvent.setup() makes a "user", and await user.click(…) presses like a person: the pointer moves over the button, presses, releases, and only then comes click. Every action of user returns a Promise, so the test is async and waits with await.

toBeInTheDocument() comes from jest-dom: checks for the page such as toBeDisabled(), toHaveTextContent(), toHaveValue(). In the sandbox they are built in, and in a project you switch them on with one line (see "More precisely").

Try it

Find the bug

The test fails with "Unable to find an element with the text: In the cart: 1", and the component is the right one. Which line is to blame?

Pick the line with the bug.

The idea

What the card tells the parent: vi.fn()

The card does not own the cart. It tells the parent "add this product" (the lesson on shared state). A test of the card should not draw the whole shop to see that. Instead of a parent you pass vi.fn(): an empty function that remembers every call. Such a stand-in function is called a mock.

const onAdd = vi.fn();
render(<ProductCard product={bread} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: 'Add to cart' }));
expect(onAdd).toHaveBeenCalledTimes(1);
expect(onAdd).toHaveBeenCalledWith(bread);

not turns the check around: expect(onAdd).not.toHaveBeenCalled() means "never called".

The test checks that the card sends exactly this product. What goes in the blank?

test('the button adds to the cart', async () => {
  const user = userEvent.setup();
  const onAdd = vi.fn();
  render(<ProductCard product={bread} onAdd={onAdd} />);
  await user.click(screen.getByRole('button', { name: 'Add to cart' }));
  expect(onAdd).(bread);
});

Try it

The sold-out product

The banitsa is sold out: the button must be disabled, the card must say "Out of stock" and a click must not call onAdd. Finish the test: press the button and check all three. "Check" runs your test against the right card and then against two broken versions. The test must pass on the right one and fail on each broken one.

React

The idea

Data that arrive later: findBy

The list loads the products from the API. First it says "Loading…", and the products come a moment later. The test has to wait for them. Run the two tests: the first one fails on purpose.

React

There are three kinds of queries:

  • getBy… looks right away. If the element is not there, the test fails. The first test fails exactly like that: the answer has not arrived yet.
  • findBy… returns a Promise and tries again until the element appears. If it does not appear in time (in Testing Library 1 second by default), it fails. That is why it goes with await.
  • queryBy… returns null if there is no element, without failing. With it you check that something is gone: expect(screen.queryByText('Loading…')).toBeNull().

When you wait for something that is not an element (a function called after the request), you use waitFor: await waitFor(() => expect(onLoad).toHaveBeenCalled()). It runs the check until it passes or the time runs out.

More precisely, if you are curious

In a real project. The packages: npm install -D vitest jsdom @testing-library/user-event @testing-library/jest-dom and @testing-library/react @testing-library/dom. In vite.config.js you add test: { environment: 'jsdom' }: the tests run in Node, and jsdom gives them a page. At the top of the tests (or in a file from setupFiles) you write import '@testing-library/jest-dom/vitest' to get toBeInTheDocument() and the others. You run the tests with npx vitest, and their files are *.test.jsx or *.test.js next to the component.

Cleaning up between tests. Testing Library removes what it drew after each test by itself if afterEach is global. In Vitest it is global only with test: { globals: true }. Otherwise you call cleanup() in afterEach. In the sandbox every test gets an empty page.

fireEvent and user. fireEvent.click(button) sends one click event. user.click(button) goes through everything a person does: the pointer, the focus, the press. An element with pointer-events: none, which a person cannot press, user refuses to press with an error, while fireEvent sends the event. That is why user is closer to the truth.

End-to-end tests. A component test draws one piece. An end-to-end test (for example with Playwright) runs the whole app in a real browser and goes through it like a customer: catalogue, cart, order. It catches faults between the parts, but it is slower. So there are many component tests and few end-to-end tests, for the most important paths.

What you do not test. The state inside (added, soldOut), the names of functions and classes. They change in a refactoring. You test the input (props, clicks, the answers of the API) and the output (the page and the calls up).

The sandbox. Here vitest and Testing Library are small versions with the same names: only the part of the API this lesson uses. findBy and waitFor wait up to 1 second, and a test is stopped after 4 seconds. The test code is written as in a real project.

Exercise

Tests for the cart

CartSummary shows the lines ("White bread × 2" with a "Remove" button), the total ("Total: €4.00") and "The cart is empty" when it is empty. On "Remove" it tells the parent which line to remove. Write tests that catch three broken versions: the total ignores the quantity, "Remove" sends the first line instead of its own, and the empty cart says nothing. The check wants all your tests to pass on the right code and all three faults to be caught.

React

Test

Check yourself

1. The test has to find the "Add to cart" button. Which query do you use first?
2. The component loads the products from the API. The test calls getByRole('heading', { name: 'White bread' }) right after render and fails. Why?
3. Your test passes against the right code and also against a version where the button is not wired. What does that mean?
4. Why do you pass onAdd = vi.fn() in the test of ProductCard?
5. You want to check the whole path: catalogue, cart, order form, a real server. What kind of test is that?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·