Skip to main content
Documentation

Testing

Playwright specs, the multi-user fixture, and how to test against real services.

On this page

Every scaffolded app ships with Playwright tests in tests/. The CLI's test command bootstraps Playwright (downloads Chromium on first run), regenerates dev secrets, and runs the suite against the dev workers. Tests use real services - no mocking of internal hooks.

Three spec files#

FileCovers
smoke.spec.tsApp boots, navigation renders, page titles, auth UI present
api.spec.tsAPI routes return expected shapes; auth gating; integration calls
collab.spec.tsMulti-user real-time sync - two users connect and see each other

Installing a feature (docs, kanban, messaging, …) does not add a new spec file. Extend these three.

Running tests#

# Default - smoke + api
npx deepspace test

# All Playwright specs
npx deepspace test e2e

# Subset
npx deepspace test smoke
npx deepspace test api
npx deepspace test tests/checkout.spec.ts

# Vitest unit tests
npx deepspace test unit

# Match a parallel dev server port
npx deepspace test --port 5180

# Plain Playwright (skips .dev.vars regen - useful for iterating)
npx playwright test
npx playwright test --ui

No separate dev server is required - the scaffolded tests/playwright.config.ts starts Vite if it's not already running and reuses it if it is.

Multi-user testing - the users fixture#

The SDK ships a Playwright fixture from 'deepspace/testing' that returns N signed-in browser contexts:

import { test, expect } from 'deepspace/testing'

test('A sends, B sees', async ({ users }) => {
  const [alice, bob] = await users(2)
  await alice.page.goto('/chat')
  await bob.page.goto('/chat')
  await alice.page.getByTestId('send-btn').click()
  await expect(bob.page.getByText('hi')).toBeVisible()
})

Each MultiplayerUser is { context, page, email, name, userId? }. Contexts auto-close when the test finishes.

The fixture caches storageState per account, so each test account signs in once per machine - not once per test. This sidesteps Better Auth's per-IP rate limit on /api/auth/sign-in/email and is materially faster as the suite grows.

Pick specific accounts by name:

const [alice, bob] = await users(['Alice', 'Bob'])

Provisioning test accounts#

The fixture reads from ~/.deepspace/test-accounts.json, populated via the CLI:

# Check what you already have
npx deepspace test-accounts list

# Create new accounts as needed (max 10 total per machine)
npx deepspace test-accounts create --email alice-1@deepspace.test --password Pass123! --name "Alice"
npx deepspace test-accounts create --email bob-1@deepspace.test --password Pass123! --name "Bob"

The account pool is global per developer and shared across apps. Emails must end @deepspace.test. Don't bake the app name into the email - the same accounts work for every app.

The test extension checklist#

Run tests only after a runtime-affecting code change (src/, worker.ts, etc.). Skip them for conversation, planning, or pure documentation edits.

TriggerRequired test
Added a schemasmoke.spec.ts - CRUD happy path for a signed-in user
Added/edited a route, page, nav item, or top-level UIsmoke.spec.ts - page-load with real-content assertion
Schema with visibilityField or 'public'/'shared'/'team'/'own' permissionscollab.spec.ts - two-user assertion (A acts, B sees)
Used useYjs* / useMessages / useReactions / usePresence / useCanvascollab.spec.ts - two-user assertion
Added/edited worker route, server action, AI chat, cron, integration call, or auth-gated UIapi.spec.ts - status codes + shape + auth gating
Fixing a bugWrite a failing test first, then fix. Leave the test in place.

For integration calls specifically, POST to /api/integrations/<endpoint> and assert success: true with the data shape your UI consumes. This catches wrong endpoint names - the most common integration-heavy-app failure.

Test data cleanup#

Tests run against the same local Durable Object the dev server uses, so anything you create persists. Two conventions to keep the dev DB clean:

Prefix test records

Every record a test creates should start with __test-${Date.now()}__ in its human-visible field (title, name, question).

Clean up in afterEach / afterAll

Track created recordIds and delete them after the test. Don't add a blanket "wipe the DB" step - it would destroy real dev data.

test('user A posts a message', async ({ users }) => {
  const [alice] = await users(1)
  const created: string[] = []
  try {
    const title = `__test-${Date.now()}__ Hello`
    // ... create, capture recordId ...
  } finally {
    for (const id of created.reverse()) {
      try { /* delete via your endpoint */ } catch { /* swallow */ }
    }
  }
})

Auth-state assertions#

The scaffold ships the mixed auth config. Every route falls into one of three buckets:

RouteSmoke assertion
Public (src/pages/<name>.tsx)Signed-out visitor sees real content; [data-testid="auth-overlay"] count is 0.
Gated (src/pages/(protected)/<name>.tsx)Signed-out: overlay visible and content not in DOM. Signed-in: content visible, no overlay.
After sign-out from gatedURL navigates to redirectOnSignOut (default /). Overlay does not appear.

The [data-testid="auth-overlay"] attribute is on the SDK's <AuthOverlay/> - more reliable than text matching.

Route coverage#

Every reachable route must have a test that:

  1. Navigates to it (for dynamic routes, create a record first and use its ID)
  2. Waits for real content to appear (a specific element with real data - not just "no crash")
  3. Fails loudly on empty/not-found states when there shouldn't be one
test('/polls/:id renders the question', async ({ page }) => {
  const id = await createTestPoll('Favorite color?')
  await page.goto(`/polls/${id}`)
  await expect(page.getByTestId('poll-question')).toContainText('Favorite color?')
})

A "page loads without JS errors" assertion is not sufficient. Assert that the data that should be there is there.

Testing canWrite-gated UI#

Surfaces backed by useYjsRoom, useYjsText, useYjsField, useCanvas, useGameRoom, useCronMonitor, and useJobs all expose a canWrite boolean that defaults to false until the server's AUTH frame arrives. Two patterns matter for tests:

Don't use getByRole('textbox') on ProseMirror / Tiptap editors. A page that also renders a title <input> has multiple textbox-role nodes and the locator is ambiguous. Target the editable surface directly with a stable data-testid:

const editor = page.locator('[data-testid="editor-content"] .ProseMirror')

Don't use expect(locator).toBeEditable(). Playwright's actionability poll runs busy enough to starve the WebSocket onmessage callback, so the AUTH frame never lands and contenteditable stays "false". Poll the attribute passively instead:

// Writer (member / owner) - wait for canWrite to flip true
await expect.poll(
  () => editor.getAttribute('contenteditable'),
  { timeout: 30_000, intervals: [500] },
).toBe('true')

// Viewer - assert it stays read-only
await expect.poll(
  () => editor.getAttribute('contenteditable'),
  { timeout: 30_000, intervals: [500] },
).toBe('false')

The same race applies to any canWrite-gated UI - if a test wants to assert the writer can act before clicking, poll a DOM signal (a disabled attribute, aria-readonly, data-can-write="true") rather than relying on actionability checks.

Self-diagnosis with tests#

When something isn't working, don't start with console logs. Start with:

Write or tighten a test that expresses the expected behavior

Describe the assertion you'd run if the feature worked.

Run it

Read the failure message and the failing selector or assertion.

Fix the code until the test passes

The test tells you what was expected and what was observed.

Leave the test in place

It now guards against regression.

A failing test tells you more than a log ever will: what was expected, what was observed, where in the flow it diverged.

Screenshots for visual debugging#

npx deepspace screenshot http://localhost:5173/ out.png
npx deepspace screenshot http://localhost:5173/dashboard out.png --full-page
npx deepspace screenshot http://localhost:5173/ out.png --wait-for-timeout 500

Shares the same Chromium install as test. Use it for "what does this page actually render right now" workflows - not as a substitute for Playwright assertions.

Tips#

  • All tests use real services. No mocking of internal hooks. The whole point is exercising the real auth, storage, and integration pipelines end-to-end.
  • Re-run after every follow-up change. Apply the extension checklist each turn - tests are a living contract.
  • Don't weaken tests to make them green. Write a more specific assertion, or fix the underlying behavior.
  • Avoid console.log-driven debugging. A tighter assertion gives better signal than a log ever will.

Next steps#