Query Tooling
Testing
A test client answers endpoints with typed mocks on a virtual clock, so a test of polling, retries or races runs in milliseconds and the same way every time.
Markdown version of this page, for assistants (Markdown for AI)
On this page
Unit tests
it('loads the list once and reports nothing to fix', async () => {
using t = createTestClient({ can: () => true }); // virtual clock, disposed at the end
t.mock(listMembers, pageOf([ada]), { latency: 50 });
const list = createQuery(listMembers, () => ({}));
t.observe(() => list.data); // a binding starts when it is read
expect(list.loading).toBe(true);
await t.advance(50);
expect(list.data?.rows).toEqual([ada]);
expect(t.calls(listMembers)).toMatchObject([{ cause: 'mount', priority: 'auto' }]);
expect(t.findings()).toEqual([]); // devtools runs in every test client
});using disposes the client at the end of the block. Bindings are lazy: t.observe() reads one the way a component would. A handler is checked against the endpoint's
types; a request with no mock is an error that names it.
Errors
it('keeps field errors typed by the body', async () => {
using t = createTestClient({ can: () => true });
t.mock(updateMember, () => reply.validation({ email: 'This address is taken.' }));
const save = createMutation(updateMember);
await save.run({ id: 1, email: 'amara@harbor.studio' }).catch(() => {});
expect(save.error?.kind).toBe('validation');
expect(save.error?.fields?.fields.email).toEqual(['This address is taken.']);
});Invalidation
it('refetches the observed list after a save', async () => {
using t = createTestClient({ can: () => true });
t.mock(listMembers, pageOf([ada]));
t.mock(updateMember, ({ params }) => ({ ...ada, ...params, id: Number(params.id) }));
const list = createQuery(listMembers, () => ({}));
t.observe(() => list.data);
await t.flush();
await createMutation(updateMember).run({ id: 1, name: 'Amara O.' });
await t.flush();
expect(t.calls(listMembers).map((call) => call.cause)).toEqual(['mount', 'invalidate']);
});Component tests
<!-- src/routes/members/MembersList.svelte -->
<script lang="ts">
import { createQuery } from '@svelte-lean/query';
import { listMembers } from '$api/members';
const members = createQuery(listMembers, () => ({}));
</script>
{#if members.loading}<p>Loading members…</p>{/if}
{#if members.error}<p role="alert">The members could not be loaded.</p>{/if}
<ul>
{#each members.data?.rows ?? [] as member (member.id)}<li>{member.name}</li>{/each}
</ul>// src/routes/members/MembersList.test.ts
// @vitest-environment happy-dom
import { flushSync, mount, unmount } from 'svelte';
import { expect, it } from 'vitest';
import { createTestClient, reply } from '@svelte-lean/query/testing';
import { listMembers } from '$api/members';
import { ada, pageOf } from './fixtures';
import MembersList from './members-list.svelte';
it('renders loading, then the rows', async () => {
using t = createTestClient({ can: () => true }); // the component finds it without setClient
t.mock(listMembers, pageOf([ada]), { latency: 200 });
const app = mount(MembersList, { target: document.body });
flushSync();
expect(document.body.textContent).toContain('Loading members…');
await t.advance(200);
flushSync();
expect(document.querySelectorAll('li')).toHaveLength(1);
unmount(app);
});
it('renders the error without retrying a 4xx', async () => {
using t = createTestClient({ can: () => true });
t.mock(listMembers, reply.http(404));
const app = mount(MembersList, { target: document.body });
await t.flush();
flushSync();
expect(document.querySelector('[role="alert"]')).not.toBeNull();
expect(t.calls(listMembers)).toHaveLength(1);
unmount(app);
});The test client is installed as the active client in every environment, so a mounted component
finds it without setClient. A test that uses the application's own client (its
transport over a mocked fetch) calls setClient(client), in a wrapper
component or in beforeEach: code after an await then reaches it,
whatever client the application created first, and client.dispose() hands the realm back.
Test client
| Method | Does |
|---|---|
t.mock(endpoint, handler, options?) | Answers one endpoint: a value, a reply or a function of the call; latency, when and times as options. |
t.advance(time) | Moves the virtual clock: latencies, polls, freshness and retries run in order. |
t.flush() | Settles everything that is due now. |
t.observe(read) | Reads a binding in a reactive context, so it starts; returns the teardown. |
t.calls(endpoint?) | The call log: params, cause, priority, attempt, scope, outcome. |
t.pending() | Calls still waiting for an answer. |
t.findings() | What the development detectors found (devtools runs in every test client). |
t.hide(), t.show() | The document hidden and visible again. |
t.disconnect(), t.reconnect() | The network lost and back. |
t.seedEntry(endpoint, params, value) | A cached value before the test starts. |
createTestClient() takes the client's own options (can, scope, modules, defaults) plus profile (handlers answer server-shaped bodies through the profile), unhandled and seed for retry jitter.
Replies
| Reply | Answers with |
|---|---|
reply.validation(fields) | A 422 with field errors, typed by the mutation body. |
reply.http(status) | An HTTP error; 401 and 403 become forbidden. |
reply.refused() | A refusal inside a 2xx answer. |
reply.network(), reply.timeout() | A failed or timed-out request (retried for queries). |
reply.never() | No answer: the request stays in flight. |
reply.sequence(a, b, …) | One answer per call, the last repeated. |
reply.deferred() | An answer the test resolves or rejects later. |
reply.meta(value, meta) | A value with server-driven invalidation hints. |
MSW
export const routes = [
route(getMember, ({ params }) => (Number(params.id) === ada.id ? ada : reply.http(404))),
route(updateMember, ({ params }) =>
params.email?.endsWith('@harbor.studio') === false
? reply.validation({ email: 'Use a harbor.studio address.' })
: { ...ada, ...params, id: Number(params.id) }
)
];// On the network level: method and path come from each endpoint (no msw import in the package).
const server = setupServer(
...(mswHandlers(msw, 'https://api.example', routes) as RequestHandler[])
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());route() pairs an endpoint with the same typed handler t.mock takes. mswHandlers() builds MSW handlers from the application's own http namespace: method and path from the endpoint, path params, search params and the parsed body handed
to the handler, the answer written through the profile. The package imports nothing from msw.
// The same routes answer a unit test, with virtual time instead of the network.
using t = createTestClient({ can: () => true });
for (const r of routes) t.mock(r);Realtime and persistence
fakeSource()is aLiveSourcewhoseemit(channel, event, payload)delivers an event and whoseloglists subscriptions.memoryStore()is an in-memoryPersistStore;persistStoreSuite(factory)returns the contract's cases for your own store.
Reference
- @svelte-lean/query/testing
- Devtools: the findings a test can assert