Skip to content

Latest commit

 

History

History
692 lines (536 loc) · 24.5 KB

File metadata and controls

692 lines (536 loc) · 24.5 KB
title Testing with Vitest
description Unit test spiceflow routes, pages, actions, and middleware with vitest.
icon test-tube

Testing with Vitest

Test your spiceflow app directly with vitest. Call app.handle() on page and API routes, call server actions as plain functions, and assert on responses. No browser, no build, sub-second feedback.

Setup

Add the spiceflow vite plugin to your vitest config. The plugin auto-detects vitest and configures everything.

// vite.config.ts
import { defineConfig } from 'vite'
import spiceflow from 'spiceflow/vite'

export default defineConfig({
  plugins: [spiceflow({ entry: './src/main.tsx' })],
})

No extra resolve.conditions or environment config needed.

Testing API Routes

Use createSpiceflowFetch(app) for type-safe API testing. It calls app.handle() internally and parses responses.

import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

test('GET /api/projects returns json', async () => {
  const result = await f('/api/projects')
  expect(result).toMatchInlineSnapshot(`
    {
      "projects": [],
    }
  `)
})

test('GET /api/projects/:id with params', async () => {
  const result = await f('/api/projects/:id', { params: { id: '42' } })
  expect(result).toMatchInlineSnapshot(`
    {
      "id": "42",
      "name": "My Project",
    }
  `)
})

Paths and params are fully typed. Invalid paths or missing required params are compile errors.

Testing Page Routes

Page routes also work with createSpiceflowFetch. They return a SpiceflowTestResponse with the rendered JSX.

import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

test('GET /dashboard renders page with layout', async () => {
  const res = await f('/dashboard')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(res.status).toBe(200)

  // Full HTML with layouts
  expect(await res.text()).toContain('Dashboard')

  // Page-only HTML (no layout wrapper)
  expect(await res.text(res.page)).toContain('<h1>Dashboard</h1>')

  // Loader data
  expect(res.loaderData).toEqual({ projects: [] })
})

test('page with params', async () => {
  const res = await f('/users/:id', { params: { id: '42' } })
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(await res.text()).toContain('User 42')
})

res.text() renders the full composed page (layouts wrapping the page) to HTML. Client components that use useLoaderData get the correct data.

res.text(node) renders a specific node. Pass res.page for the page only, or res.layouts[0].element for a single layout.

res.page gives raw JSX for vitest inline snapshots without rendering.

Testing with Authentication

Declare two fetch clients at the top of your test file. One unauthenticated, one with a Bearer token.

import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app } from './main.js'

const f = createSpiceflowFetch(app)
const authed = createSpiceflowFetch(app, {
  headers: { authorization: 'Bearer test-token' },
})

test('unauthenticated request returns error', async () => {
  const result = await f('/admin')
  expect(result).toBeInstanceOf(Error)
})

test('authenticated request renders admin page', async () => {
  const res = await authed('/admin')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(await res.text()).toContain('Admin Panel')
})

test('authenticated API call returns user data', async () => {
  const result = await authed('/api/me')
  expect(result).toMatchInlineSnapshot(`
    {
      "user": "tommy",
      "token": "test-token",
    }
  `)
})

test('unauthenticated API call returns error', async () => {
  const result = await f('/api/me')
  expect(result).toBeInstanceOf(Error)
})

Middleware runs before page handlers, just like in production.

Server Actions

Server actions in "use server" files become plain functions in vitest (the directive is stripped automatically). Call them directly.

import { test, expect } from 'vitest'
import { runAction } from 'spiceflow/testing'
import { createProject, deleteProject } from './actions.js'

test('createProject returns the new project', async () => {
  const project = await createProject('My App')
  expect(project).toMatchInlineSnapshot(`
    {
      "id": "1",
      "name": "My App",
    }
  `)
})

Actions that call getActionRequest() need the runAction wrapper to provide request context.

test('action reads request headers', async () => {
  const result = await runAction(() => headerAwareAction(), {
    request: new Request('http://localhost', {
      method: 'POST',
      headers: { authorization: 'Bearer admin-token' },
    }),
  })
  expect(result.auth).toBe('Bearer admin-token')
})

Actions that call redirect() throw a Response. Use .catch() to capture it.

test('action redirects after mutation', async () => {
  const error = await runAction(() => submitForm()).catch((e) => e)
  if (!(error instanceof Response)) throw new Error('expected redirect')
  expect(error.status).toBe(307)
  expect(error.headers.get('location')).toBe('/dashboard')
})

Stateful Workflows

Test full user workflows: render a page, call an action, render again, verify the state changed.

import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app, projectStore } from './main.js'
import { createProject } from './actions.js'

const f = createSpiceflowFetch(app)

test('dashboard shows projects after creation', async () => {
  projectStore.length = 0

  // Empty state
  const empty = await f('/dashboard')
  if (!(empty instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(await empty.text()).toContain('No projects yet')

  // Create a project via action
  await createProject('My New App')

  // Page now shows the project
  const filled = await f('/dashboard')
  if (!(filled instanceof SpiceflowTestResponse)) throw new Error('expected page')
  const html = await filled.text()
  expect(html).toContain('My New App')
  expect(html).not.toContain('No projects yet')
})

Client components using useLoaderData get correct data because the loader runs on each request and the result is provided to the rendering context.

Dependency Injection with State

Use .state() to register dependencies your handlers need (database clients, KV stores, auth services). In tests, pass overrides via createSpiceflowFetch(app, { state }) to swap real services for test doubles.

// main.tsx
import { Spiceflow } from 'spiceflow'

// Define the app with typed state for a KV store and auth
export const app = new Spiceflow()
  .state('kv', productionKV as KVStore)
  .state('user', null as User | null)
  .get('/api/projects', async ({ state }) => {
    if (!state.user) return new Response('Unauthorized', { status: 401 })
    const projects = await state.kv.get(`projects:${state.user.id}`)
    return { projects: projects ?? [] }
  })
  .get('/api/projects/:id', async ({ state, params }) => {
    if (!state.user) return new Response('Unauthorized', { status: 401 })
    const project = await state.kv.get(`project:${params.id}`)
    if (!project) return new Response('Not Found', { status: 404 })
    return project
  })
  .post('/api/projects', async ({ state, request }) => {
    if (!state.user) return new Response('Unauthorized', { status: 401 })
    const body = await request.json()
    await state.kv.put(`project:${body.id}`, body)
    return body
  })

interface KVStore {
  get(key: string): Promise<any>
  put(key: string, value: any): Promise<void>
}

interface User {
  id: string
  name: string
}

In tests, create a fake KV store and pass it as state. No mocking modules, no patching globals. The typed state ensures your test doubles match the expected interface.

// main.test.ts
import { test, expect } from 'vitest'
import { createSpiceflowFetch } from 'spiceflow/client'
import { app } from './main.js'

// In-memory KV for tests
const store = new Map<string, any>()
const fakeKV = {
  async get(key: string) { return store.get(key) },
  async put(key: string, value: any) { store.set(key, value) },
}

const testUser = { id: 'u1', name: 'Tommy' }

// Typed fetch with test state injected
const f = createSpiceflowFetch(app, {
  state: { kv: fakeKV, user: testUser },
})

// Unauthenticated client (user: null)
const anon = createSpiceflowFetch(app, {
  state: { kv: fakeKV, user: null },
})

test('authenticated user can create and list projects', async () => {
  store.clear()

  // Create a project
  await f('/api/projects', {
    method: 'POST',
    body: { id: 'p1', name: 'My App' },
  })

  // Verify KV was written
  expect(store.get('project:p1')).toEqual({ id: 'p1', name: 'My App' })
})

test('unauthenticated user gets 401', async () => {
  const result = await anon('/api/projects')
  expect(result).toBeInstanceOf(Error)
})

When to use state for DI:

  • External services (KV, database, email, storage) you want to replace with in-memory fakes in tests
  • Auth context (current user, session) so you don't need to set up real auth flows
  • Feature flags or config that varies between tests

State is deep-cloned per request by default, so each app.handle() call gets its own copy. Overrides via createSpiceflowFetch or app.handle(req, { state }) replace the defaults for that call only.

Testing with better-auth

When your app uses better-auth for authentication, set the database path via an environment variable so tests run against an in-memory SQLite database (AUTH_DB=:memory: in vitest config). The bearer() plugin enables Authorization: Bearer <token> headers, and emailAndPassword lets tests create real users via auth.api.signUpEmail. Drizzle migrations run in a setup file before tests start.

The example-better-auth test file is a complete working example. It covers:

  • describe('public routes') — pages that render without auth
  • describe('protected routes') — sign up a user in beforeAll, set .headers on the fetch client, verify pages and APIs
  • describe('unauthenticated access') — verify middleware blocks access and redirects to login
  • describe('multiple users') — create two users with separate fetch clients, verify data isolation
  • describe('server actions with auth') — call actions with runAction + authed request, verify mutations and redirects
  • describe('Org workflow') — full multi-step journey: create user → create org → redirect to dashboard → verify empty state → create project → verify dashboard shows it → delete project → verify empty again. Also tests cross-user access denial.

Key techniques:

  • createSpiceflowFetch(app, { headers }) with a Bearer token to simulate an authenticated user
  • Mutable .headers field set in beforeAll after user creation for describe-scoped auth
  • runAction with a custom request to pass auth tokens to server actions that call getActionRequest()
  • Catching redirect responses with .catch((e) => e) then asserting on status and location header
  • res.loaderData to verify the loader returned correct data from the real database
  • res.page for inline JSX snapshots; res.text() for full HTML assertions

Type Safety

Register your app type once so router.href() and createSpiceflowFetch() paths are fully typed.

// main.tsx
export const app = new Spiceflow()
  .get('/api/projects', () => ({ projects: [] }))
  .page('/dashboard', async () => <div>Dashboard</div>)

declare module 'spiceflow/react' {
  interface SpiceflowRegister {
    app: typeof app
  }
}

Invalid paths and missing params are caught at compile time.

// @ts-expect-error - path does not exist
router.href('/nonexistent')

// @ts-expect-error - missing required params
f('/api/projects/:id')

Tracing

Use createTestTracer() to capture spans during app.handle() and snapshot the trace tree. The tracer records every span created by spiceflow's instrumentation and renders them as an ASCII tree via .text().

import { test, expect } from 'vitest'
import { Spiceflow } from 'spiceflow'
import { createTestTracer } from 'spiceflow/testing'

const tracer = createTestTracer()
const app = new Spiceflow({ tracer })
  .use(async (ctx, next) => { await next() }) // auth middleware
  .get('/api/projects', () => ({ projects: [] }))

test('request spans', async () => {
  tracer.clear()
  await app.handle(new Request('http://localhost/api/projects'))
  expect(tracer.text()).toMatchInlineSnapshot(`
    "GET /api/projects (200)
    ├── middleware - anonymous
    └── handler - /api/projects"
  `)
})

tracer.text() renders the span tree as ASCII with status codes on the root span. tracer.spans gives raw access to span objects. tracer.clear() resets between tests.

Formatting HTML for Snapshots

Raw HTML from res.text() is a single long string with all attributes. For readable inline snapshots, use posthtml to strip noisy attributes and posthtml-beautify to indent the output.

pnpm add -D posthtml posthtml-beautify

Strip attributes and format

import { test, expect } from 'vitest'
import posthtml from 'posthtml'
import beautify from 'posthtml-beautify'
import { createSpiceflowFetch } from 'spiceflow/client'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

/** posthtml plugin that removes specified attributes from all nodes */
function removeAttrs(attrs: string[]) {
  return (tree: any) => {
    tree.walk((node: any) => {
      if (node.attrs) {
        for (const attr of attrs) delete node.attrs[attr]
      }
      return node
    })
    return tree
  }
}

test('page structure without styling noise', async () => {
  const res = await f('/dashboard')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')

  const html = await res.text()
  const { html: clean } = await posthtml([
    removeAttrs(['class', 'style']),
    beautify({ rules: { blankLines: '' } }),
  ]).process(html)

  expect(clean).toMatchInlineSnapshot(`
    "<html lang="en">
      <head></head>
      <body>
        <div>
          <h1>Dashboard</h1>
          <p>No projects yet</p>
        </div>
      </body>
    </html>
    "
  `)
})

The blankLines: '' option removes empty lines between sibling elements that posthtml-beautify adds by default.

Find elements with tree.match

Use tree.match() to locate specific elements by tag or attributes, then assert on their properties.

test('button has correct attributes and text', async () => {
  const res = await f('/settings')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')

  const html = await res.text()
  const buttons: { tag: string; attrs: any; text: string; childCount: number }[] = []

  await posthtml()
    .use((tree) => {
      tree.match({ tag: 'button' }, (node) => {
        const text = (node.content || [])
          .filter((c): c is string => typeof c === 'string')
          .join('')
        buttons.push({
          tag: node.tag!,
          attrs: node.attrs || {},
          text,
          childCount: (node.content || []).length,
        })
        return node
      })
      return tree
    })
    .process(html)

  expect(buttons).toMatchInlineSnapshot(`
    [
      {
        "attrs": {
          "class": "btn-primary",
          "type": "submit",
        },
        "childCount": 1,
        "tag": "button",
        "text": "Save",
      },
    ]
  `)
})

tree.match() accepts matcher objects: { tag: 'div' }, { attrs: { id: 'main' } }, or both combined. It walks the full tree recursively.

Testing on Cloudflare Workers

Run tests inside the real workerd runtime with @cloudflare/vitest-pool-workers. This gives you access to cloudflare:workers APIs (env, waitUntil, D1, KV, etc.) in your test files — the same environment as production. D1 is simulated locally as an in-memory SQLite database by Miniflare; no real Cloudflare infrastructure is involved.

See the complete working example: example-vitest-cloudflare

Setup

Install the pool workers package alongside the Cloudflare Vite plugin:

pnpm add -D @cloudflare/vitest-pool-workers @cloudflare/vite-plugin wrangler

Configure vite.config.ts to swap between cloudflareTest() (vitest) and cloudflare() (dev/build) based on the VITEST env variable.

readD1Migrations() runs on the Node.js side (at config time), reads the SQL files from your migrations/ folder, and passes them as a miniflare binding called TEST_MIGRATIONS. The actual migration application happens later inside workerd via the setup file.

// vite.config.ts
import path from 'node:path'
import { cloudflare } from '@cloudflare/vite-plugin'
import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-pool-workers'
import spiceflow from 'spiceflow/vite'
import { defineConfig } from 'vite'

export default defineConfig(async () => {
  // Reads .sql files from migrations/ on the Node.js side, before workerd starts.
  // Passed to miniflare as the TEST_MIGRATIONS binding so workerd can apply them.
  const migrations = await readD1Migrations(path.join(__dirname, 'migrations')).catch(() => [])

  return {
    plugins: [
      process.env.VITEST
        ? cloudflareTest({
            wrangler: { configPath: './wrangler.jsonc' },
            miniflare: { bindings: { TEST_MIGRATIONS: migrations } },
          })
        : cloudflare({
            viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] },
          }),
      spiceflow({ entry: './src/main.tsx' }),
    ],
    test: {
      setupFiles: ['./src/apply-migrations.ts'],
    },
  }
})

Applying D1 Migrations

The setup file runs inside workerd before each test file. It applies the SQL migrations to the fresh in-memory D1 using two APIs from Cloudflare's virtual modules:

  • env from cloudflare:workers — the same bindings object available in production handlers (env.DB, env.KV, etc.), wired to Miniflare's in-memory implementations
  • applyD1Migrations from cloudflare:test — test-only helper that runs pending SQL migrations against a D1 binding; tracks which have been applied so it is safe to call multiple times
// src/apply-migrations.ts
import { applyD1Migrations } from 'cloudflare:test'
import { env } from 'cloudflare:workers'

// Applies all .sql files passed via TEST_MIGRATIONS to the in-memory env.DB.
// Idempotent: skips migrations already applied.
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)

Storage Isolation Model

All storage — D1, KV, R2, and Durable Objects — follows the same isolation model: per test file, shared within a file.

Each test file gets a completely fresh storage snapshot. workerd implements this by pushing a new SQLite snapshot onto an on-disk stack at the start of each file, then popping it at the end — discarding every write. Tests within the same file share state; different files are completely isolated and run concurrently.

This is why apply-migrations.ts runs once per file: the fresh snapshot has no tables yet, so migrations are applied to each file's clean DB before its tests start.

pnpm test
│
├─ vite.config.ts (Node.js)
│   └─ readD1Migrations() → SQL strings → TEST_MIGRATIONS binding
│
├─ users.test.ts                      ├─ posts.test.ts
│   Fresh D1 + KV + DO storage            Fresh D1 + KV + DO storage
│   ├─ setup: apply migrations             ├─ setup: apply migrations
│   ├─ test 1 → INSERT, create DO          ├─ test 1 → INSERT, create DO
│   └─ test 2 → sees test 1's state        └─ test 2 → sees test 1's state
│   (file ends → snapshot discarded)      (file ends → snapshot discarded)
│
│   Files run concurrently, each sees only its own storage.

Durable Objects follow the exact same model. DO instances created in one test file don't exist in another. listDurableObjectIds(namespace) from cloudflare:test only returns IDs created within the current file's snapshot.

If you need per-test isolation within a file: clean up manually in beforeEach/afterEach — e.g. DELETE FROM table for D1 or env.KV.delete(key) for KV.

If you need shared state across files (e.g. integration tests that build up accumulated data): run with --max-workers=1 --no-isolate.

WebSockets + Durable Objects don't work with per-file isolation. Use --max-workers=1 --no-isolate as a workaround.

Writing Tests

Import env and waitUntil directly from cloudflare:workers — the same virtual module available in your Worker handlers. env gives typed access to all bindings declared in wrangler.jsonc.

import { test, expect } from 'vitest'
import { env, waitUntil } from 'cloudflare:workers'
import { SpiceflowTestResponse } from 'spiceflow/testing'
import { createSpiceflowFetch } from 'spiceflow/client'
import { app } from './main.js'

const f = createSpiceflowFetch(app)

// Pages work the same as in Node.js tests
test('GET / renders home page', async () => {
  const res = await f('/')
  if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page')
  expect(await res.text()).toContain('Home')
})

// D1 is available via env — same API as production
test('D1: insert and query a user', async () => {
  await env.DB.prepare('INSERT INTO users (name) VALUES (?)').bind('Alice').run()
  const row = await env.DB.prepare('SELECT * FROM users WHERE name = ?').bind('Alice').first()
  expect(row).toMatchObject({ name: 'Alice' })
})

// waitUntil works inside workerd
test('waitUntil is callable', () => {
  expect(() => waitUntil(Promise.resolve('done'))).not.toThrow()
})

Cloudflare virtual module imports used in tests:

Import Module Purpose
env cloudflare:workers Bindings from wrangler.jsonc (D1, KV, R2, etc.)
waitUntil cloudflare:workers Extend Worker lifetime for background tasks
applyD1Migrations cloudflare:test Apply SQL migrations to a D1 binding in tests

Type Safety for Bindings

env from cloudflare:workers is typed via the Cloudflare.Env interface. Run wrangler types to auto-generate worker-configuration.d.ts from your wrangler.jsonc bindings:

wrangler types

This produces a file like:

// worker-configuration.d.ts  (generated, do not edit)
declare namespace Cloudflare {
  interface Env {
    DB: D1Database
    MY_KV: KVNamespace
  }
}
// makes `env` from cloudflare:workers typed as Cloudflare.Env
interface Env extends Cloudflare.Env {}

For test-only bindings that only exist in miniflare (like TEST_MIGRATIONS), augment Cloudflare.Env manually in a separate .d.ts file so you don't touch the generated file:

// src/env.d.ts
declare namespace Cloudflare {
  interface Env {
    TEST_MIGRATIONS: D1Migration[]
  }
}

interface D1Migration {
  name: string
  queries: string[]
}

After this, env.DB, env.MY_KV, and env.TEST_MIGRATIONS are all fully typed everywhere — in test files, setup files, and Worker handlers alike. The Cloudflare.Env namespace is open for augmentation, so TypeScript merges all declarations.

For testing Durable Objects, Queues, Workflows, and the full list of available test helpers, see the Cloudflare Workers Vitest test APIs reference.

What's Not Supported

Vitest mode bypasses RSC Flight serialization. Some features require the full RSC environment and should be tested with e2e tests (Playwright).

  • Error boundaries: errors in page handlers return a 500 JSON response. The error boundary fallback UI is not rendered. Use e2e tests for error boundary behavior.
  • Server actions via form POST: the RSC action protocol (decoding form data into server function calls) is not available. Test action logic by calling functions directly instead.
  • Client-side navigation: there's no browser, so router.push(), hydration, and client-side transitions can't be tested. Use e2e tests for navigation flows.