Field note / react

publishedupdated 2026-05-04#react#testing#rtl#vitest#jest

Testing React Components

How I design a React test portfolio that catches real regressions: behavior-first component tests, reducer tests, network seams, and critical user flows.

TL;DR

  • I treat React testing as a portfolio problem: pure logic, component behavior, integration seams, and a small number of end-to-end flows all have different jobs.
  • The most durable component tests read like a user story: render, interact, assert visible behavior, and verify the side effect that matters.
  • Staff-level testing discipline is mostly about boundary choice. I do not want business rules trapped only inside JSX tests, and I do not want browser flows proving every tiny branch.
  • When a bug involves race conditions, auth, caching, retries, focus, or navigation, I raise the test level until the risk is actually covered.

Context

On product teams, frontend test suites usually fail in one of two ways:

  1. they are tiny and miss expensive regressions
  2. they are huge, slow, and brittle because they assert implementation details

I want a suite that survives refactors and still catches the bugs users feel: wrong validation, stale search results, optimistic UI not rolling back, keyboard traps, broken loading states, and routing flows that collapse under real timing.

The Approach

1. Give each test level one clear job

This is the mental model I use:

LevelWhat I proveWhat I avoid
Pure unit testreducer rules, selectors, formatting, merge logicDOM rendering
Component testuser interaction and visible stateinternal hook calls, private state shape
Integration testcomponent + data seam + retry/error statesfull browser navigation
E2E testcritical business flow end to endevery branch in the app

When teams skip this separation, all the business rules end up trapped inside JSX tests and every UI refactor becomes painful.

2. Write component tests around user intent

The default shape I want is:

  1. render the component
  2. interact like a user
  3. assert what changed on screen
  4. verify the side effect that matters
it("shows a validation error for an invalid email", async () => {
  const onSubmit = vi.fn()

  render(<SignupForm onSubmit={onSubmit} />)

  await userEvent.type(screen.getByLabelText(/email/i), "not-an-email")
  await userEvent.click(screen.getByRole("button", { name: /create account/i }))

  expect(screen.getByText(/enter a valid email/i)).toBeVisible()
  expect(onSubmit).not.toHaveBeenCalled()
})

I intentionally avoid asserting:

  • that setState ran
  • that a hook was called with a specific dependency array
  • that a CSS class flipped unless the class itself is the product behavior
  • that internal state matches an object shape the user never sees

3. Pull branching business logic out of the component and test it directly

If the component contains real workflow logic, I extract the transition rules and test them as data.

type SaveState =
  | { status: "idle" }
  | { status: "saving" }
  | { status: "error"; message: string }
  | { status: "success" }

type Action =
  | { type: "submit" }
  | { type: "failed"; message: string }
  | { type: "saved" }

export function saveReducer(state: SaveState, action: Action): SaveState {
  switch (action.type) {
    case "submit":
      return { status: "saving" }
    case "failed":
      return { status: "error", message: action.message }
    case "saved":
      return { status: "success" }
  }
}
it("moves from saving to error when the request fails", () => {
  expect(saveReducer({ status: "saving" }, { type: "failed", message: "Timeout" })).toEqual({
    status: "error",
    message: "Timeout",
  })
})

This keeps the UI tests smaller and makes failure logic easier to reason about in interviews and code reviews.

4. Test async seams where frontend bugs usually hide

The bugs I see most often are not “button click did not fire.” They are:

  • stale response overwrote fresh input
  • optimistic UI never rolled back
  • loading state never cleared after retry
  • form submitted twice
  • navigation happened before cache or state updated

For those cases, I want a component test that exercises timing:

it("ignores an older search response after a newer query wins", async () => {
  const resolveQueue: Array<(value: { items: string[] }) => void> = []
  const searchApi = vi.fn(
    () =>
      new Promise<{ items: string[] }>((resolve) => {
        resolveQueue.push(resolve)
      })
  )

  render(<SearchBox searchApi={searchApi} />)

  await userEvent.type(screen.getByLabelText(/search/i), "rea")
  await userEvent.clear(screen.getByLabelText(/search/i))
  await userEvent.type(screen.getByLabelText(/search/i), "react")

  resolveQueue[1]({ items: ["react"] })
  resolveQueue[0]({ items: ["rea"] })

  expect(await screen.findByText("react")).toBeVisible()
  expect(screen.queryByText("rea")).not.toBeInTheDocument()
})

If I cannot explain how stale responses are prevented, I do not yet trust the search experience.

5. Keep end-to-end tests focused on money paths

I do not want dozens of browser tests for small view logic. I want a small set of flows that prove the business still works:

  • login or signup
  • search or discovery
  • add to cart / checkout / save
  • a high-value role-based or tenant-based flow

For those, Playwright-level coverage is worth it because the risk is routing, auth, cookies, cache, focus, and timing all at once.

6. Make accessibility assertions part of normal UI testing

The easiest habit upgrade is to query the UI the way assistive tech does:

  • getByRole
  • getByLabelText
  • getByText only when the text is the real user-facing signal

That one habit makes both the tests and the components better.

Trade-offs

  • Behavior-first tests are more resilient, but they can be broader and slower than tiny implementation-driven tests.
  • Extracting reducers and helpers makes testing cleaner, but it requires discipline to keep component files from becoming giant all-in-one modules.
  • Browser E2E coverage catches integration bugs, but it is too expensive to be the first place I verify basic validation or rendering logic.
  • Snapshot-heavy suites look busy, but I have rarely seen them give principal-level confidence unless they are paired with stronger behavior tests.

References