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.
Testing the Core API
Section titled “Testing the Core API”Basic Query Test
Section titled “Basic Query Test”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. });});Testing Mutations
Section titled “Testing Mutations”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 });});Testing Cache Invalidation
Section titled “Testing Cache Invalidation”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");});Testing Events with next()
Section titled “Testing Events with next()”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");});Testing Hydration
Section titled “Testing Hydration”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" });});Testing React Hooks
Section titled “Testing React Hooks”Testing useQuery
Section titled “Testing useQuery”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
queryinstance directly touseQueryvia the options — no need for aQueryProviderin 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.
Testing Mutations
Section titled “Testing Mutations”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");});Replacing the Fetcher
Section titled “Replacing the Fetcher”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/posts": [{ id: 1, title: "Hello" }],});Aborting in Tests
Section titled “Aborting in Tests”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);});