Skip to content
Guides— browse docs
On this page

Testing

createFakeHost() is a complete host made of inert fakes — it is what runs every example on this site, and it is what your tests run against.

Here's a fact about this page that's more useful than any argument I could make for testing:

Every live example on this site is running against createFakeHost(). There is no server behind them, no session, no database. The bestiary tool you typed into two pages ago is the real tool, mounted against a host made entirely of fakes — and it collaborated, merged, and showed presence anyway.

That is the same harness your tests use. Not a mock of it. The same one.

You will learn

  • How to render your tool in a test with createFakeHost()
  • How to override one namespace to exercise a specific path
  • How to test a codec's merge behaviour directly

Why this works at all

Because the host is a contract, not a global. Your tool never imports the platform — it consumes whatever host it's given. So a test harness isn't a special mode; it's another host.

createFakeHost() is a complete HostServices of inert fakes: no Hocuspocus, no Supabase, no network, no app code. Documents open against a local in-memory runtime that has real CRDT semantics — which is why two panes actually converge on this site rather than pretending to.

Here's the fake host describing itself. Every number below is read from the same object your tests are handed — and the assertions underneath are the ones a test would make, evaluated live as you edit the document:

A tool grading itself against its host
src/tool.tsx

Edit and the example re-runs. Tab indents; press Escape to leave the editor.

Running · your edits, live
Starting the example…

Empty the name, or remove every trait, and watch a check flip. That's the whole idea: a test is this, without the pixels.

The shape of a test

src/bestiary.test.tsx
import { renderToStaticMarkup } from "react-dom/server"
import { HostProvider, createFakeHost } from "@vvd/sdk"
import { expect, test } from "vitest"

import { codec } from "@/codec"
import tool from "@/tool"

const scope = { type: "world", worldId: "w1" } as const
const doc = {
  id: "d1",
  worldId: "w1",
  type: "bestiary",
  title: "Ashfall Drake",
  scope,
  visibility: "shown" as const,
}

test("renders the creature's name", () => {
  const View = tool.render
  const html = renderToStaticMarkup(
    <HostProvider services={createFakeHost()}>
      <View document={doc} context={{ scope, canEdit: true }} />
    </HostProvider>,
  )
  expect(html).toContain("Ashfall Drake")
})

Three things to notice: you render your own component, you pass a host, and you assert on output. There's no platform to boot.

Override one namespace to exercise a path

createFakeHost takes overrides, and each one replaces that namespace. This is how you test the branches that are hard to reach by hand.

src/bestiary.test.tsx
// Read-only: does your UI actually disable its controls?
createFakeHost({ identity: { me: null, canEdit: () => false } })
src/bestiary.test.tsx
// A world with exactly the rows your query cares about. Build the arrays ONCE,
// outside the functions — see the warning below.
const CARDS = [
  { id: "c1", name: "Aria of the North", slug: "aria", documentType: "card", updatedAt: null },
]
const NONE: never[] = []
const rows = <T,>(items: readonly T[]) => ({ get: () => items, subscribe: () => () => {} })

createFakeHost({
  world: {
    documents: () => rows(CARDS),
    eras: () => rows(NONE),
    projects: () => rows(NONE),
    media: () => rows(NONE),
  },
})

An override replaces the namespace rather than merging into it, so give world all four of its collections — a half-filled namespace throws the moment something reads the missing one.

Warning:

The rows a documents() collection returns must be referentially stable — return the same array object each time, not a fresh literal. The read goes through useSyncExternalStore, which compares snapshots by identity, so a new array on every call makes React re-render forever and eventually throw "Maximum update depth exceeded".

Build the array once, outside the function.

There are recording fakes for the command-shaped capabilities, so you can assert on what your tool asked the host to do:

src/bestiary.test.tsx
import { createFakeHost, createRecordingNav } from "@vvd/sdk"

const { nav, commands } = createRecordingNav()
const services = createFakeHost({ nav })

// …render under that host, then click the "open the linked card" button…

expect(commands).toContainEqual(
  expect.objectContaining({ type: "open" }),
)

createRecordingAnalytics, createRecordingMenus, createRecordingDnd, createRecordingIcons, createRecordingSearch and friends all follow the same pattern.

Test the codec on its own

Merge behaviour is the thing most worth pinning, and you can test it without React at all — two documents, edits applied to each, then merged.

src/codec.test.ts
import { Y } from "@vvd/sdk"
import { expect, test } from "vitest"

import { codec } from "@/codec"

/** Exchange full state both ways — after this, a and b must be identical. */
function sync(a: Y.Doc, b: Y.Doc) {
  const ua = Y.encodeStateAsUpdate(a)
  const ub = Y.encodeStateAsUpdate(b)
  Y.applyUpdate(a, ub)
  Y.applyUpdate(b, ua)
}

test("concurrent trait inserts both land", () => {
  const a = new Y.Doc()
  const b = new Y.Doc()

  codec.actions!(a).list("traits").push("Amphibious")
  codec.actions!(b).list("traits").push("Ambush hunter")
  sync(a, b)

  const traits = codec.read(a).traits
  expect(traits).toEqual(codec.read(b).traits)
  expect([...traits].sort()).toEqual(["Ambush hunter", "Amphibious"])
})

test("the same scalar converges on one winner", () => {
  const a = new Y.Doc()
  const b = new Y.Doc()

  codec.actions!(a).set("name", "From A")
  codec.actions!(b).set("name", "From B")
  sync(a, b)

  expect(codec.read(a).name).toBe(codec.read(b).name)
})

actions is codec.actions! because the codec contract declares it optional — defineStateCodec always provides it.

If a field's merge behaviour matters to your product — and it usually does — this is the test that will still be catching regressions in a year.

Assert that a missing grant fails loud

Your tool declares needs. The platform enforces it by omitting the namespace. You can prove your tool fails at the boundary instead of silently degrading:

src/bestiary.test.tsx
import {
  HostProvider,
  createFakeHost,
  grantsToHostCapabilities,
  restrictHost,
} from "@vvd/sdk"

// Exactly the host an install with NO grants gets: baseline capabilities, no `world`.
const ungranted = restrictHost(createFakeHost(), grantsToHostCapabilities([]))

expect(() =>
  renderToStaticMarkup(
    <HostProvider services={ungranted}>
      <View document={doc} context={{ scope, canEdit: true }} />
    </HostProvider>,
  ),
).toThrow(/Host capability "world"/)

Setting up a runner

A fresh vvd project deliberately has no package.json, so there's no test runner in it either. Adding one is ordinary npm work plus one alias, because @vvd/sdk isn't installed from the registry — the CLI keeps its sources under ~/.vvd.

npm init -y && npm install -D vitest jsdom @vitejs/plugin-react
Expected output:
added 214 packages in 6s
vitest.config.ts
import os from "node:os"
import path from "node:path"

import react from "@vitejs/plugin-react"
import { defineConfig } from "vitest/config"

// Where the CLI installed the SDK sources. `vvd doctor --fix-types` points your
// tsconfig at the same place, so the two never disagree.
const sdk = path.join(os.homedir(), ".vvd", "sdk", "packages")

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@vvd/sdk": path.join(sdk, "sdk", "src", "index.ts"),
      "@": path.resolve("src"),
    },
  },
  test: { environment: "jsdom" },
})

Adding a package.json doesn't change how your creation ships: the CLI still externalises React, the SDK and the CRDT library, and only bundles dependencies you actually import from src/. Dev dependencies never reach the artifact.

Tests are not the finish line

Be honest with yourself about what a fake host can't tell you. It has no real server, so it can't prove your permissions are right, your document type is registered, your capability grant is what you think it is, or that your tool looks correct inside the world's actual chrome.

vvd run is the verification surface. Nothing is done until it works on the live tab. Tests are how you keep it working afterwards.

When it doesn't work

useHost() must be used within a <HostProvider>. You rendered the component bare. Wrap it in <HostProvider services={createFakeHost()}>.

"Maximum update depth exceeded" in a world-reading test. Your fake documents() returns a fresh array on every call. Hoist it — see the warning above.

Your document renders empty in a test. The document opens asynchronously even locally. Either assert after a flush, or test the codec directly (which is synchronous, and usually the better test anyway).

Cannot find module '@vvd/sdk' from the test runner. The alias above is missing, or the SDK sources aren't on this machine yet — they're fetched on your first vvd create or vvd run. vvd doctor will tell you.

Recap

  • createFakeHost() is a complete host of inert fakes — the same one running every example on this site.
  • Rendering a tool in a test is rendering it under a different host; there is no special test mode.
  • The recording fakes let you assert what your tool asked the host for, not only what it drew.
  • Codec logic is pure, so most of what you want to test needs no DOM at all.

Next steps

  • Ship it — save, share, publish, and who sees what.