RizTech Academy logo
RizTech Academy
Testing the Whole StackLesson 4 of 530 min

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

  1. Run the web tests and note the time. Compare it with the Playwright suite.
  2. Change a Tailwind class in Price and confirm no test fails. Then change the price arithmetic and confirm one does.
  3. Rewrite one query as container.querySelector and restyle the component. Watch it break for the wrong reason.
  4. Replace a queryBy absence check with getBy and read the failure message.
  5. Remove afterEach(cleanup) and run a file with two tests in it.
  6. Add a status to OrderStatus without a label, and confirm which test catches it.
  7. Write a component test for AddToCart. Work out what you had to mock, then decide what the test proves.
  8. Add a case to safeNextParam's table for /\evil.example and check the behaviour.
  9. Render OrderProgress for each status and write down which should show a bar.
  10. Query Price with getByRole only. 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