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: app-internal hooks, routes, and services stay real, always.

The one sanctioned carve-out is the external integration boundary: mock exactly the paid or user-OAuth integration call when a real call would charge money, mutate provider state, or require credits or credentials the test run doesn't have. Nothing inside your app qualifies - if a piece of your own code is hard to exercise, that's a design problem to fix, not a seam to mock.

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 run

# All Playwright specs
npx deepspace test run e2e

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

# Vitest unit tests
npx deepspace test run unit

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

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

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()
})
ts

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'])
ts

Provisioning test accounts#

The fixture reads from ~/.deepspace/test-accounts.json - a local credential store written mode 0600. Credentials live there and nowhere else: don't copy passwords into specs, fixtures, or committed files. Populate it via the CLI:

# Check what you already have
npx deepspace test accounts list

# ...only the ones the users() fixture can actually sign in (saved credentials)
npx deepspace test accounts list --usable

# 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"
bash

Each account prints a Selector: - the string you pass to users(['Selector']). It is the --name you gave, or the email's local part when you omit --name, so every created account is selectable. list shows a usableByFixture flag (in --json) and masks saved passwords in human output; pass --reveal to print them - in --json too, where the password field is omitted entirely without the flag - or --usable to list only accounts whose credentials are saved locally - the ones users() can actually drive. An account visible remotely but without saved credentials on this machine is not usable by the fixture, which is exactly what --usable filters for.

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 pool is shared across every app on the machine, so treat it as shared infrastructure: create only the shortfall a run actually needs, and delete only accounts created for the current run. Never clear the pool wholesale - other apps' suites depend on the accounts already in it.

Creating them requires an OAuth-authenticated developer session - a test account cannot create more test accounts.

What test accounts cannot do#

Test accounts sign in with an email and a password - exactly what a Playwright fixture needs, and exactly what real DeepSpace accounts don't have. Those sign in through browser OAuth only.

The trade is that a test account is a fixture, not a customer. On the test billing tier it:

  • cannot deploy apps - the platform refuses with test_account_cannot_deploy, and the account's app quota is 0
  • has zero storage quota - no Git packs, rollback bundles, or deploy assets
  • cannot be a collaborator, in either direction
  • cannot receive an app transfer, or request credits

Use them to drive multi-user auth flows in your app. Everything that touches the platform itself stays on your own account.

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 */ }
    }
  }
})
ts

Auth-state assertions#

Every route lives in one of three tiers, and each tier has its own contract to assert:

Route tierSmoke assertion
Static (src/pages/<name>.tsx)Signed-out visitor sees real content with no providers mounted - no auth session fetch, no realtime WebSocket, no [data-testid="auth-overlay"]. The top-level landing page must preserve this static contract.
Dynamic (src/pages/(app)/<name>.tsx)Signed-out visitor sees real, dynamic content; [data-testid="auth-overlay"] count is 0. Providers are mounted, but the page is not gated.
Gated (src/pages/(app)/(protected)/<name>.tsx)Signed-out: overlay visible and protected content not in DOM. Signed-in: content visible, no overlay.
After sign-out from gatedURL navigates to redirectOnSignOut (default /). Overlay does not appear - a stranded overlay is a bug.

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?')
})
ts

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')
ts

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')
ts

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 test screenshot http://localhost:5173/ out.png
npx deepspace test screenshot http://localhost:5173/dashboard out.png --full-page
npx deepspace test screenshot http://localhost:5173/ mobile.png --viewport 390x844
npx deepspace test screenshot http://localhost:5173/ out.png --wait-for-timeout 500
bash

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#

  • 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#