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.
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.