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.
Console
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:
- Draw the component:
render. - Find the element the way a person finds it: by role and name, by label, by text.
- Act like a person:
user.click,user.type. - 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:
getByRolewith 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.getByTestIdonly at the end, when nothing else works. Thedata-testidattribute 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
- test() gives one check a name. Vitest runs every test in the file and reports which passed.
- render draws ProductCard into an empty page, as the browser would. No App, no rest of the shop: only this component.
- 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.
- user.click presses the button like a person: pointer events, then click. The component updates and shows "In the cart: 1".
- expect checks what the person sees: the text is on the page. The test passes.
- Now the same test against a broken version: the button is not wired to anything. The click reaches the button, and nothing changes.
- 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.
Console
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.
Line 11: user.click(…) without await. The click is promised but has not happened yet when expect looks for the text. With await user.click(…) the test waits for the click and for the component to update.
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);
});toHaveBeenCalledWith(bread): the function was called at least once with these arguments. It compares the values, so an object with the same fields passes too.
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.
Console
Solution
ProductCard.test.jsximport { test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProductCard from './ProductCard.jsx';
const banitsa = { id: 4, name: 'Cheese banitsa', price: 2.5, stock: 0 };
test('a sold-out product is not added', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ProductCard product={banitsa} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: 'Add to cart' }));
expect(onAdd).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Add to cart' })).toBeDisabled();
expect(screen.getByText('Out of stock')).toBeInTheDocument();
});
ProductCard.jsximport { useState } from 'react';
import { money } from './money.js';
export default function ProductCard({ product, onAdd }) {
const [added, setAdded] = useState(0);
const soldOut = product.stock === 0;
function add() {
setAdded(added + 1);
onAdd?.(product);
}
return (
<article className="card">
<h3>{product.name}</h3>
<p>{money(product.price)}</p>
{soldOut && <p>Out of stock</p>}
<button type="button" className="add" disabled={soldOut} onClick={add}>Add to cart</button>
{added > 0 && <p>In the cart: {added}</p>}
</article>
);
}
money.jsexport const money = (x) => '€' + x.toFixed(2);
await user.click(screen.getByRole('button', { name: 'Add to cart' }));expect(onAdd).not.toHaveBeenCalled();expect(screen.getByRole('button', { name: 'Add to cart' })).toBeDisabled();expect(screen.getByText('Out of stock')).toBeInTheDocument();A click on a disabled button does not reach the component, so onAdd is not called. The test catches both faults: the version where the button is not disabled (the click calls onAdd) and the version without the message. A test that checks only the message passes against the first one. That is why the check rejects it.
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.
Console
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 withawait.queryBy…returnsnullif 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.
Console
Solution
CartSummary.test.jsximport { test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CartSummary from './CartSummary.jsx';
const bread = { id: 1, name: 'White bread', price: 1.2, qty: 2 };
const milk = { id: 5, name: 'Fresh milk', price: 1.6, qty: 1 };
test('shows the lines', () => {
render(<CartSummary items={[bread, milk]} onRemove={vi.fn()} />);
expect(screen.getByText('White bread × 2')).toBeInTheDocument();
expect(screen.getByText('Fresh milk × 1')).toBeInTheDocument();
});
test('the total counts the quantity', () => {
render(<CartSummary items={[bread, milk]} onRemove={vi.fn()} />);
expect(screen.getByText('Total: €4.00')).toBeInTheDocument();
});
test('"Remove" sends its own line', async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
render(<CartSummary items={[bread, milk]} onRemove={onRemove} />);
await user.click(screen.getByRole('button', { name: 'Remove Fresh milk' }));
expect(onRemove).toHaveBeenCalledWith(milk);
});
test('the empty cart says so', () => {
render(<CartSummary items={[]} onRemove={vi.fn()} />);
expect(screen.getByText('The cart is empty')).toBeInTheDocument();
});
CartSummary.jsximport { money } from './money.js';
export default function CartSummary({ items, onRemove }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
if (items.length === 0) return <p>The cart is empty</p>;
return (
<section aria-label="The cart">
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Remove ${item.name}`} onClick={() => onRemove(item)}>
Remove
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</section>
);
}
money.jsexport const money = (x) => '€' + x.toFixed(2);
Three tests, one for each behaviour:
- The total: a line with quantity 2 (
bread) and a line with 1 (milk) give "Total: €4.00". If every line has quantity 1, price and price × quantity are the same and the fault does not show. - "Remove": press the button of the second line ("Remove Fresh milk") and check
toHaveBeenCalledWith(milk). If you press the first line, the broken version sends the first line too and the test passes. - The empty cart:
items: []and "The cart is empty" on the page.
A good test picks data where the mistake shows. It is the same skill as with tests of functions: the cases at the edge.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: State · Lifting state up · Effects · Data from an API in a component
- Continues in: From the sandbox to a project · The shop front
- Tasks on this topic: A whole app