Ramonda

Testing queries

There is no query-specific test API, and that is not an omission — the two tools you need are already in @ramonda/testing-library, and the line between them is the only thing worth learning.

act for work already scheduled, waitFor for a round trip

act commits everything already scheduled: pending renders, @mounteds, subscriptions. With an async callback it also gives the continuations a few microtask turns and commits after each — so a fetcher that resolves from memory (a cache hit, a stub returning Promise.resolve(…)) is fully settled by the time it returns.

const { container } = render(<UserCard id="1" />);

await act(async () => {});
expect(container.querySelector("#name")!.textContent).toBe("Ada");

What act does not do is wait for a timer or a real request. For that, use waitFor — it retries the assertion, and because renders are batched on a microtask the DOM catches up between attempts with nothing else to arrange:

await waitFor(() => {
  expect(container.querySelector("#name")!.textContent).toBe("Ada");
});

waitFor comes from @testing-library/dom, which @ramonda/testing-library re-exports whole — along with screen, within and every query.

Getting this wrong fails in one specific way: the assertion reads pending because the fetch had not landed. If that happens, you wanted waitFor.

A fresh client per test

Give each test its own QueryClient, and the reason is the same one there is no global client in the first place: a cache that outlives the test carries one test's data into the next, and the failure shows up as an order-dependent test that passes alone.

function mount(client = new QueryClient()) {
  class Harness extends Component {
    private query = this.use(QueryClientProvider, () => ({ client }));
    render() {
      return <UserCard id="1" />;
    }
  }
  return { client, ...render(<Harness />) };
}

Passing the client in also gives the test a handle on the cache — peek, setData, invalidate, all — which is usually a better assertion than digging through the DOM:

const { client } = mount();
await act(async () => {});
expect(client.peek<User>(["user", "1"])!.data).toEqual({ name: "Ada" });

Seeding instead of stubbing

A test about rendering does not have to go through a fetcher at all. setData puts the data in the cache directly, and a query that finds fresh data does not fetch:

const client = new QueryClient({ defaults: { staleTime: 60_000 } });
client.setData(["user", "1"], { name: "Ada" });

const { container } = mount(client);
// Already filled in on the first render — no fetch, no waiting.
expect(container.querySelector("#name")!.textContent).toBe("Ada");

Without a staleTime the data is stale the moment it arrives, so mounting refreshes it — see refetchOnMount. That is usually what you want in an app and rarely what you want in a test.

Controlling time without a fake clock

QueryClient takes a now function. Staleness is arithmetic on timestamps, and a test that has to advance a fake clock to check "is this five minutes old" ends up testing the fake clock:

let now = 1_000;
const client = new QueryClient({ now: () => now, defaults: { staleTime: 1_000 } });

await client.fetch(["k"], loadThing);
expect(client.isStale(["k"], 1_000)).toBe(false);

now += 1_000;
expect(client.isStale(["k"], 1_000)).toBe(true);

For polling you do need a timer — fake only the one you mean, or the framework's own update batching goes with it:

vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });

Asserting that something did NOT fetch

Most of what is interesting about a cache is the request that did not happen, so count calls rather than only checking the rendered output:

const fetcher = vi.fn(async () => ({ name: "Ada" }));
// … mount two observers of the same key …
expect(fetcher).toHaveBeenCalledTimes(1);

Failures

Reject from the fetcher and set retry: 0, or the test waits out the backoff:

this.use(Query, () => ({
  key: ["user", "1"],
  fetch: async () => {
    throw new Error("503");
  },
  retry: 0,
}));

Remember that a failed refetch keeps the previous data — status becomes "error" while data still holds the last good value — so assert on both if that is the behaviour you care about.

A server render, end to end

The SSR path is worth one test, because it is the part with no app wiring to inspect:

const html = await renderToString(<App />);
expect(html).toContain("Ada");

// Hydrate the very markup the server produced.
const { container } = render(<App />, { hydrate: html });

// Before any settling: the first client render must already show the server's data,
// or hydration would have thrown the markup away.
expect(container.querySelector("#name")!.textContent).toBe("Ada");

See queries on the server for why that first render matters.

Next

  • Testing — rendering, act, hooks, and SSR in general.