L3 · Automation craft
L3Go deeper4 min read

Component and unit testing

The tests developers own — which is exactly why you should be able to read and improve them. A well-placed component test removes ten end-to-end ones, and knowing where that trade lands is a quality-engineering skill.

A unit test exercises one small piece of code in isolation, usually a function or a class, with its dependencies replaced. Milliseconds to run, and it names precisely what broke.

A component test renders one piece of user interface on its own, without a full browser or a running backend. You interact with it as a user would, click a button, type in a field, and assert on what appears.

Both usually belong to developers. Testers should still care, for two reasons. You will be asked to judge whether they are any good. And a defect caught here is far cheaper than the same one found in the browser.

The terms you will hear

  • Unit under test. The single function or class being exercised.
  • Component test. Rendering one UI component in a simulated DOM.
  • Testing Library. The common family of tools, with a philosophy of querying as a user would.
  • Query by role. Finding an element by its accessible role and name.
  • Implementation detail. Internal state, class names, prop names. Not user-visible, so not worth asserting.
  • Test runner. Vitest or Jest, typically, providing the harness.

Where each belongs

Unit tests are for logic with rules in it. Money maths, date handling, validation, pricing, permission checks. If getting it wrong would be expensive and the rules are non-trivial, this is the cheapest place to check them.

Component tests are for UI behaviour. Does the button disable while submitting, does the error appear for an invalid code, does the total update when a card is applied. All without a server, in milliseconds, and without the flakiness of a real browser.

End-to-end tests are for proving the pieces are wired together. Few, precious, and slow, per pyramid or trophy.

Why testers should care

Because the review is often yours, and because it changes what you have to test yourself.

For example, a gift-card form has rules for an invalid code, a spent code, another customer's code, a balance below the minimum, and a network failure. If those five are covered by component tests, your browser suite needs one journey. If they are not, you either write five slow browser tests or leave the rules untested.

Knowing which is the case is your call, and it is the practical version of the shape decision.

What good looks like

  1. Queries by role and label. getByRole('button', { name: 'Apply' }) rather than a class. It survives redesigns and fails when accessibility breaks.
  2. Asserts what a user sees. The error message text, the disabled button, the updated total. Not component state, not props.
  3. One behaviour per test. A test name that says what a user did and what happened.
  4. Real user events. Typing character by character rather than setting a value directly, because that is what triggers real validation.
  5. Network stubbed at the boundary. Intercept the request, per mocking, rather than replacing the component's own hooks.
  6. No snapshot as the only assertion. A stored render is a change detector, not a correctness check, and it locks in whatever was there when it was recorded.

A component test that asserts on internal state passes when the user sees nothing and fails when a developer renames a variable. It has the cost of a test and the value of a comment.

A worked pair

For example, here is the same rule tested at both levels.

gift-card-field.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from './msw-server'
import { GiftCardField } from '../GiftCardField'

test('shows a clear message when the code is already spent', async () => {
  // Stub at the network boundary: the component's own code stays under test.
  server.use(
    http.post('/api/gift-cards/apply', () =>
      HttpResponse.json({ error: 'gift_card_spent' }, { status: 409 }),
    ),
  )

  render(<GiftCardField orderTotal="35.99" />)

  // Query as a user would, and type as a user would.
  await userEvent.type(screen.getByLabelText('Gift card code'), 'GIFT-SPENT-1')
  await userEvent.click(screen.getByRole('button', { name: 'Apply' }))

  // Assert what the user sees, not what the component holds.
  expect(await screen.findByRole('alert')).toHaveTextContent('This code cannot be used')
  expect(screen.getByTestId('remaining-to-pay')).toHaveTextContent('35.99')
  expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled()
})
apply-gift-card.test.ts
import { applyGiftCard } from '../applyGiftCard'

// The unit test covers the arithmetic, where the real risk is. No DOM,
// no network, no component. Sub-millisecond, and it names the rule.
test.each([
  ['35.99', '25.00', '10.99', '0.00'],
  ['32.49', '25.00', '7.49', '0.00'],
  ['20.00', '25.00', '0.00', '5.00'],
  ['25.00', '25.00', '0.00', '0.00'],
])('order %s with card %s pays %s, leaves %s', (total, balance, due, left) => {
  const result = applyGiftCard({ orderTotal: total, cardBalance: balance })

  expect(result.remainingToPay).toBe(due)
  expect(result.cardBalance).toBe(left)
  // The invariant that caught the real defect: no penny may vanish.
  expect(Number(due) + Number(balance) - Number(left)).toBeCloseTo(Number(total), 2)
})

Four table rows and one invariant cover the money rules. The component test covers what the user sees. Neither needs a browser, and between them they leave one journey for the end-to-end suite.

How to show you know it

  • A rule moved down a level. "These five validation cases were browser tests; they are now component tests and the suite is four minutes faster."
  • A review of somebody else's component tests. Pointing out state assertions and snapshot-only checks is a genuinely useful contribution.
  • An invariant test. The no-penny-vanishes assertion is worth more than ten example-based cases.
  • A query-by-role argument. Explaining that it doubles as an accessibility check tends to win the discussion.

Questions

Should testers write unit tests?

Read them, certainly. Writing them is fine where you are comfortable in the codebase, and reviewing them is often the higher-value contribution, especially for assertion strength.

Are component tests not just slower unit tests?

They render real markup and handle real events, so they catch a different class of defect: disabled buttons, missing labels, error messages that never appear. Far cheaper than a browser, and closer to the user than a unit test.

What about snapshots?

Useful for noticing unintended change, useless as the only assertion. If a snapshot is the whole test, nobody knows whether the first recording was ever correct.

Who owns these tests?

Usually the developers who wrote the code, and that is the right default. Your influence is in the review and in noticing which browser tests could move down a level.