Testing
The SDK ships an emulator under `grist-widget-sdk/emulator` that recreates the plugin API in-process. Used through `renderWithGrist(...)`, your hooks take their real code path inside vitest/Jest.
The SDK ships an emulator under grist-widget-sdk/emulator that recreates the plugin API in-process. Used through renderWithGrist(...), your hooks take their real code path inside vitest/Jest.
Quick start
import { describe, expect, it } from "vitest"
import { renderWithGrist, presets } from "grist-widget-sdk/emulator/testing"
import App from "./App"
describe("App", () => {
it("renders the selected row title", async () => {
const { emulator, findByText } = renderWithGrist(<App />, {
emulator: { document: presets.todoList() },
})
emulator.cursor.select("Tasks", 1)
expect(await findByText("Buy milk")).toBeInTheDocument()
})
})renderWithGrist:
- Reuses
@testing-library/react(render(ui, options)semantics). - Mounts the emulator behind
window.gristexactly once. - Cleans up the singletons on
unmount.
Configuration
renderWithGrist(<App />, {
// anything from RenderOptions (container, wrapper, etc.) is accepted
emulator: {
document: presets.contacts(), // or your own GristReplicaDocument
transport: { kind: "inline" }, // default
},
})You can also bring your own emulator instance:
const emulator = createGristEmulator({ document: presets.blank() })
renderWithGrist(<App />, { existing: emulator })Document fixtures
The emulator only accepts a complete GristReplicaDocument. Three preset shortcuts are available, or build your own:
import { presets } from "grist-widget-sdk/emulator/testing"
presets.blank() // one empty Table1
presets.todoList() // Tasks table with sample rows
presets.contacts() // Contacts table with multiple typesCustom fixtures live in your test repo:
import type { GristReplicaDocument } from "grist-widget-sdk"
export const fixture: GristReplicaDocument = {
generatedAt: "2026-01-01T00:00:00Z",
tables: {
Tasks: {
columns: { Title: { type: "Text" }, Done: { type: "Bool" } },
rows: [
{ id: 1, Title: "Buy milk", Done: false },
{ id: 2, Title: "Walk dog", Done: true },
],
},
},
}Validate fixtures with parseDocument (Zod-backed) if they come from disk:
import { parseDocument } from "grist-widget-sdk/emulator"
const doc = parseDocument(JSON.parse(json))Mutating data in tests
The emulator gives you imperative mutators:
emulator.mutate.addRow("Tasks", { Title: "Pay bills" })
emulator.mutate.updateRow("Tasks", 1, { Done: true })
emulator.mutate.removeRow("Tasks", 2)
emulator.cursor.select("Tasks", 1) // moves the cursor
emulator.cursor.selectNewRow() // sets mode: "new-row"
emulator.cursor.clear() // mode: "empty"
emulator.options.set({ theme: "dark" }) // widget options
emulator.theme.set("dark") // host themeAfter each call, React state updates automatically through the normal subscription path.
Custom matchers
import { actionsOf, eventsOf, waitForEvent } from "grist-widget-sdk/emulator/testing"
// All user actions Grist saw during the test
expect(actionsOf(emulator)).toContainEqual([
"UpdateRecord",
"Tasks",
1,
{ Done: true },
])
// Every event Grist emitted to the widget
expect(eventsOf(emulator)).toEqual([
{ kind: "ready" },
{ kind: "record", record: { id: 1, ... } },
...
])
// Wait for the *next* bus event of a kind (ignores history — start the waiter first)
const optionsEvent = waitForEvent(emulator, "options")
await userEvent.click(screen.getByText("Save"))
await optionsEventuseGristSchema snapshots
The SDK test suite snapshots useGristSchema() output for presets.blank(),
presets.todoList(), and presets.contacts() across schema-only,
schema+samples, and schema+data. Intentional replica changes require updating
snapshots: pnpm --filter grist-widget-sdk test -- -u use-grist-schema.snapshot.
Component-level testing patterns
Hooks in isolation
When the unit under test is a hook, render a probe component:
function Probe({ onResult }: { onResult: (w: ReturnType<typeof useGrist>) => void }) {
const w = useGrist()
React.useEffect(() => onResult(w), [w])
return null
}
renderWithGrist(<Probe onResult={spy} />, { emulator: {...} })Live mutations
emulator.mutate.updateRow("Tasks", 1, { Title: "Renamed" })
expect(await findByText("Renamed")).toBeInTheDocument()Action assertions
fireEvent.click(getByRole("button", { name: /save/i }))
await waitFor(() => {
expect(actionsOf(emulator)).toContainEqual(["UpdateRecord", "Tasks", 1, expect.any(Object)])
})Where the emulator runs
| Use case | Transport | Mount |
|---|---|---|
| Unit tests (vitest jsdom) | inline | renderWithGrist |
| Storybook / IDE preview | inline | createGristEmulator({ transport: { kind: "inline" } }) |
| Production-fidelity demo | iframe | createGristEmulator({ transport: { kind: "iframe", iframe } }) |
See /api/emulator for the lower-level API.
Performance
The SDK is designed so a small widget can use `useGrist()` everywhere without thinking, and a large widget can opt into slice hooks to keep render cost flat.
Cheat sheet
One page for daily reference once you understand the mental model. Every TypeScript example below is the full `WidgetApp.tsx` from a playground widget — type-checked by `pnpm --filter playground bu...