Assert All Four UI States
You're only testing one of your UI's four states. Almost every screen that talks to a network or a database moves through the same lifecycle: it loads, it may come back empty, it may fail, and — if everything goes right — it renders success. Most widget and component tests assert exactly one of those four: the happy render. That's 25% coverage of the states your users actually see.
Why the happy path isn't the whole app
The success render is the state you look at all day during development, so it's the one that rarely breaks. The bugs users hit live elsewhere:
- A spinner that never goes away because an error was swallowed.
- An empty list that renders a blank white void instead of "No results yet."
- An error state that crashes the component because
datawasundefinedand the render assumed an array. - A retry button that isn't wired to anything.
None of these are exotic. All of them ship constantly, because no test ever put the component into that state.
The four assertions
| State | What to assert | How to reach it in a test |
|---|---|---|
| Loading | Spinner/skeleton is visible; content is not | Render before the mocked request resolves |
| Empty | The empty-state message and its CTA render | Mock the request to return [] / no items |
| Error | Error message renders; retry control exists | Mock the request to return a 500 or a network error |
| Success | Data renders; spinner and error are gone | Mock the request to return realistic fixture data |
Four states, four tests. Each one is a few lines once the request mocking is in place.
What it looks like in code
With React Testing Library and MSW, the error case is a one-handler override:
test("handles server error", async () => {
server.use(
http.get("/api/items", () =>
HttpResponse.json({ error: "boom" }, { status: 500 })
)
);
render(<ItemList />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
Two details do the heavy lifting:
findBy*waits,queryBy*denies.findByTextpolls until the async state settles;queryByRole(...)returningnullis how you assert the spinner is gone. Asserting absence is half the job — a screen showing an error and a spinner is still a bug.- Override per test, not per suite. MSW's
server.use()swaps in the failing handler for one test only, so success stays the default and each state is explicit.
The same shape works everywhere: in Flutter, tester.pumpWidget() plus find.byType(CircularProgressIndicator) for loading, then pump past the future and assert the error Text; in any stack, the pattern is control the data source, assert the render.
Power-user moves
- Make the states a fixture set. Define
loading/empty/error/successresponses once and reuse them across every screen's tests. New screen, same four fixtures. - Give each state a Storybook story. A story per state means designers review the empty and error screens that otherwise nobody ever sees — and visual regression tools diff all four, not just success.
- Test the transition, not just the state. The nastiest bugs are transitions: error → retry → loading → success. One test that clicks retry after a mocked failure catches the unwired button.
- Point your AI agent at the table. When an agent writes component tests, "test this component" gets you the happy path. "Write four tests: loading, empty, error, success — assert the spinner is gone in the last three" gets you real coverage. The checklist is the prompt.
Resources
- Async methods — Testing Library (
findBy*,waitFor) - React Testing Library example — success and server-error tests with MSW
- Mocking error responses — MSW docs
- An introduction to widget testing — Flutter cookbook
- How to write stories — Storybook docs
Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.