@sanity-labs/client-fake-for-test
v0.4.0
Published
In-memory fake of @sanity/client for tests. Implements the data-plane surface (fetch / getDocument / mutate / patch / transaction / actions / releases / perspectives) against a groq-js evaluator and a closure-scoped store, with mutation-log inspection, a
Downloads
536
Readme
@sanity-labs/client-fake-for-test
[!NOTE] In-memory fake of
@sanity/clientfor tests. Drop-in for the data-plane methods (fetch,getDocument,patch,createOrReplace,transaction, …), backed by groq-js and a closure-scoped document store. No network, no global state, fresh per test.
npm install -D @sanity-labs/client-fake-for-testWhat this is for
Any code typed against SanityClient (or a narrow subset of its methods) that you want to test without:
- Hitting the network — real datasets are slow, flaky, rate-limited, and shared across runs.
- Mocking individual methods one-by-one — drifts away from the real client's surface shape, and breaks the moment you add a call.
- Standing up a stub HTTP server (MSW, nock) — fine for HTTP-shaped tests, overkill for "did my patch chain do the right thing".
Typical use cases:
- Server functions that read or write Sanity content — Sanity Functions, route handlers, custom backends.
- Hooks and utilities typed against
SanityClient. - Migration scripts — exercise the patch chain against a synthetic document set, assert end state.
- Schema validators and other tooling that walks documents via GROQ.
- Workflow / orchestration engines that mutate Sanity state — see
@sanity-labs/workflow-engine-explore, the original consumer.
Quickstart
import { createTestClient } from "@sanity-labs/client-fake-for-test";
const client = createTestClient({
documents: [
{ _id: "article-1", _type: "article", title: "Hello", state: "draft" },
{ _id: "article-2", _type: "article", title: "World", state: "approved" },
],
});
// fetch — GROQ via groq-js against the in-memory store
const approved = await client.fetch<{ _id: string }[]>(
`*[_type == "article" && state == $s]{_id}`,
{ s: "approved" },
);
// → [{ _id: "article-2" }]
// patch + commit
await client.patch("article-1").set({ state: "approved" }).commit();
// transaction — atomic batch
await client.transaction()
.patch("article-1", (p) => p.set({ reviewedBy: "alice" }))
.patch("article-2", (p) => p.set({ reviewedBy: "alice" }))
.commit();
// snapshot — read the raw store for assertions
expect(client.snapshot().find((d) => d._id === "article-1")?.state).toBe("approved");More patterns — passing the fake to code typed against SanityClient, asserting on writes, sharing a store across consumers, simulating ACL denials — in docs/patterns.md.
Client surface — what's supported and what isn't
SanityClient is large. This fake covers the data-plane methods most code reaches for, and intentionally leaves the rest out. Compatibility is structural — if your code is typed against the supported subset (or all of SanityClient and only touches these methods), the fake drops in unchanged.
Supported
| Area | Method | Notes |
| --- | --- | --- |
| Read | fetch<T>(query, params?, { filterResponse?, returnQuery? }) | GROQ via groq-js. filterResponse: false → { result, ms, query } (RawQueryResponse), like the real client |
| Read | getDocument<T>(id) | Returns undefined if missing (or hidden by ACL) — matches @sanity/client |
| Read | getDocuments<T>(ids) | Element-wise null for missing |
| Write | create(doc, options?) | Throws if _id already exists |
| Write | createOrReplace(doc, options?) | Upsert |
| Write | createIfNotExists(doc, options?) | No-op if _id exists |
| Write | delete(id, options?) | Default { _id } or null; id/array shapes via options |
| Mutate | mutate(operations, options?) | Raw mutation array ({create}/{patch}/{delete}/…), or a patch() / transaction() builder. Atomic |
| Patch | patch(id) chain → .commit() | Ops: .set, .setIfMissing, .unset, .inc, .dec, .insert(position, anchor, items), .ifRevisionId |
| Transaction | transaction() chain → .commit() | Mutations: .create, .createOrReplace, .createIfNotExists, .delete, .patch(id, recipe). Atomic — any throw rolls back the whole batch. Default result is a MultipleMutationResult ({ transactionId, documentIds, results }) |
| Actions | action(action \| action[]) | Content Lake release / version actions, applied atomically |
| Releases | releases.*, createVersion, unpublishVersion | Content Releases lifecycle — create / edit / schedule / publish / archive / delete, plus version create / replace / discard / unpublish |
| Config | config() | Returns { projectId, dataset, perspective } |
| Config | withConfig({ projectId?, dataset?, perspective?, accessControl? }) | Sibling handle into the same multi-dataset registry; accessControl rebinds the acting identity (a token-scoped sibling sharing the store) |
| Perspectives | raw / published / drafts / release stacks | Per-client default or per-fetch override; draft/version layering with _originalId projection |
| HTTP | request({ url? \| uri? }) | Present only when the client is created with accessControl. Serves /users/me + the configured ACL path. Calling it without accessControl throws a guidance error. |
Mutation return shapes
create / createOrReplace / createIfNotExists / delete / mutate / transaction().commit() honor returnDocuments / returnFirst, mirroring @sanity/client:
| returnDocuments | returnFirst | Resolves to |
| --- | --- | --- |
| (default for create-family) | | the mutated document |
| true | false | SanityDocument[] |
| false | true (or default) | SingleMutationResult { transactionId, documentId, results } |
| false | false (default for transaction / mutate) | MultipleMutationResult { transactionId, documentIds, results } |
[!NOTE] The static return type of
create/deletereflects the default shape (the document /{ _id } | null). When you passreturnDocuments: falseto get an id-shaped result, supply a type argument or cast — the runtime value is correct either way.
Gating writes — mutationGuard
createTestClient({ mutationGuard }) installs a pluggable write interceptor. It runs on every write through the document mutation API — create / createOrReplace / createIfNotExists / patch / delete and the equivalent transaction() / mutate() operations — before the store changes, and throws to reject the write (the throw propagates out of the client call, like a real lake rejection). The release/version action API (action, releases.*, createVersion) is not intercepted.
The client owns the seam; the caller owns the policy. This is the hook a workflow test bench uses to enforce Content Lake mutation guards at the write path without re-implementing guard evaluation. Each call receives the before/after image (before is null on create, after is null on delete), the action, and the acting identity.
const client = createTestClient({
documents: [{ _id: "article-1", _type: "article", body: "draft" }],
mutationGuard: ({ action, before, after }) => {
// Freeze `body` once an article is locked.
if (action === "update" && before?.locked && before.body !== after?.body) {
throw new Error("body is locked");
}
},
});
await client.patch("article-1").set({ title: "ok" }).commit(); // allowed
await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toThrow();Identity is the token
identity is the id bound to the client's token — its accessControl.currentUser. To act as a different user against the same store (e.g. to exercise identity()-based guards), branch a token-scoped sibling with withConfig({ accessControl }):
const alice = createTestClient({
documents: [article],
accessControl: { currentUser: { id: "alice" }, grants },
mutationGuard: onlyTheAssignedReviewerMayWrite,
});
const bob = alice.withConfig({ accessControl: { currentUser: { id: "bob" }, grants } });
// alice and bob share one store; each write carries its own identity().[!NOTE] Treat
before/afteras read-only — they are the live store images, not copies; mutating them corrupts the store.
Test-only helpers (not on real SanityClient)
Useful in test bodies, but a giveaway that the code under test isn't using the real client. Keep them in the test file, not in the code being tested.
| Method | Use |
| --- | --- |
| client.snapshot() | Read-only readonly SanityDocument[] of the current dataset |
| client.seed(documents) | Replace the current dataset's contents |
| client.add(documents) | Merge documents in without resetting |
| client.reset() | Drop everything in the current dataset |
| client.store() | Direct access to the underlying TestStore |
| client.datasets() | { projectId, dataset }[] known to the registry |
| client.mutations() | Every committed mutation, flattened and in order — assert exactly what writes your code submitted |
| client.transactions() | Every committed transaction (a direct mutation is a one-mutation transaction), each tagged with transactionId + (projectId, dataset) |
| client.clearLog() | Reset the mutation / transaction log |
| client.calls(method?) | Every recorded method call, with args + result — a framework-agnostic spy (like vi.fn().mock.calls) |
| client.callCount(method?) | Number of recorded calls, optionally filtered by method |
| client.clearCalls() | Reset the call log |
| client.stub(name, handler) | Register a handler for any unimplemented @sanity/client member (see below) |
| client.stubs() | The currently-registered stubs |
| createTestClient({ datasets: { "p:d": [...] } }) | Seed multiple (projectId, dataset) stores up front |
Two layers of observability
The fake records at two levels, so you can assert on either what your code did to the data or how it called the client:
- Write effects —
client.mutations()/client.transactions(): the create/patch/delete/mutate operations that actually landed (rolled-back anddryRunwrites excluded). - API calls —
client.calls()/client.callCount(): every invocation of a@sanity/client-surface method (fetch,getDocument,create,patch,mutate,releases.*, …) with itsargs, resolvedresult, or thrownerror. This is the spy layer — reach for it to assert read patterns (fetchcalled once, with these params) that the write log can't show.
const client = createTestClient({ documents: [{ _id: "a", _type: "article" }] });
await myLoader(client);
expect(client.callCount("fetch")).toBe(1);
expect(client.calls("fetch")[0]?.args).toEqual([`*[_type=="article"]`, {}]);
expect(client.calls("getDocument")[0]?.result).toMatchObject({ _id: "a" });[!NOTE] Builder-returning methods (
patch,transaction) record the call at builder-creation time (patch("a")→args: ["a"]); the chained ops/commit show up in the mutation log, not as extra calls. Test-only helpers (snapshot,mutations,calls, …) are never recorded.
Inspecting what your code wrote
const client = createTestClient();
await myFeature(client); // code under test issues some writes
expect(client.mutations()).toEqual([
{ kind: "create", doc: { _id: "draft.x", _type: "article" } },
{ kind: "patch", documentId: "draft.x", ops: [{ kind: "set", props: { status: "ready" } }] },
]);
// or assert on whole transactions:
expect(client.transactions()).toHaveLength(1);Not supported — but fails gracefully
The client is wrapped in a Proxy. Reaching for a member the fake doesn't implement doesn't give you a bare undefined is not a function — it throws an actionable error naming the member, what it is on the real client, and what to do instead:
[client-fake-for-test] `client.listen()` is not implemented (real-time listener …).
This is an in-memory fake of @sanity/client. It implements:
fetch, getDocument, getDocuments, create, createOrReplace, …
→ Test your subscription logic in isolation, or register a fake: client.stub("listen", () => myFakeObservable).
Register a stub to fill any gap: client.stub("listen", handler)
If this should be supported, open an issue: …Nested access reports the full path (client.assets.upload(...) → client.assets.upload). If you type the test variable as TestClient, TypeScript also catches the misuse at compile time.
| Area | Status | Workaround |
| --- | --- | --- |
| listen(...) — real-time | ❌ Guarded | Test subscription logic separately, or client.stub("listen", fn) |
| clone() | ❌ Guarded | withConfig({}) returns a sibling handle |
| getUrl(uri) / getDataUrl(...) — URL builders | ❌ Guarded | Hard-code in tests, or stub |
| request(...) — generic HTTP | ⚠️ Only with accessControl | Configure accessControl, stub it, or use MSW |
| agent.* / assets.* / projects.* / users.* / live.* / mediaLibrary.* | ❌ Guarded | Seed docs directly, or client.stub(name, fn) |
| observable.* — RxJS API | ❌ Guarded | Wrap the Promise API with from() if needed |
| Patch ops: .diffMatchPatch, .merge, .append, .prepend, .replace | ❌ Absent | .insert("after", "field[-1]", items) for append; .insert("before", "field[0]", items) for prepend |
Registering stubs
Fill any gap without forking — client.stub(name, handler) makes that member resolve to your handler (shared across withConfig siblings):
const client = createTestClient();
client.stub("listen", () => fakeObservable);
client.stub("assets", { upload: vi.fn() });Stubs fill gaps only — they don't shadow implemented methods like fetch or patch.
Design constraints
- Closure-scoped isolation.
createTestClient({ documents })returns a client whose store is local to that closure. Two clients in two tests do not see each other's writes. Pass the same client to two consumers if they need to share a store. - Structural compatibility, not nominal. Methods accept the same arg shapes as
@sanity/client, so any code typed againstSanityClient(or a narrow subset) can take aTestClientwithout an adapter. - groq-js directly. Same GROQ evaluator the real client supports server-side, against the in-memory document set. What groq-js supports, this supports. What it doesn't (some
&&short-circuit edge cases, custom functions), this doesn't either.
License
MIT
