June 20, 2026
How to Test API-Driven UI States in Browser Automation Without Mocking Away Real Bugs
Learn how to test API-driven UI states in browser automation, including loading, empty, error, and partial-success flows without hiding real bugs behind mocks.
When a UI depends on live API behavior, the interesting bugs are rarely in the happy path. They show up when the backend is slow, returns partial data, drops a field, sends an empty response, or fails after the page has already started rendering. That is why teams that only mock everything in browser tests often miss the very states users actually see.
To test api-driven ui states well, you need a strategy that keeps the browser journey real enough to expose integration issues, but controlled enough to make assertions stable. That means validating loading states, empty states, error states, and partial-success states using a mix of real requests, carefully scoped network control, and user-visible assertions.
This article walks through that balance. It focuses on browser automation with practical examples in Playwright, but the same ideas apply to Cypress, Selenium, or a low-code platform like Endtest, which uses agentic AI to keep assertions tied to what the user can actually see instead of brittle implementation details.
What makes API-driven UI states hard to test
A page that renders from static HTML has a simple contract. A page that depends on APIs does not. The UI may start in a loading state, then re-render after one or more requests complete, and then change again if one request fails while another succeeds.
The tricky part is that the browser test is observing a moving target:
- A spinner may appear for 200 ms or 5 seconds.
- A skeleton may be present only while data is in flight.
- An empty state may be genuine, or it may mean the API silently returned an unexpected shape.
- An error banner may be correct, but the page might still contain stale data from a previous state.
- A partial success may look acceptable to a user, but still indicate a broken dependency.
Traditional selector-first assertions are not enough here. If you only check that a div.spinner exists or that a text node equals No items found, you can miss whether the UI actually communicated the right outcome to the user.
The best assertion for a stateful UI is usually not “did the DOM change?”, but “did the user end up with the right outcome after the API behavior that occurred?”
The states you should explicitly test
For most API-driven screens, there are four core states worth testing repeatedly.
Loading state
The UI should indicate that data is being fetched. Depending on the product, this might be a spinner, skeleton rows, disabled controls, or placeholder text.
What to verify:
- A loading indicator appears quickly after navigation or action.
- Interactive controls are disabled if the app cannot safely use them yet.
- The loading indicator disappears when the response completes.
- The page does not flash an error or empty state during normal latency.
Empty state
The request succeeded, but the API returned no records or no qualifying records.
What to verify:
- The page makes it clear that data is absent, not broken.
- There is no misleading table chrome with blank rows.
- Helpful copy or action hints are present, such as “Create your first project.”
- Empty state logic is different from error logic, even if both are visually sparse.
Error state
The request failed due to network issues, server errors, auth issues, validation failures, or a malformed response.
What to verify:
- The user sees a clear error message.
- Retry actions are visible if the app supports them.
- Sensitive details, stack traces, and raw API payloads are not exposed.
- The page does not present stale success data as if it were current.
Partial-success state
One request succeeds and another fails, or the response contains some valid records and some invalid ones, or a subset of fields is missing.
What to verify:
- The UI degrades gracefully, showing what it can.
- The failure is visible somewhere appropriate.
- The user can still complete allowed actions if the product permits it.
- The page does not hide the mismatch between data completeness and visual completeness.
Why pure mocking often hides bugs
Mocking is useful, but only when it is intentionally scoped. The problem starts when every browser test intercepts every API call.
Common failure modes hidden by over-mocking:
- The UI assumes a field exists because all mocks include it.
- Loading states never get enough real latency to expose flicker or race conditions.
- The app handles mocked 500s, but not malformed JSON or a slower-than-expected stream.
- A caching bug only appears when a real response includes headers your mock does not reproduce.
- The page state becomes inconsistent because one mocked endpoint updates earlier than another.
Browser automation is strongest when it sees the same class of state transitions that users see. That does not mean every request must hit production. It means you should be deliberate about what you fake and why.
A practical testing model, start with the browser, then decide where to control the network
A good pattern for UI state testing is:
- Let the browser run the real app shell.
- Observe what requests are made and when.
- Decide whether a given test needs live responses, controlled responses, or a hybrid.
- Assert on visible outcomes, not just request metadata.
That gives you three useful modes.
Mode 1, fully live API behavior
Use this for smoke checks, staging validation, and end-to-end flows where integration correctness matters more than repeatability.
Pros:
- Finds real integration issues.
- Confirms contracts across frontend, gateway, and backend.
- Reflects production timing and caching behavior.
Cons:
- More flake from unstable test data, rate limiting, and environment drift.
- Harder to reproduce failures.
- May be expensive or slow.
Mode 2, controlled responses for specific states
Use this when you need to force an empty, error, timeout, or partial-success condition on demand.
Pros:
- Deterministic coverage of edge states.
- Faster test execution.
- Easier debugging.
Cons:
- Can miss response-shape bugs or header-related behavior.
- Too much control can turn the test into a simulation of the app, not a test of it.
Mode 3, hybrid live plus targeted interception
Use live app code, but intercept one request to create a single edge case.
Pros:
- Preserves realistic app initialization.
- Lets you test a very specific state.
- Reduces the gap between test and production.
Cons:
- Requires careful control of the network layer.
- Can become complex when many requests depend on each other.
What to assert, visible outcomes first
A browser test that validates API-driven UI states should answer a simple question, what would a user notice?
That usually means asserting on:
- Visible text, not response payloads alone.
- Presence or absence of loading indicators.
- Disabled or enabled controls.
- Error banners, callouts, and retry buttons.
- Table row counts or card counts, if the UI presents those directly.
- Empty-state copy or placeholder content.
Avoid assertions like these as your only check:
- The network request returned 200.
- The response body included
items: []. - A specific CSS class was added.
Those are internal facts. They may matter, but only if the user-visible state also changed correctly.
Playwright example, validate a loading state followed by an empty state
Here is a compact example showing how to test a page that first loads data, then renders an empty state when the API returns no results.
import { test, expect } from '@playwright/test';
test('shows loading then empty state for an empty API response', async ({ page }) => {
await page.route('**/api/projects', route => {
setTimeout(() => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [] })
});
}, 700);
});
await page.goto(‘http://localhost:3000/projects’);
await expect(page.getByText(/loading projects/i)).toBeVisible(); await expect(page.getByText(/no projects yet/i)).toBeVisible({ timeout: 5000 }); await expect(page.getByRole(‘button’, { name: /create project/i })).toBeVisible(); });
This test does three useful things:
- It makes the UI wait long enough to show loading behavior.
- It checks the final user-visible empty state.
- It avoids coupling to response internals beyond the fact that the response is empty.
A subtle issue here is that the loading assertion should be short and the final assertion should allow time for rendering. If the app never shows a loading state for fast responses, that may be fine. But if your design promises a loading indicator, the test should verify it under realistic latency.
Testing an error state without making the whole browser test unrealistic
Error handling is one of the easiest places to over-mock, because developers often replace the whole backend with 500 responses. That can validate the banner, but not the app behavior around retries, stale data, or navigation.
A better approach is to keep the app real and inject a failure into only the relevant call.
import { test, expect } from '@playwright/test';
test('shows an error state when the projects API fails', async ({ page }) => {
await page.route('**/api/projects', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Internal Server Error' })
});
});
await page.goto(‘http://localhost:3000/projects’);
await expect(page.getByRole(‘alert’)).toContainText(/could not load projects/i); await expect(page.getByRole(‘button’, { name: /retry/i })).toBeVisible(); await expect(page.getByText(/project list/i)).toBeHidden(); });
When you write this kind of test, be careful not to assert the raw server message unless the product explicitly exposes it. In many apps, the UI should translate backend failures into user-friendly language.
Partial-success states, the most underrated test case
Partial-success is where a lot of real bugs live. For example, imagine a dashboard that loads account summary, recent activity, and recommendations through separate requests. If one request fails but the others succeed, the UI should not collapse into a generic error if the remaining information is still useful.
You can simulate this by allowing one request through and failing another.
import { test, expect } from '@playwright/test';
test('renders partial data when one API call fails', async ({ page }) => {
await page.route('**/api/recommendations', route => {
route.fulfill({ status: 503, body: 'Service Unavailable' });
});
await page.goto(‘http://localhost:3000/dashboard’);
await expect(page.getByText(/account summary/i)).toBeVisible(); await expect(page.getByText(/recent activity/i)).toBeVisible(); await expect(page.getByText(/recommendations unavailable/i)).toBeVisible(); });
This test matters because partial-success often reveals unclear product decisions. Should the dashboard still look healthy if one widget is broken? Should the user be able to refresh only the failed section? Should the error be global or local?
If you do not test this case, teams often end up with one of two bad patterns:
- The page hides everything because one request failed.
- The page shows incomplete data without any sign that something went wrong.
Handling dynamic UI timing without brittle sleeps
UI state tests become flaky when they use fixed delays instead of state-based waits. The correct approach is to wait for observable outcomes, not arbitrary time.
Good waits:
- Wait for a spinner to disappear.
- Wait for a card to appear.
- Wait for a button to become enabled.
- Wait for a network response and then the corresponding visible change.
Bad waits:
waitForTimeout(3000)as a default.- Sleeping just long enough for the test to usually pass.
- Assuming the data will arrive in the same order every time.
One nuance, do not over-wait on network events alone. A request might complete before the DOM updates, or the DOM might update from cached data without a fresh request. The safest pattern is often:
- Observe the request if needed for debugging.
- Assert on the visible UI state.
- Use the network only to stabilize the sequence.
Validate states at the component boundary and the flow boundary
There are two layers to this problem.
Component boundary
This is where you validate a single widget, such as a table, a form, or a panel.
Useful checks:
- Skeleton appears while the component loads.
- Empty and error messages are local to the component.
- Button state changes correctly after response arrival.
Flow boundary
This is where you validate a real user journey, such as login, search, checkout, or creating a record.
Useful checks:
- The page transitions from one state to another in the correct order.
- Multiple dependent API calls resolve into a coherent screen.
- The user can recover from an error without reloading the whole app.
A robust test suite needs both. Component-level checks are fast and focused. Flow-level checks reveal integration issues that component tests cannot see.
What not to mock away
If you want realistic coverage, keep these things as real as possible when the test is about UI state quality:
- Auth and session setup, unless the test is specifically about auth failures.
- App shell loading, routing, and hydration.
- API latency, at least occasionally.
- Response shape compatibility, especially optional or missing fields.
- Browser rendering and layout behavior.
What is reasonable to mock or intercept:
- A single endpoint needed to force a rare state.
- A third-party dependency that is not part of the behavior under test.
- A time-sensitive or expensive backend workflow, if the UI only needs the final state.
The main rule is simple, mock the minimum necessary to make the state reachable, not the minimum necessary to make the test pass.
A small checklist for test api-driven ui states
Before you add a new browser test, ask these questions:
- What exact user-visible state should appear?
- Is the state driven by a single API call or several concurrent calls?
- Do I need real backend behavior, or just one controlled response?
- What is the minimum network control needed to reproduce the state?
- Which assertion best matches what the user would notice?
- How will this fail if the backend returns unexpected but valid data?
- What should remain visible if only part of the response succeeds?
If you cannot answer the last two questions, the test probably still leans too hard on a happy-path fixture.
Debugging failures in these tests
When a browser test for UI states fails, the first instinct is often to inspect the selector. That is sometimes useful, but state tests usually fail for deeper reasons.
A practical debugging sequence:
- Confirm the request sequence, which endpoints fired and in what order.
- Check whether the UI showed the wrong state transiently before settling.
- Verify whether cached data from a previous test polluted the current run.
- Look for missing fields, not just failed status codes.
- Re-run with slower latency to expose race conditions.
Useful artifacts to capture include screenshots, console logs, and network traces. If your framework supports it, capture them on failure only so the suite stays fast.
Where Endtest can fit
If your team prefers a lower-code workflow, Endtest can be useful for scripting the browser journey while keeping assertions tied to visible user outcomes. Its AI Assertions feature is designed to validate what should be true on the page, in cookies, in variables, or in logs, which is handy when the UI state matters more than a specific selector.
That is especially relevant for loading, error, and empty-state checks where the exact DOM structure may change over time. Endtest’s agentic AI approach can create editable platform-native steps, which makes it easier to express, for example, “the page looks like a success state” without hard-coding fragile text fragments everywhere.
It is not a replacement for understanding your API contracts, but it can be a practical way to keep assertions closer to user outcomes when browser automation gets noisy.
A sane division of labor between API tests and browser tests
Do not ask browser tests to do all the work.
Use API tests for:
- Contract validation.
- Schema checks.
- Status code handling.
- Boundary and permission rules.
Use browser tests for:
- Loading behavior.
- Empty, error, and partial-success rendering.
- Real user flows that depend on multiple API calls.
- Whether the product communicates state clearly.
This split keeps your browser suite focused. It also reduces the temptation to assert low-level details in the wrong layer.
If you want to understand the broader categories behind this split, the basics of software testing, test automation, and continuous integration are useful reference points, especially when you are deciding what belongs in CI and what belongs in nightly runs.
Putting it into a maintainable project structure
A practical browser automation project for UI states usually ends up with a structure like this:
tests/ui/loading.spec.tstests/ui/empty-state.spec.tstests/ui/error-state.spec.tstests/ui/partial-success.spec.tstests/helpers/network.tstests/helpers/assertions.ts
Keep the network setup reusable, but do not abstract away the meaning of the test. A helper named setupProjectsFixture() is fine. A helper named makeEverythingPass() is not.
The test name should tell you the visible state under validation. If you have to open the file to know whether it covers empty or error behavior, the suite is already drifting toward maintenance debt.
Final checklist before you call it done
A strong test for API-driven UI states should satisfy most of these conditions:
- It reproduces a real or intentionally controlled API outcome.
- It verifies a visible loading, empty, error, or partial-success state.
- It avoids fixed sleeps unless there is no better option.
- It asserts on user-facing outcomes, not only request payloads.
- It fails clearly when the UI communicates the wrong state.
- It does not depend on brittle selectors for every check.
- It stays narrow enough that one failure points to one likely problem.
If your current browser suite only verifies that data eventually appears, it is probably missing the states that matter most to users. The goal is not to mock less for its own sake. The goal is to mock only where it helps you reach a meaningful state, then let the browser prove that the app presents that state correctly.
That balance is what makes browser automation valuable for API-driven interfaces, and it is the difference between a test suite that mostly confirms happy paths and one that actually protects the product.