Guides
Testing
Test components and loaders that use raf-query with a fresh client per test, seeded data, a fake clock and a fake page.
Components that read queries need a QueryClient and handles. In tests, create both directly. There's nothing to mock in raf-query itself: mock your API, or seed the cache.
A client per test
Create a new client for every test, so no cached data leaks between them. Turn retries off, or a failing request takes seconds to fail:
import { class QueryClient<TServices = never>QueryClient, function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider } from "@raf-query/react";
let let queryClient: QueryClient<never>queryClient: class QueryClient<TServices = never>QueryClient;
beforeEach<object>(fn: BeforeEachListener<object>, timeout?: number): voidbeforeEach(() => {
let queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({ Config.defaults?: QueryBehavior | undefineddefaults: { QueryBehavior.retry?: RetryPolicy | undefinedretry: false } });
});
afterEach<object>(fn: AfterEachListener<object>, timeout?: number): voidafterEach(async () => {
await let queryClient: QueryClient<never>queryClient.QueryClient<never>.dispose(): Promise<void>dispose();
});
function function renderWithClient(ui: ReactNode): Promise<RenderResult<typeof import(".pnpm/@testing-library+dom@10.4.2/node_modules/@testing-library/dom/types/queries"), HTMLElement, HTMLElement>>renderWithClient(ui: ReactNodeui: type ReactNode = string | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<ReactNode> | React.ReactPortal | Promise<AwaitedReactNode> | null | undefinedReactNode) {
return act<RenderResult<typeof import(".pnpm/@testing-library+dom@10.4.2/node_modules/@testing-library/dom/types/queries"), HTMLElement, HTMLElement>>(callback: () => RenderResult<typeof import(".pnpm/@testing-library+dom@10.4.2/node_modules/@testing-library/dom/types/queries"), HTMLElement, HTMLElement> | Promise<RenderResult<typeof import(".pnpm/@testing-library+dom@10.4.2/node_modules/@testing-library/dom/types/queries"), HTMLElement, HTMLElement>>): Promise<...> (+1 overload)act(async () =>
function render(ui: React.ReactNode, options?: Omit<RenderOptions<typeof import(".pnpm/@testing-library+dom@10.4.2/node_modules/@testing-library/dom/types/queries"), HTMLElement, HTMLElement>, "queries"> | undefined): RenderResult (+1 overload)render(
<function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider client: QueryClient<unknown>client={let queryClient: QueryClient<never>queryClient}>{ui: ReactNodeui}</function QueryClientProvider(props: {
readonly children?: ReactNode;
readonly client: QueryClient<unknown>;
}): React.JSX.Element
QueryClientProvider>,
),
);
}dispose() interrupts in-flight requests and timers, so nothing runs after the test ends.
Render inside an awaited, async act. A tree that suspends inside a sync act, which render on its own uses, is never retried by React once its data lands, with or without a Suspense boundary. The same holds for any use(promise), not only raf-query's.
Testing a component
A component takes a handle as a prop, so a test can start the query itself, the same way a loader would:
test<object>(name: string | Function, fn?: TestFunction<object> | undefined, options?: number): void (+1 overload)test("shows the post", async () => {
const const handle: QueryHandle<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle = let queryClient: QueryClient<never>queryClient.QueryClient<never>.prefetch<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, input: {
slug: string;
}): QueryHandle<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
prefetch(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, { slug: stringslug: "hello" });
await function renderWithClient(ui: ReactNode): Promise<unknown>renderWithClient(
<const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense React.SuspenseProps.fallback?: ReactNodefallback="Loading…">
<function Post(props: {
handle: QueryHandle<typeof postQuery>;
}): ReactNode
Post handle: QueryHandle<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle={const handle: QueryHandle<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Suspense>,
);
expect<string>(actual: string, message?: string): Assertion<void, string> (+1 overload)expect((await const screen: Screen<typeof import(".pnpm/@testing-library+dom@10.4.2/node_modules/@testing-library/dom/types/queries")>screen.findByRole<HTMLElement>(role: ByRoleMatcher, options?: ByRoleOptions | undefined, waitForElementOptions?: waitForOptions | undefined): Promise<HTMLElement> (+1 overload)findByRole("heading")).Element.textContent: stringtextContent).JestAssertion<void, string>.toBe: <string>(expected: string) => voidtoBe("Hello");
});For a component that takes a QueryHandleWithData, pass await queryClient.ensure(...) instead.
Seeding the cache
To render with known data and no request at all, write it with setData before rendering:
const queryClient: QueryClient<never>queryClient.QueryClient<never>.setData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, input: {
slug: string;
}, updater: QueryCache.Updater<Post>): void
setData(
function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery,
{ slug: stringslug: "hello" },
{ Post.slug: stringslug: "hello", Post.title: stringtitle: "Hello", Post.body: stringbody: "", Post.authorId: stringauthorId: "" },
);
const const handle: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>
handle = await const queryClient: QueryClient<never>queryClient.QueryClient<never>.ensure<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, input: {
slug: string;
}): Promise<QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>> (+1 overload)
ensure(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, { slug: stringslug: "hello" });
Data written with setData is fresh, so ensure resolves at once without calling the queryFn.
Mocking the network
The queryFn is your code, so mock what it calls. With fetch, MSW intercepts requests without changing application code:
const server: {
use(...handlers: ReadonlyArray<unknown>): void;
}
server.function use(...handlers: ReadonlyArray<unknown>): voiduse(
const http: {
get(path: string, resolver: () => unknown): unknown;
}
http.function get(path: string, resolver: () => unknown): unknownget("/api/posts/:slug", () =>
const HttpResponse: {
json(body: unknown): unknown;
}
HttpResponse.function json(body: unknown): unknownjson({ slug: stringslug: "hello", title: stringtitle: "Hello" }),
),
);
Testing loaders
A loader is a function of its context. Export it from the route file, call it with a client, and check what it started:
test<object>(name: string | Function, fn?: TestFunction<object> | undefined, options?: number): void (+1 overload)test("the post loader starts both queries", async () => {
await function postLoader(args: {
context: {
queryClient: QueryClient;
};
params: {
slug: string;
};
}): Promise<{
comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<Flat<{
key: readonly ["comments", string];
page: CommentPage;
pageParam: number;
}>>>;
post: QueryHandleWithData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>;
}>
postLoader({
context: {
queryClient: QueryClient;
}
context: { queryClient: QueryClient<never>queryClient },
params: {
slug: string;
}
params: { slug: stringslug: "hello" },
});
expect<Post | undefined>(actual: Post | undefined, message?: string): Assertion<void, Post | undefined> (+1 overload)expect(const queryClient: QueryClient<never>queryClient.QueryClient<never>.getData<(input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>>(factory: (input: {
slug: string;
}) => QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>, input: {
slug: string;
}): Post | undefined
getData(function postQuery(input: {
slug: string;
}): QueryOptions<Flat<{
data: Post;
key: readonly ["post", string];
}>>
postQuery, { slug: stringslug: "hello" })).JestAssertion<void, Post | undefined>.toBeDefined: () => voidtoBeDefined();
expect<number>(actual: number, message?: string): Assertion<void, number> (+1 overload)expect(const queryClient: QueryClient<never>queryClient.QueryClient<never>.isFetching(filter?: Filter): numberisFetching({ Filter.queryKey?: QueryKey | undefinedqueryKey: ["comments"] })).JestAssertion<void, number>.toBe: <number>(expected: number) => voidtoBe(1);
});Time, focus and the network
Staleness, garbage collection, retries and polling depend on time; focus refetching and offline retries depend on the page. raf-query reads both from Effect services, so a test can replace them instead of mocking globals:
- Time comes from Effect's
Clock. UseTestClockto move time forward instantly. - The page comes from the
Platformservice: whether it's visible, whether it's online, and its focus and reconnect events.
const const listeners: Set<(event: "focus" | "online") => void>listeners = new var Set: SetConstructor
new <(event: "focus" | "online") => void>(iterable?: Iterable<(event: "focus" | "online") => void> | null | undefined) => Set<(event: "focus" | "online") => void> (+1 overload)
Set<(event: "focus" | "online"event: "focus" | "online") => void>();
let let visible: booleanvisible = true;
const const FakePlatform: Layer.Layer<never, never, never>FakePlatform = import LayerLayer.const succeed: <never, Platform.Platform>(service: Key<never, Platform.Platform>, resource: Platform.Platform) => Layer.Layer<never, never, never> (+1 overload)succeed(import PlatformPlatform.const Platform: Reference<Platform.Platform>Platform, {
Platform.isOnlineUnsafe(): booleanisOnlineUnsafe: () => true,
Platform.isServer: booleanisServer: false,
Platform.isVisibleUnsafe(): booleanisVisibleUnsafe: () => let visible: booleanvisible,
Platform.subscribeUnsafe(listener: (event: Platform.PlatformEvent) => void): () => voidsubscribeUnsafe(listener: (event: Platform.PlatformEvent) => voidlistener) {
const listeners: Set<(event: "focus" | "online") => void>listeners.Set<(event: "focus" | "online") => void>.add(value: (event: "focus" | "online") => void): Set<(event: "focus" | "online") => void>add(listener: (event: Platform.PlatformEvent) => voidlistener);
return () => const listeners: Set<(event: "focus" | "online") => void>listeners.Set<(event: "focus" | "online") => void>.delete(value: (event: "focus" | "online") => void): booleandelete(listener: (event: Platform.PlatformEvent) => voidlistener);
},
});
function function focus(): voidfocus() {
let visible: booleanvisible = true;
for (const const listener: (event: "focus" | "online") => voidlistener of const listeners: Set<(event: "focus" | "online") => void>listeners) const listener: (event: "focus" | "online") => voidlistener("focus");
}
const const queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({ QueryClientConfig<never>.layer?: Layer.Layer<never, never, never> | undefinedlayer: const FakePlatform: Layer.Layer<never, never, never>FakePlatform });With a fake clock, a test of staleTime or gcTime runs in milliseconds. @effect/vitest's it.effect provides TestClock:
const it: Vitest.Methods<never, {}>it.Vitest.MethodsNonLive<never, {}>.effect: Vitest.Tester
<void, unknown>(name: string, self: Vitest.TestFunction<void, unknown, Scope, [TestContext]>, timeout?: number | TestOptions) => void
effect("garbage-collects unread entries", () =>
import EffectEffect.const gen: <Effect.Effect<QueryCache.QueryCache, never, QueryCache.QueryCache> | Effect.Effect<QueryHandleWithData<() => InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>>, unknown, never> | Effect.Effect<void, never, never>, void>(f: () => Generator<Effect.Effect<QueryCache.QueryCache, never, QueryCache.QueryCache> | Effect.Effect<QueryHandleWithData<() => InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>>, unknown, never> | Effect.Effect<...>, void, never>) => Effect.Effect<...> (+1 overload)
gen(function* () {
const const cache: QueryCache.QueryCachecache = yield* import QueryCacheQueryCache.const QueryCache: Service<QueryCache.QueryCache, QueryCache.QueryCache>QueryCache;
yield* const cache: QueryCache.QueryCachecache.QueryCache.fetch<() => InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>>(factory: () => InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>, input?: undefined): Loaded<() => InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>> (+1 overload)
fetch(function todosQuery(): InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>
todosQuery);
yield* import TestClockTestClock.const adjust: (duration: Input) => Effect.Effect<void>adjust("5 minutes");
expect<boolean>(actual: boolean, message?: string): Assertion<void, boolean> (+1 overload)expect(import OptionOption.const isNone: <InfiniteData<{
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>(self: Option.Option<InfiniteData<{
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>) => self is Option.None<InfiniteData<{
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>
isNone(yield* const cache: QueryCache.QueryCachecache.QueryCache.getData<() => InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>>(factory: () => InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>, input?: undefined): Effect.Effect<Option.Option<InfiniteData<{
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>, never, never>
getData(function todosQuery(): InfiniteQueryOptions<Flat<{
key: readonly ["todos"];
page: {
items: readonly Todo[];
nextCursor: null;
};
pageParam: number;
}>>
todosQuery))).JestAssertion<void, boolean>.toBe: <boolean>(expected: boolean) => voidtoBe(true);
}).Pipeable.pipe<Effect.Effect<void, unknown, QueryCache.QueryCache>, Effect.Effect<void, unknown, never>, Effect.Effect<void, unknown, never>>(this: Effect.Effect<void, unknown, QueryCache.QueryCache>, ab: (_: Effect.Effect<void, unknown, QueryCache.QueryCache>) => Effect.Effect<void, unknown, never>, bc: (_: Effect.Effect<void, unknown, never>) => Effect.Effect<void, unknown, never>): Effect.Effect<void, unknown, never> (+21 overloads)pipe(import EffectEffect.const provide: <QueryCache.QueryCache, never, never>(layer: Layer.Layer<QueryCache.QueryCache, never, never>, options?: {
readonly local?: boolean | undefined;
} | undefined) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, QueryCache.QueryCache>> (+5 overloads)
provide(import QueryCacheQueryCache.const layer: (config?: QueryCache.Config) => Layer.Layer<QueryCache.QueryCache>layer()), import EffectEffect.const provide: <never, never, never>(layer: Layer.Layer<never, never, never>, options?: {
readonly local?: boolean | undefined;
} | undefined) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, never>> (+5 overloads)
provide(const FakePlatform: Layer.Layer<never, never, never>FakePlatform)),
);In a plain Vitest test without Effect, vi.useFakeTimers() also works: the default clock reads Date.now() and schedules with setTimeout.
Server rendering
A client created where window isn't defined behaves as a server client: no retries, no garbage collection, cached data never stale. To test browser behavior in Node without a DOM environment, pass isServer: false:
const const queryClient: QueryClient<never>queryClient = new new QueryClient<never>(config?: QueryClientConfig<never>): QueryClient<never>QueryClient({ Config.isServer?: boolean | undefinedisServer: false });