コンテンツにスキップ

Testing

plasma のテストスイートは完全に Node 上で動作します — ブラウザなし、Cloudflare エッジなし。あなたのアプリのテストも同じことができます。このガイドでは 2 つのハーネスを解説します。

Terminal window
pnpm add -D fake-indexeddb vitest

テストファイルの先頭で一度 import します:

import "fake-indexeddb/auto"

それだけです — plasma のクライアントエンジンは polyfill された indexedDBIDBKeyRangeIDBOpenDBRequest などを透過的に使います。

import "fake-indexeddb/auto"
import { createDb, defineSchema, eq, id, int, table, text } from "@sh1n4ps/plasma-core"
import { createIdbEngine } from "@sh1n4ps/plasma-client"
import { describe, expect, it } from "vitest"
const todos = table("todos", { id: id(), title: text(), done: int().default(0) })
const schema = defineSchema({ todos })
async function makeDb() {
const engine = await createIdbEngine({
schema,
dbName: `test-${Math.random().toString(36).slice(2, 8)}`,
})
return createDb({ schema, engine })
}
describe("markDone mutator", () => {
it("flips the done flag", async () => {
const db = await makeDb()
await db.insert(todos).values({ id: "t1", title: "hi" })
await db.update(todos).set({ done: 1 }).where(eq(todos.id, "t1"))
const rows = await db.select().from(todos)
expect(rows[0]?.done).toBe(1)
})
})

createIdbEngine はスタンドアロンのエンジンコンストラクタです — PlasmaClient の足場なし、sync ループなし。アプリが使うのと同じクエリビルダーに対して mutator ロジックを単体テストするのに最適です。

IVM のセットアップは非同期です — subscribe(cb) は初回コールバックが発火する前に返ります。whenReady() を使ってください:

it("emits the initial snapshot", async () => {
const db = await makeDb()
await db.insert(todos).values({ id: "t1", title: "hi" })
const live = db.select().from(todos).live()
const seen: number[] = []
live.subscribe((rows) => seen.push(rows.length))
await live.whenReady?.()
expect(seen).toEqual([1])
})

whenReady() なしでは、setTimeout(0) の待機が忙しい CI マシン上で IDB のシード読み取りと競合します。

サーバー側 — Miniflare + @cloudflare/vitest-pool-workers

Section titled “サーバー側 — Miniflare + @cloudflare/vitest-pool-workers”

Worker 側のテストには D1 エミュレータと workerd ランタイムが必要です。Cloudflare は両方を提供する @cloudflare/vitest-pool-workers を提供しています。

Terminal window
pnpm add -D @cloudflare/vitest-pool-workers wrangler

vitest.config.ts:

import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.jsonc" },
},
},
projects: [
{
test: {
name: "server",
include: ["packages/server/test-workerd/**/*.test.ts"],
poolOptions: {
workers: {
main: "./packages/server/test-workerd/entry.ts",
miniflare: {
d1Databases: { DB: "test-db" },
r2Buckets: { BUCKET: "test-bucket" },
},
},
},
},
},
],
},
})

wrangler.jsonc はテスト worker が使う D1 と R2 のバインディングを宣言します — Miniflare がそれらをインプロセスでエミュレートします。

プロダクションで使うのと同じ ensureSchema を使います:

import { env } from "cloudflare:test"
import { ensureSchema, fromD1 } from "@sh1n4ps/plasma-server"
beforeEach(async () => {
const executor = fromD1(env.DB)
await ensureSchema({ schema, executor })
})

env.DB は Miniflare の D1 エミュレータ、env.BUCKET は Miniflare の R2 エミュレータです。テスト worker のブートごとに空で始まります; 必要な table をシードしてください。

実際の Request オブジェクトで sync ハンドラを駆動できます:

import { createSyncHandler } from "@sh1n4ps/plasma-server"
it("push then pull round-trips a mutation", async () => {
const executor = fromD1(env.DB)
await ensureSchema({ schema, executor })
const handler = createSyncHandler({
schema, mutators, executor,
schemaVersion: "v1",
auth: async () => ({ ok: true, clientGroupID: "g", clientID: "c", ctx: {} }),
})
const pushRes = await handler(new Request("https://plasma.test/sync/push", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
protocolVersion: 1,
schemaVersion: "v1",
clientGroupID: "g",
clientID: "c",
mutations: [{ id: 1, name: "createTodo", args: { id: "t1", title: "hi", updatedAt: 0 } }],
}),
}))
expect(pushRes.status).toBe(200)
const pullRes = await handler(new Request("https://plasma.test/sync/pull?protocolVersion=1&schemaVersion=v1&clientGroupID=g&clientID=c"))
const body = await pullRes.json()
expect(body.patch).toHaveLength(1)
})

これは auth、schema mismatch、envelope 検証、CRDT マージを含め、プロダクションが使うのと同じ sync-handler.ts のコードパスに対して実行されます。

PlasmaProvider を使った React コンポーネントのテスト

Section titled “PlasmaProvider を使った React コンポーネントのテスト”

useLiveQuery / useMutation を使うコンポーネントテストには、PlasmaProvider でラップし、fake IDB でクライアントを構築します:

import { PlasmaProvider } from "@sh1n4ps/plasma-react"
import { createPlasmaClient } from "@sh1n4ps/plasma-client"
import { render } from "@testing-library/react"
const client = createPlasmaClient({
schema, mutators,
dbName: `test-${Math.random()}`,
endpoint: "https://plasma.test/sync",
clientGroupID: "test",
schemaVersion: "v1",
getContext: async () => ({ userId: "test" }),
fetcher: async () => new Response(JSON.stringify({
cookie: null,
patch: [],
lastMutationIDs: {},
hasMore: false,
})), // すべての push/pull をスタブ化
pollIntervalMs: 60_000,
retry: { maxAttempts: 1 },
})
render(
<PlasmaProvider client={client}>
<TodoList />
</PlasmaProvider>,
)

fetcher スタブにより、実行中の Worker なしでクライアントをテストできます。push/pull の挙動をテストする必要があれば、レスポンスを手作りしてください。

決定的なタイミングのパターン

Section titled “決定的なタイミングのパターン”

2 つのパターンが頻繁に登場します:

  1. await live.whenReady?.() — 初回スナップショット配信をアサートする前。
  2. await new Promise(r => setTimeout(r, 0)) — mutation をトリガーした後、リアクティブハブの同期的な emit が伝播し終わるのを待つため。

どちらのパターンもマジックナンバーを必要としません。whenReady は本物の Promise を待ち、emit にはゼロティックのマイクロタスクで十分です。