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:
- they are tiny and miss expensive regressions
- 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:
| Level | What I prove | What I avoid |
|---|---|---|
| Pure unit test | reducer rules, selectors, formatting, merge logic | DOM rendering |
| Component test | user interaction and visible state | internal hook calls, private state shape |
| Integration test | component + data seam + retry/error states | full browser navigation |
| E2E test | critical business flow end to end | every 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:
- render the component
- interact like a user
- assert what changed on screen
- 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
setStateran - 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:
getByRolegetByLabelTextgetByTextonly 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.