An asynchronous test must wait for the behavior it is checking, and a test that calls a live API is often slow and unpredictable. Keep the application logic testable by passing the network operation into it or mocking it at the request boundary. Then explicitly test success and failure paths.
Keep response handling separate #
Consider a function that loads a record and parses the response:
export async function loadProfile(fetcher, url) {
const response = await fetcher(url);
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
return response.json();
}
Passing fetcher lets the test control the network outcome without changing the function’s behavior. In a larger application this dependency can be wrapped in a service module.
Test success and rejection #
Vitest can provide a predictable mock for each case:
import { expect, it, vi } from "vitest";
import { loadProfile } from "./loadProfile.js";
it("returns parsed profile data", async () => {
const fetcher = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 7, name: "Ada" }),
});
await expect(loadProfile(fetcher, "/api/profile/7"))
.resolves.toEqual({ id: 7, name: "Ada" });
expect(fetcher).toHaveBeenCalledWith("/api/profile/7");
});
it("reports a non-success status", async () => {
const fetcher = vi.fn().mockResolvedValue({ ok: false, status: 503 });
await expect(loadProfile(fetcher, "/api/profile/7"))
.rejects.toThrow("Request failed: 503");
});
Use await with the promise assertion. If the test forgets to await the promise, the test runner may finish before the assertion runs.
Test loading UI separately #
For a UI, the observable contract is often the state shown to the user: a loading indicator, data after success, or a useful error after failure. Test those states through the component’s public inputs and outputs. Keep the deferred promise under test control so the loading state can be asserted before resolving it.
Avoid fragile async tests #
- Do not use arbitrary
setTimeoutdelays to guess when a request has completed. - Resolve or reject the promise directly in the test.
- Make every test await the behavior it starts.
- Reset mocks between tests so call history cannot leak.
- Use a separate integration test when you need to verify the real HTTP client or server contract.
Deterministic async tests make race conditions easier to reason about while keeping most checks independent of network access.