Testing React components
A component test renders a component and asks what a person would see. That second half is the whole discipline, and it is what separates a test that survives a refactor from one that breaks every time somebody renames a class.
Query the way a person reads
render(<Price variant={variant()} />);
expect(screen.getByText(/95\.00/)).toBeInTheDocument();
Not container.querySelector(".text-lg"). Not a data-testid. The text.
Testing Library's whole argument is this: the closer your test resembles how the software is used, the more confidence it gives you. A test that finds an element by class breaks when you restyle and passes when the text is wrong. Both are the wrong way round.
The query priority, in order of preference:
| Query | Use it for |
|---|---|
getByRole |
anything interactive — buttons, links, headings, inputs |
getByLabelText |
form fields |
getByText |
static content |
getByTestId |
a last resort, when nothing above identifies it |
getByRole first is not fashion. It is the accessibility tree — the same
structure a screen reader walks. A component you cannot query by role is usually
a component somebody cannot use with a screen reader, so the test failing is
telling you something real.
The tests worth writing
it("shows the price per kilogram so pack sizes can be compared", () => {
render(<Price variant={variant()} />);
expect(screen.getByText(/190\.00\/kg/)).toBeInTheDocument();
});
500 g at ₹95 is ₹190/kg. That number is the reason the component exists — it is what makes a 5 kg bag obviously better value than five 1 kg bags — and getting it wrong misleads a customer about price.
it("shows no saving when there is no MRP", () => {
render(<Price variant={variant()} />);
expect(screen.queryByText(/% off/)).not.toBeInTheDocument();
});
queryBy for absence, getBy for presence. getBy throws when it finds
nothing, so it can never express "this should not be here". Reaching for
expect(getBy…).toBeNull() fails with a confusing error instead of a clean one.
Asserting that nothing renders
it("renders nothing at all for a cancelled order", () => {
const { container } = render(<OrderProgress status="CANCELLED" />);
expect(container).toBeEmptyDOMElement();
});
Module 13 decided that a cancelled order gets no progress bar, because a greyed-out one reads as "not yet" rather than "never".
That is a product decision living in an if at the top of a component, and
without a test it survives exactly until somebody tidies the component. This is
a good example of a test whose value is not "the code is correct" but "this
decision was deliberate".
Do not let the enum leak
it("shows a human label, never the enum value", () => {
render(<OrderStatusBadge status="OUT_FOR_DELIVERY" />);
expect(screen.getByText("Out for delivery")).toBeInTheDocument();
expect(screen.queryByText("OUT_FOR_DELIVERY")).not.toBeInTheDocument();
});
Two assertions: the right thing is there, and the wrong thing is not. The second
is the one that catches a missing entry in ORDER_STATUS_LABELS, where a
component might fall back to rendering the raw value.
Uppercase with underscores on a customer's screen tells them they are looking at something not meant for them.
Clean up between tests
// vitest.setup.ts
afterEach(cleanup);
jsdom keeps the document between tests in a file. Without this, the second test
finds two copies of everything and getByRole throws "found multiple
elements" — a failure that looks like a bug in the component and is a bug in the
setup.
Helpers, not fixtures
const variant = (overrides: Partial<VariantSummary> = {}): VariantSummary => ({
id: "v1",
sku: "DAL-TOOR-500",
label: "500 g",
unit: "GRAM",
quantity: 500,
pricePaise: 9500,
mrpPaise: null,
inStock: true,
...overrides,
});
A function with defaults, so each test names only what it cares about:
render(<Price variant={variant({ pricePaise: 28_500, mrpPaise: 31_000 })} />);
The reader sees immediately that this test is about the MRP. A shared mutable object at the top of the file would hide that, and would let one test's changes leak into the next.
Typed as VariantSummary, so adding a field to the shared type breaks this
helper — which is the right place to find out.
What this layer cannot do
Component tests here cover presentational components only: Price,
OrderStatusBadge, OrderProgress, and the URL helpers.
They do not cover AddToCart, QuantityStepper or CheckoutForm, and the
reason is worth being explicit about. Those components call server actions —
functions that run on the Next server. Testing them in jsdom means mocking the
action, and then the test asserts that clicking a button calls a function you
replaced. That is a test of your mock.
What those components actually need proving is that clicking Add puts something in the cart, and that is a Playwright test against a running application. The next lesson does exactly that.
When a component's value is in what it talks to, test it where it can talk.
Pure helpers belong here too
it("takes the first of a repeated key", () => {
expect(readString({ category: ["dairy", "snacks"] }, "category")).toBe("dairy");
});
searchParams values are string | string[] | undefined, because a URL can
repeat a key. String(value) on an array gives "dairy,snacks", which the API
rejects — or worse, ignores.
it.each([
["https://evil.example", "absolute URL"],
["//evil.example", "protocol-relative URL"],
["javascript:alert(1)", "javascript URL"],
])("refuses %s (%s)", (value, _why) => {
expect(safeNextParam(value)).toBe("/products");
});
An open redirect right after somebody has typed a password is the classic
phishing setup. Each of those is a real attack shape, and the second one —
//evil.example — is the one people forget, because it looks like a path and is
an absolute URL.
it.each with a reason column, so a failure names which case broke.
Check your work
Why query by role and text: the test resembles how the software is used, and survives restyling.
Why getByRole first: it is the accessibility tree, so a component you
cannot query that way is usually one somebody cannot use.
Why queryBy for absence: getBy throws when it finds nothing and cannot
express "should not be here".
Why assert an empty render: a product decision living in an if survives
only until somebody tidies the component.
Why assert the enum is absent: it catches a missing label entry, where the raw value would leak to a customer.
Why afterEach(cleanup): jsdom keeps the document, and the second test then
finds two of everything.
Why a factory rather than a shared object: each test names only what it cares about, and nothing leaks.
Why the interactive components are not tested here: they call server actions, so a jsdom test would assert that a mock was called.
Why //evil.example is the interesting case: it looks like a path and is an
absolute URL.
Practice
- Run the web tests and note the time. Compare it with the Playwright suite.
- Change a Tailwind class in
Priceand confirm no test fails. Then change the price arithmetic and confirm one does. - Rewrite one query as
container.querySelectorand restyle the component. Watch it break for the wrong reason. - Replace a
queryByabsence check withgetByand read the failure message. - Remove
afterEach(cleanup)and run a file with two tests in it. - Add a status to
OrderStatuswithout a label, and confirm which test catches it. - Write a component test for
AddToCart. Work out what you had to mock, then decide what the test proves. - Add a case to
safeNextParam's table for/\evil.exampleand check the behaviour. - Render
OrderProgressfor each status and write down which should show a bar. - Query
PricewithgetByRoleonly. Note what is unreachable, and whether that is a problem with the test or with the component.
Next: the whole thing, in a real browser.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship