noboil

Testing

Unit tests, integration tests, and E2E testing strategies.

Setup

Install convex-test:

bun add -d convex-test

Set CONVEX_TEST_MODE=true when running tests:

{
  "scripts": {
    "test": "CONVEX_TEST_MODE=true bun with-env bun test"
  }
}

Create a test auth helper in convex/testAuth.ts:

import { makeTestAuth } from 'noboil/convex/test'
import { getAuthUserId } from '@convex-dev/auth/server'
import { mutation, query } from './_generated/server'

const t = makeTestAuth({ getAuthUserId, mutation, query })
export const {
  ensureTestUser,
  getTestUser,
  cleanupTestUsers,
  getAuthUserIdOrTest
} = t

Unit tests

import { convexTest } from 'convex-test'
import { describe, expect, test } from 'bun:test'
import schema from './schema'
import { api } from './_generated/api'

const modules = {
  './_generated/api.js': async () => import('./_generated/api'),
  './_generated/server.js': async () => import('./_generated/server'),
  './blog.ts': async () => import('./blog')
}

describe('blog CRUD', () => {
  test('create and read a blog post', async () => {
    const ctx = convexTest(schema, modules)
    const userId = await ctx.run(async c =>
      c.db.insert('users', {
        email: 'test@example.com',
        emailVerificationTime: Date.now()
      })
    )
    const asUser = ctx.withIdentity({
      subject: userId,
      tokenIdentifier: `test|${userId}`
    })
    const postId = await asUser.mutation(api.blog.create, {
      title: 'Hello',
      content: 'World',
      category: 'tech',
      published: true
    })
    const post = await asUser.query(api.blog.read, { id: postId })
    expect(post?.title).toBe('Hello')
  })
})

Integration tests

convex-test runs your Convex functions in a local in-memory environment. No network calls, no deployed backend needed.

describe('blog CRUD', () => {
  test('create and read a blog post', async () => {
    const ctx = convexTest(schema, modules)
    const userId = await ctx.run(async c =>
      c.db.insert('users', {
        email: 'test@example.com',
        emailVerificationTime: Date.now()
      })
    )
    const asUser = ctx.withIdentity({
      subject: userId,
      tokenIdentifier: `test|${userId}`
    })
    const postId = await asUser.mutation(api.blog.create, {
      title: 'Hello',
      content: 'World',
      category: 'tech',
      published: true
    })
    const post = await asUser.query(api.blog.read, { id: postId })
    expect(post?.title).toBe('Hello')
  })
})

Testing org-scoped endpoints

makeOrgTestCrud creates test helpers for org tables with membership and ACL checks:

import { makeOrgTestCrud } from 'noboil/convex/test'

export const wikiTest = makeOrgTestCrud({
  acl: true,
  mutation,
  query,
  table: 'wiki'
})
const orgId = await ctx.run(async c =>
  c.db.insert('org', {
    name: 'Acme',
    slug: 'acme',
    updatedAt: Date.now(),
    userId: ownerId
  })
)
const memberId = await ctx.run(async c =>
  c.db.insert('orgMember', {
    isAdmin: false,
    orgId,
    updatedAt: Date.now(),
    userId: memberUserId
  })
)

let threw = false
try {
  await asMember.mutation(api.wiki.update, {
    id: wikiId,
    orgId,
    title: 'Hacked'
  })
} catch (error) {
  threw = true
  expect(String(error)).toContain('EDITOR_REQUIRED')
}
expect(threw).toBe(true)

Testing auth

Test both authorization (wrong user) and authentication (no user) failures:

test('update fails on non-owned document', async () => {
  const ctx = convexTest(schema, modules)
  const owner = await ctx.run(async c =>
    c.db.insert('users', {
      email: 'owner@test.com',
      emailVerificationTime: Date.now()
    })
  )
  const other = await ctx.run(async c =>
    c.db.insert('users', {
      email: 'other@test.com',
      emailVerificationTime: Date.now()
    })
  )

  const asOwner = ctx.withIdentity({
    subject: owner,
    tokenIdentifier: `test|${owner}`
  })
  const asOther = ctx.withIdentity({
    subject: other,
    tokenIdentifier: `test|${other}`
  })

  const id = await asOwner.mutation(api.blog.create, {
    title: 'My Post',
    content: 'Content',
    category: 'tech',
    published: true
  })

  let threw = false
  try {
    await asOther.mutation(api.blog.update, { id, title: 'Hacked' })
  } catch (error) {
    threw = true
    expect(String(error)).toContain('NOT_FOUND')
  }
  expect(threw).toBe(true)
})

test('unauthenticated access throws', async () => {
  const ctx = convexTest(schema, modules)
  let threw = false
  try {
    await ctx.mutation(api.blog.create, {
      title: 'No Auth',
      content: 'Content',
      category: 'tech',
      published: true
    })
  } catch (error) {
    threw = true
    expect(String(error)).toContain('NOT_AUTHENTICATED')
  }
  expect(threw).toBe(true)
})

Testing soft delete and restore

Tables with softDelete: true don't delete documents — they set deletedAt. The restore endpoint reverses this.

test('soft delete and restore', async () => {
  const ctx = convexTest(schema, modules)
  const userId = await ctx.run(async c =>
    c.db.insert('users', {
      email: 'test@example.com',
      emailVerificationTime: Date.now()
    })
  )
  const asUser = ctx.withIdentity({
    subject: userId,
    tokenIdentifier: `test|${userId}`
  })

  const id = await asUser.mutation(api.wiki.create, {
    orgId,
    slug: 'test',
    status: 'draft',
    title: 'Test'
  })

  await asUser.mutation(api.wiki.rm, { id, orgId })

  const deleted = await asUser.query(api.wiki.read, { id, orgId })
  expect(deleted.deletedAt).toBeDefined()

  await asUser.mutation(api.wiki.restore, { id, orgId })

  const restored = await asUser.query(api.wiki.read, { id, orgId })
  expect(restored.deletedAt).toBeUndefined()
})

Testing rate limiting

Rate limiting is skipped when CONVEX_TEST_MODE=true. To test rate limits, either unset the env var or test against a deployed backend.

test('rate limit blocks excessive requests', async () => {
  const ctx = convexTest(schema, modules)
  const userId = await ctx.run(async c =>
    c.db.insert('users', {
      email: 'test@example.com',
      emailVerificationTime: Date.now()
    })
  )
  const asUser = ctx.withIdentity({
    subject: userId,
    tokenIdentifier: `test|${userId}`
  })

  for (let i = 0; i < 10; i++) {
    await asUser.mutation(api.blog.create, {
      title: `Post ${String(i)}`,
      content: 'Content',
      category: 'tech',
      published: true
    })
  }

  let threw = false
  try {
    await asUser.mutation(api.blog.create, {
      title: 'One too many',
      content: 'Content',
      category: 'tech',
      published: true
    })
  } catch (error) {
    threw = true
    expect(String(error)).toContain('RATE_LIMITED')
  }
  expect(threw).toBe(true)
})

This test only works when CONVEX_TEST_MODE is NOT set. isTestMode() bypasses rate limits, so the 11th request will succeed in test mode.

Search tests require the searchIndex to be defined in your schema. convex-test supports search indexes — results match the same behavior as production.

test('search returns matching results', async () => {
  const ctx = convexTest(schema, modules)
  const userId = await ctx.run(async c =>
    c.db.insert('users', {
      email: 'test@example.com',
      emailVerificationTime: Date.now()
    })
  )
  const asUser = ctx.withIdentity({
    subject: userId,
    tokenIdentifier: `test|${userId}`
  })

  await asUser.mutation(api.blog.create, {
    title: 'TypeScript Guide',
    content: 'Learn TypeScript basics',
    category: 'tech',
    published: true
  })
  await asUser.mutation(api.blog.create, {
    title: 'Cooking Tips',
    content: 'Best pasta recipes',
    category: 'life',
    published: true
  })

  const results = await asUser.query(api.blog.search, { query: 'TypeScript' })
  expect(results.length).toBe(1)
  expect(results[0]?.title).toBe('TypeScript Guide')
})

Testing conflict detection

expectedUpdatedAt enables optimistic concurrency control.

test('concurrent edit triggers conflict', async () => {
  const ctx = convexTest(schema, modules)
  const userId = await ctx.run(async c =>
    c.db.insert('users', {
      email: 'test@example.com',
      emailVerificationTime: Date.now()
    })
  )
  const asUser = ctx.withIdentity({
    subject: userId,
    tokenIdentifier: `test|${userId}`
  })

  const id = await asUser.mutation(api.blog.create, {
    title: 'Original',
    content: 'Content',
    category: 'tech',
    published: true
  })
  const post = await asUser.query(api.blog.read, { id })
  const staleTimestamp = post?.updatedAt

  await asUser.mutation(api.blog.update, { id, title: 'Updated by user A' })

  let threw = false
  try {
    await asUser.mutation(api.blog.update, {
      id,
      title: 'Updated by user B',
      expectedUpdatedAt: staleTimestamp
    })
  } catch (error) {
    threw = true
    expect(String(error)).toContain('CONFLICT')
  }
  expect(threw).toBe(true)
})

Testing log / kv / quota factories

The new factories follow the same convexTest / stdb integration patterns. See poll demo and backend/convex/convex/f.test.ts for full examples.

test('log: append + list returns rows in order', async () => {
  const ctx = t()
  const { asUser } = await createTestContext(ctx)
  const parent = 'poll-1'
  await asUser(0).mutation(api.vote.append, { parent, payload: { optionIdx: 0, voter: 'a' } })
  await asUser(0).mutation(api.vote.append, { parent, payload: { optionIdx: 1, voter: 'b' } })
  const { page } = await asUser(0).query(api.vote.list, {
    parent,
    paginationOpts: { cursor: null, numItems: 100 }
  })
  expect(page.length).toBe(2)
})
test('kv: set + restore round-trip', async () => {
  const ctx = t()
  const { asUser } = await createTestContext(ctx)
  await asUser(0).mutation(api.siteConfig.set, { key: 'banner', payload: { active: true, message: 'hi' } })
  await asUser(0).mutation(api.siteConfig.rm, { key: 'banner' })
  expect(await asUser(0).query(api.siteConfig.get, { key: 'banner' })).toBeNull()
  await asUser(0).mutation(api.siteConfig.restore, { key: 'banner' })
  const back = await asUser(0).query(api.siteConfig.get, { key: 'banner' })
  expect(back?.message).toBe('hi')
})
test('quota: exhausting limit returns allowed=false', async () => {
  const ctx = t()
  const { asUser } = await createTestContext(ctx)
  for (let i = 0; i < 30; i += 1) await asUser(0).mutation(api.pollVoteQuota.consume, { owner: 'p1' })
  const result = await asUser(0).mutation(api.pollVoteQuota.consume, { owner: 'p1' })
  expect(result.allowed).toBe(false)
})

E2E tests with Playwright

E2E tests run against the full stack.

import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  timeout: 10_000,
  use: {
    baseURL: 'http://localhost:4110'
  },
  webServer: {
    command: 'bun dev',
    url: 'http://localhost:4110',
    reuseExistingServer: !process.env.CI
  }
})

Deploy Convex before running tests:

CONVEX_TEST_MODE=true bun with-env convex dev --once

Writing E2E tests

import { expect, test } from '@playwright/test'

test('creates and displays a post', async ({ page }) => {
  await page.goto('/')

  await page.waitForSelector('[data-testid="post-list"]')

  await page.click('[data-testid="new-post-button"]')
  await page.fill('[name="title"]', 'E2E test post')
  await page.fill('[name="content"]', 'Test content')
  await page.click('[type="submit"]')

  await expect(page.getByText('E2E test post')).toBeVisible()
})

test('deletes a post', async ({ page }) => {
  await page.goto('/')
  await page.waitForSelector('[data-testid="post-list"]')

  const postTitle = `Delete test ${Date.now()}`
  await page.click('[data-testid="new-post-button"]')
  await page.fill('[name="title"]', postTitle)
  await page.fill('[name="content"]', 'To be deleted')
  await page.click('[type="submit"]')

  await expect(page.getByText(postTitle)).toBeVisible()

  await page.click(`[data-testid="delete-${postTitle}"]`)

  await expect(page.getByText(postTitle)).not.toBeVisible()
})

Running E2E tests

timeout 30 bun playwright test e2e/blog.test.ts --timeout=8000

bun test:e2e

Test isolation

convex-test creates a fresh in-memory database for each test context. Tests are isolated by default — no cleanup needed between tests.

Running all tests

bun test:all

This runs unit tests and E2E tests in parallel. All tests must pass before pushing.

Test inventory

Auto-generated breakdown of every *.test.ts in lib/noboil/. Counts come from static describe/test/it regex scans, so they reflect declared assertions, not runtime pass/fail.

2998 tests across 104 files (548 describe blocks)

Filedescribetest/it
src/__tests__/bin-smoke.test.ts316
src/__tests__/cli-utils.test.ts11
src/__tests__/doctor-fix.test.ts11
src/__tests__/help-commands.test.ts18
src/__tests__/init.test.ts11
src/__tests__/scaffold-ops.test.ts110
src/convex/__tests__/audit.test.ts12
src/convex/__tests__/budget.property.test.ts14
src/convex/__tests__/budget.synthetic.test.ts112
src/convex/__tests__/budget.test.ts26
src/convex/__tests__/builder.test.ts410
src/convex/__tests__/devtools.test.ts15
src/convex/__tests__/docs-gen.test.ts36
src/convex/__tests__/doctor.test.ts15
src/convex/__tests__/eslint-plugin.test.ts15
src/convex/__tests__/eslint-smoke.test.ts15
src/convex/__tests__/manifest.test.ts125
src/convex/__tests__/optimistic-store.test.tsx13
src/convex/__tests__/pure.test.ts162967
src/convex/__tests__/use-bulk-mutate.test.tsx15
src/convex/__tests__/use-form.test.tsx12
src/convex/__tests__/use-list.test.tsx25
src/convex/__tests__/use-mutate.test.ts14
src/convex/server/__tests__/helpers.test.ts410
src/convex/server/__tests__/middleware.test.ts49
src/convex/server/__tests__/schema-helpers.test.ts15
src/convex/server/__tests__/setup-hooks.test.ts13
src/convex/server/__tests__/test-harness.test.ts26
src/convex/tools/__tests__/dispatch.test.ts215
src/convex/tools/__tests__/error.test.ts09
src/convex/tools/__tests__/manifest.test.ts410
src/convex/tools/__tests__/parser.test.ts010
src/convex/tools/__tests__/step-sink.test.ts04
src/convex/tools/__tests__/to-dispatch-error.test.ts13
src/shared/__tests__/auth-helpers.test.ts210
src/shared/__tests__/binary.test.ts36
src/shared/__tests__/bounded-stream.test.ts38
src/shared/__tests__/cli.test.ts620
src/shared/__tests__/completions.test.ts15
src/shared/__tests__/config.test.ts24
src/shared/__tests__/crash-log.test.ts13
src/shared/__tests__/docs-gen.test.ts412
src/shared/__tests__/env-file.test.ts26
src/shared/__tests__/env-zod.test.ts26
src/shared/__tests__/error-toast.test.tsx18
src/shared/__tests__/eslint-factory.test.ts623
src/shared/__tests__/file-utils.test.ts614
src/shared/__tests__/fixtures.test.ts14
src/shared/__tests__/form-meta.test.ts619
src/shared/__tests__/form-use-form.test.tsx18
src/shared/__tests__/helpers.test.ts16
src/shared/__tests__/http-body.test.ts26
src/shared/__tests__/log.test.ts13
src/shared/__tests__/redact.test.ts19
src/shared/__tests__/retry.test.ts315
src/shared/__tests__/sanitize.test.ts418
src/shared/__tests__/security.test.ts310
src/shared/__tests__/small-utils.test.ts410
src/shared/__tests__/sse.test.ts110
src/shared/__tests__/state.test.ts13
src/shared/__tests__/test-utils.test.ts314
src/shared/__tests__/token-bucket.test.ts29
src/shared/__tests__/update-check.test.ts210
src/shared/__tests__/url.fuzz.test.ts15
src/shared/__tests__/url.test.ts623
src/shared/__tests__/use-bulk-mutate.test.tsx19
src/shared/__tests__/use-bulk-selection.test.tsx18
src/shared/__tests__/use-online-status.test.tsx14
src/shared/__tests__/use-optimistic.test.tsx15
src/shared/__tests__/use-soft-delete.test.tsx13
src/shared/__tests__/viz.test.ts34
src/shared/__tests__/zod.test.ts1149
src/spacetimedb/__tests__/check.test.ts119
src/spacetimedb/__tests__/docs-gen.test.ts14
src/spacetimedb/__tests__/doctor.test.ts13
src/spacetimedb/__tests__/eslint-plugin.test.ts16
src/spacetimedb/__tests__/migrate.test.ts16
src/spacetimedb/__tests__/pure.test.ts2031199
src/spacetimedb/react/__tests__/devtools.test.ts15
src/spacetimedb/react/__tests__/optimistic-store.test.tsx18
src/spacetimedb/react/__tests__/use-bulk-mutate.test.tsx15
src/spacetimedb/react/__tests__/use-list.test.tsx13
src/spacetimedb/react/__tests__/use-mutate.test.ts14
src/spacetimedb/server/__tests__/cache-crud.test.ts17
src/spacetimedb/server/__tests__/child.test.ts16
src/spacetimedb/server/__tests__/crud.test.ts15
src/spacetimedb/server/__tests__/file.test.ts18
src/spacetimedb/server/__tests__/helpers.test.ts117
src/spacetimedb/server/__tests__/kv.test.ts16
src/spacetimedb/server/__tests__/log.test.ts17
src/spacetimedb/server/__tests__/middleware.test.ts13
src/spacetimedb/server/__tests__/org-crud.test.ts19
src/spacetimedb/server/__tests__/org-invites.test.ts16
src/spacetimedb/server/__tests__/org-join.test.ts17
src/spacetimedb/server/__tests__/org-members.test.ts16
src/spacetimedb/server/__tests__/org.test.ts210
src/spacetimedb/server/__tests__/presence.test.ts15
src/spacetimedb/server/__tests__/quota.test.ts12
src/spacetimedb/server/__tests__/rls.test.ts111
src/spacetimedb/server/__tests__/schema-helpers.test.ts113
src/spacetimedb/server/__tests__/setup.test.ts114
src/spacetimedb/server/__tests__/singleton.test.ts14
src/spacetimedb/server/__tests__/stdb-tables.test.ts17
src/spacetimedb/server/__tests__/test.test.ts12

E2E coverage

Playwright test counts per demo app, scanned from each demo's e2e/*.test.ts. These run against a real backend.

Playwright E2E coverage across all 10 demo apps. 604 total tests in 56 files.

Democvx filescvx describecvx teststdb filesstdb describestdb test
blog52525252
chat26262626
movie10141014
org6012860128
poll141882141882
total2830228302

On this page