# Testing

> Component and unit tests with createTestClient: virtual time, typed mocks, replies, the call log, findings, and the same handlers on MSW.

Page: https://svelte-lean.vercel.app/query/testing · Markdown: https://svelte-lean.vercel.app/query/testing.md · Index: https://svelte-lean.vercel.app/llms.txt

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.

## Unit tests

`src/lib/api/members.test.ts`

```ts
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

`a refused save`

```ts
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

`a save refreshes the list`

```ts
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`

```svelte
<!-- 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`

```ts
// 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

`src/mocks/members.ts`

```ts
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) }
	)
];
```

`vitest.setup.ts`

```ts
// 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 on the virtual clock`

```ts
// 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 a `LiveSource` whose `emit(channel, event, payload)` delivers an event and whose `log` lists subscriptions.
- `memoryStore()` is an in-memory `PersistStore`; `persistStoreSuite(factory)` returns the contract's cases for your own store.

## Reference

- [@svelte-lean/query/testing](https://svelte-lean.vercel.app/query/api/testing.md)
- [Devtools](https://svelte-lean.vercel.app/query/devtools.md): the findings a test can assert
