Skip to content

Testing

λ Query is designed with testability in mind. The core is a plain JavaScript object with no global state, and the React bindings work with standard testing tools. This guide covers patterns for testing both the vanilla API and the React hooks.

import { describe, it, expect } from "vitest";
import { createQuery } from "@studiolambda/query";
describe("createQuery", () => {
it("fetches and caches data", async () => {
const query = createQuery({
fetcher: () => Promise.resolve({ name: "Erik" }),
});
const first = await query.query("/user");
const second = await query.query("/user");
expect(first).toEqual({ name: "Erik" });
expect(second).toEqual({ name: "Erik" });
// Both return the same cached value — only one fetch happened.
});
});
it("mutates cached data", async () => {
const query = createQuery({
fetcher: () => Promise.resolve({ count: 0 }),
});
await query.query("/counter");
await query.mutate("/counter", { count: 1 });
const snapshot = await query.snapshot("/counter");
expect(snapshot).toEqual({ count: 1 });
});
it("mutates based on previous value", async () => {
const query = createQuery({
fetcher: () => Promise.resolve({ count: 0 }),
});
await query.query("/counter");
await query.mutate("/counter", (prev) => ({ count: prev.count + 1 }));
const snapshot = await query.snapshot("/counter");
expect(snapshot).toEqual({ count: 1 });
});
it("forgets cached data", async () => {
const query = createQuery({
fetcher: () => Promise.resolve("data"),
});
await query.query("/key");
expect(query.keys("items")).toContain("/key");
await query.forget("/key");
expect(query.keys("items")).not.toContain("/key");
});
it("forgets by regex pattern", async () => {
const query = createQuery({
fetcher: (key) => Promise.resolve(key),
});
await query.query("/posts/1");
await query.query("/posts/2");
await query.query("/users/1");
await query.forget(/^\/posts/);
expect(query.keys("items")).not.toContain("/posts/1");
expect(query.keys("items")).not.toContain("/posts/2");
expect(query.keys("items")).toContain("/users/1");
});

The next() method is the preferred way to synchronize with cache operations in tests:

it("emits resolved events", async () => {
const query = createQuery({
fetcher: () => Promise.resolve("hello"),
});
const result = query.next("/key");
query.query("/key");
expect(await result).toBe("hello");
});
it("waits for multiple keys", async () => {
const query = createQuery({
fetcher: (key) => Promise.resolve(`data-${key}`),
});
const result = query.next(["/a", "/b"]);
query.query("/a");
query.query("/b");
const [a, b] = await result;
expect(a).toBe("data-/a");
expect(b).toBe("data-/b");
});
it("hydrates data into the cache", async () => {
const query = createQuery();
query.hydrate("/user", { name: "Erik" });
const snapshot = await query.snapshot("/user");
expect(snapshot).toEqual({ name: "Erik" });
});

Use React’s act() with a custom query instance to control fetching:

import { it, expect } from "vitest";
import { Suspense, act } from "react";
import { createRoot } from "react-dom/client";
import { createQuery } from "@studiolambda/query";
import { useQuery } from "@studiolambda/query/react";
it("renders fetched data", async () => {
const query = createQuery({
fetcher: () => Promise.resolve("works"),
});
function Component() {
const { data } = useQuery<string>("/user", { query });
return <div>{data}</div>;
}
const container = document.createElement("div");
const result = query.next("/user");
await act(async () => {
createRoot(container).render(
<Suspense fallback="loading">
<Component />
</Suspense>
);
});
await act(async () => {
await result;
});
expect(container.innerText).toBe("works");
});

Key points:

  • Pass the query instance directly to useQuery via the options — no need for a QueryProvider in tests.
  • Use query.next(key) to wait for the fetch to complete before asserting.
  • Wrap renders and updates in act() for proper React lifecycle handling.
it("updates the UI after mutation", async () => {
const query = createQuery({
fetcher: () => Promise.resolve("initial"),
});
function Component() {
const { data, mutate } = useQuery<string>("/key", { query });
return (
<div>
<span>{data}</span>
<button onClick={() => mutate("updated")}>Update</button>
</div>
);
}
const container = document.createElement("div");
const initialResult = query.next("/key");
await act(async () => {
createRoot(container).render(
<Suspense fallback="loading">
<Component />
</Suspense>
);
});
await act(async () => {
await initialResult;
});
expect(container.querySelector("span")!.textContent).toBe("initial");
// Click the update button.
await act(async () => {
container.querySelector("button")!.click();
});
expect(container.querySelector("span")!.textContent).toBe("updated");
});

A custom fetcher lets you control exactly what data is returned in tests without mocking fetch:

function createTestQuery(data: Record<string, unknown>) {
return createQuery({
fetcher: (key) => {
if (key in data) {
return Promise.resolve(data[key]);
}
return Promise.reject(new Error(`No test data for ${key}`));
},
});
}
// Usage:
const query = createTestQuery({
"/api/user": { name: "Erik", email: "erik@example.com" },
"/api/posts": [{ id: 1, title: "Hello" }],
});

Test that abort signals are properly handled:

it("aborts in-flight requests", async () => {
let aborted = false;
const query = createQuery({
fetcher: (key, { signal }) => {
signal.addEventListener("abort", () => {
aborted = true;
});
return new Promise(() => {}); // Never resolves.
},
});
query.query("/key");
query.abort("/key");
expect(aborted).toBe(true);
});