A test stubs a component's API call with an instantly-resolved response, so its loading skeleton disappears before any assertion can run. How do you make the pending state observable in a test without adding a fixed sleep?
answer
- control when the response settles
- delay belongs in the mock
- a promise the test resolves by hand
- wait for a condition, not a duration
- assert the skeleton disappears too
basics
~20 sTake control of when the stubbed response settles: either inject a delay into the handler, or hand the test a promise it resolves by hand. Assert the skeleton while the request is pending, release the response, then assert it is replaced by the data.
solid answer
~50 sThe problem is that the test has no control over *when* the response arrives, so the pending window is shorter than the first assertion. Two techniques fix that. The simplest is an **injected delay** in the mock handler — the response resolves after a controlled interval, wide enough that the skeleton is present when you assert it, and you then wait for the loaded UI with a retrying query such as `findByRole`. The stronger technique is a **deferred response**: the handler returns a promise the test itself holds, so the test asserts the skeleton, then explicitly resolves the promise and asserts the final state. That version has no timing at all in it — the pending window lasts exactly as long as the test wants. What you never do is `await new Promise(r => setTimeout(r, 500))` before asserting: a fixed sleep is both slow and unreliable, because it encodes a guess about machine speed rather than a fact about the component.
code
javascript · 26 linesimport { render, screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from './test-server'
import { Report } from './Report'
test('shows the skeleton until the response is released', async () => {
let release
const pending = new Promise((resolve) => { release = resolve })
server.use(
http.get('/api/report', async () => {
await pending
return HttpResponse.json({ rows: [{ id: 1, label: 'Q1' }] })
})
)
render(<Report />)
expect(screen.getByRole('progressbar')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
release()
expect(await screen.findByRole('table')).toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})go deeper
Know that a loading state can only be asserted while the request is still pending, and that the way to get there is to make the stubbed response wait rather than to sleep in the test.
Explain both mechanisms — an injected delay in the handler versus a promise the test resolves itself — and why waits in tests should be conditional on an element appearing rather than on a fixed duration.
Show when a virtual clock is the right tool because the component schedules the delay, and be ready to name the traps: retrying queries stalling on a frozen clock and simulated user input needing the clock advanced for it.
Own the suite-level convention for this: where delays are allowed to live, a shared deferred helper so every team writes the pending test the same way, and a standing rule against absolute sleeps in test bodies.
## Why the skeleton vanishes A mock that returns an already-resolved value settles on the very next microtask. By the time your first assertion runs, the component has usually already re-rendered with data, so `expect(screen.getByRole('progressbar')).toBeInTheDocument()` fails with "unable to find" — and the developer's instinct is to delete the assertion, which is how loading states end up untested. The pending state is real UI with real bugs in it (a skeleton whose height differs from the loaded content, a spinner that never gets removed, a disabled submit button that stays disabled), so it deserves a deterministic test rather than a deleted one. ## Technique 1 — injected delay in the handler Most network-interception tools let a handler wait before answering. In MSW v2 this is the `delay()` helper you `await` inside the handler; with a hand-rolled module double it is a promise wrapped around a timer. The test then reads: ```js render(<Report />) expect(screen.getByRole('progressbar')).toBeInTheDocument() expect(await screen.findByRole('table')).toBeInTheDocument() ``` The first assertion is synchronous and runs while the request is still in flight; the second uses a **retrying** query that polls until the table appears. Notice that no number appears in the test body — the delay lives in the handler, and the wait is expressed as "until this exists", not "for this many milliseconds". That distinction is the whole point. A delay of a few tens of milliseconds is enough; a large delay just makes the suite slow. The risk with injected delay is that it is still a race, merely one you have tilted heavily in your favour. On a loaded CI machine a synchronous assertion placed after several `await`ed setup lines can still arrive late. Keep the pending assertion as the first thing after render, and prefer technique 2 when the pending state is important enough to have its own test. ## Technique 2 — a deferred you resolve by hand Here the handler returns a promise whose resolver the test keeps: ```js let release const pending = new Promise((resolve) => { release = resolve }) ``` The stub awaits `pending` before answering. Now the pending window is unbounded and entirely under the test's control: assert the skeleton, assert that the retry button is absent, assert whatever else belongs to the pending state, then call `release(data)` and await the loaded UI. There is no timing assumption left anywhere in the test, which makes it immune to CI slowness. The cost is a little more plumbing per test, and the discipline of always resolving the promise — a test that forgets to release it hangs until the runner's timeout. ## Where fake timers fit, and where they hurt A virtual clock is the right tool when the *component* schedules time — a debounce before the request, a delayed spinner that only appears after 300ms to avoid flashing, a backoff between retries. You advance the clock explicitly instead of waiting. Two cautions. First, a retrying query and a frozen clock can deadlock: the query waits for a timer that only advances if something advances it, so use the runner's async timer-advancing helper and configure the retrying wait to cooperate with fake timers. Second, user-event simulations schedule their own timers between keystrokes, so they need to be told how to advance the fake clock, or interactions appear to hang. If the delay you care about is the *network's* rather than the component's, the deferred promise is simpler and has none of these hazards. ## Why a fixed sleep is the wrong answer `await new Promise(r => setTimeout(r, 500))` fails on both axes. It is **slow** — multiply it by every test that fetches, and a suite loses minutes. And it is **unreliable in the wrong direction**: it passes on your laptop and fails on a contended CI runner, which is the classic recipe for a test people start re-running until it goes green. Any wait in a test should be conditional ("until this element exists") rather than absolute ("for this long"), and any absolute duration should live in the mock, where it is a controlled input, not in the assertions. ## What the loading test should assert Assert the skeleton is present, that the eventual content is not, and — after release — that the skeleton is gone and the content is there. The final negative assertion matters: a spinner that survives alongside loaded data is a common bug that a purely positive test never notices.
- When would you reach for fake timers instead of a deferred promise here?When the delay belongs to the component rather than the network — a debounce before the request fires, a spinner deliberately held back for 300ms, or the gap between retry attempts. Then you advance the virtual clock to assert the behaviour at each point. If it is simply "the server has not answered yet", the deferred promise is simpler and avoids the interaction hazards fake timers bring.
- Your delayed-handler test passes locally but fails intermittently on CI at the skeleton assertion. What is going on?The injected delay only tilts a race, it does not remove it. On a slower or contended machine the response can settle before your synchronous assertion runs, especially if `await`ed setup lines sit between render and the assertion. Move the pending assertion immediately after render, and for a test whose whole subject is the pending state, switch to a deferred you release by hand.
- Why is asserting that the spinner disappears worth a separate line?Because a spinner left rendered next to loaded content is a genuine and common bug — usually a missing `finally` or a loading flag set in only one branch. A purely positive test that finds the table passes happily while the page shows both. The negative assertion pins the whole end state rather than one element of it.
saying these in an interview costs you the question
- Adds a fixed setTimeout sleep before asserting the spinner.
- Deletes the loading assertion because it kept failing.
- Puts a large delay in every handler, slowing the whole suite.
- Thinks a retrying query can catch a state that already ended.
- Freezes timers and then waits on a retrying query, deadlocking the test.