Testing
Unit tests, integration tests, and E2E testing strategies.
Setup
Install convex-test:
bun add -d convex-testSet 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
} = tUnit 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.
Testing search
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 --onceWriting 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:e2eTest 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:allThis 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)
| File | describe | test/it |
|---|---|---|
src/__tests__/bin-smoke.test.ts | 3 | 16 |
src/__tests__/cli-utils.test.ts | 1 | 1 |
src/__tests__/doctor-fix.test.ts | 1 | 1 |
src/__tests__/help-commands.test.ts | 1 | 8 |
src/__tests__/init.test.ts | 1 | 1 |
src/__tests__/scaffold-ops.test.ts | 1 | 10 |
src/convex/__tests__/audit.test.ts | 1 | 2 |
src/convex/__tests__/budget.property.test.ts | 1 | 4 |
src/convex/__tests__/budget.synthetic.test.ts | 1 | 12 |
src/convex/__tests__/budget.test.ts | 2 | 6 |
src/convex/__tests__/builder.test.ts | 4 | 10 |
src/convex/__tests__/devtools.test.ts | 1 | 5 |
src/convex/__tests__/docs-gen.test.ts | 3 | 6 |
src/convex/__tests__/doctor.test.ts | 1 | 5 |
src/convex/__tests__/eslint-plugin.test.ts | 1 | 5 |
src/convex/__tests__/eslint-smoke.test.ts | 1 | 5 |
src/convex/__tests__/manifest.test.ts | 1 | 25 |
src/convex/__tests__/optimistic-store.test.tsx | 1 | 3 |
src/convex/__tests__/pure.test.ts | 162 | 967 |
src/convex/__tests__/use-bulk-mutate.test.tsx | 1 | 5 |
src/convex/__tests__/use-form.test.tsx | 1 | 2 |
src/convex/__tests__/use-list.test.tsx | 2 | 5 |
src/convex/__tests__/use-mutate.test.ts | 1 | 4 |
src/convex/server/__tests__/helpers.test.ts | 4 | 10 |
src/convex/server/__tests__/middleware.test.ts | 4 | 9 |
src/convex/server/__tests__/schema-helpers.test.ts | 1 | 5 |
src/convex/server/__tests__/setup-hooks.test.ts | 1 | 3 |
src/convex/server/__tests__/test-harness.test.ts | 2 | 6 |
src/convex/tools/__tests__/dispatch.test.ts | 2 | 15 |
src/convex/tools/__tests__/error.test.ts | 0 | 9 |
src/convex/tools/__tests__/manifest.test.ts | 4 | 10 |
src/convex/tools/__tests__/parser.test.ts | 0 | 10 |
src/convex/tools/__tests__/step-sink.test.ts | 0 | 4 |
src/convex/tools/__tests__/to-dispatch-error.test.ts | 1 | 3 |
src/shared/__tests__/auth-helpers.test.ts | 2 | 10 |
src/shared/__tests__/binary.test.ts | 3 | 6 |
src/shared/__tests__/bounded-stream.test.ts | 3 | 8 |
src/shared/__tests__/cli.test.ts | 6 | 20 |
src/shared/__tests__/completions.test.ts | 1 | 5 |
src/shared/__tests__/config.test.ts | 2 | 4 |
src/shared/__tests__/crash-log.test.ts | 1 | 3 |
src/shared/__tests__/docs-gen.test.ts | 4 | 12 |
src/shared/__tests__/env-file.test.ts | 2 | 6 |
src/shared/__tests__/env-zod.test.ts | 2 | 6 |
src/shared/__tests__/error-toast.test.tsx | 1 | 8 |
src/shared/__tests__/eslint-factory.test.ts | 6 | 23 |
src/shared/__tests__/file-utils.test.ts | 6 | 14 |
src/shared/__tests__/fixtures.test.ts | 1 | 4 |
src/shared/__tests__/form-meta.test.ts | 6 | 19 |
src/shared/__tests__/form-use-form.test.tsx | 1 | 8 |
src/shared/__tests__/helpers.test.ts | 1 | 6 |
src/shared/__tests__/http-body.test.ts | 2 | 6 |
src/shared/__tests__/log.test.ts | 1 | 3 |
src/shared/__tests__/redact.test.ts | 1 | 9 |
src/shared/__tests__/retry.test.ts | 3 | 15 |
src/shared/__tests__/sanitize.test.ts | 4 | 18 |
src/shared/__tests__/security.test.ts | 3 | 10 |
src/shared/__tests__/small-utils.test.ts | 4 | 10 |
src/shared/__tests__/sse.test.ts | 1 | 10 |
src/shared/__tests__/state.test.ts | 1 | 3 |
src/shared/__tests__/test-utils.test.ts | 3 | 14 |
src/shared/__tests__/token-bucket.test.ts | 2 | 9 |
src/shared/__tests__/update-check.test.ts | 2 | 10 |
src/shared/__tests__/url.fuzz.test.ts | 1 | 5 |
src/shared/__tests__/url.test.ts | 6 | 23 |
src/shared/__tests__/use-bulk-mutate.test.tsx | 1 | 9 |
src/shared/__tests__/use-bulk-selection.test.tsx | 1 | 8 |
src/shared/__tests__/use-online-status.test.tsx | 1 | 4 |
src/shared/__tests__/use-optimistic.test.tsx | 1 | 5 |
src/shared/__tests__/use-soft-delete.test.tsx | 1 | 3 |
src/shared/__tests__/viz.test.ts | 3 | 4 |
src/shared/__tests__/zod.test.ts | 11 | 49 |
src/spacetimedb/__tests__/check.test.ts | 1 | 19 |
src/spacetimedb/__tests__/docs-gen.test.ts | 1 | 4 |
src/spacetimedb/__tests__/doctor.test.ts | 1 | 3 |
src/spacetimedb/__tests__/eslint-plugin.test.ts | 1 | 6 |
src/spacetimedb/__tests__/migrate.test.ts | 1 | 6 |
src/spacetimedb/__tests__/pure.test.ts | 203 | 1199 |
src/spacetimedb/react/__tests__/devtools.test.ts | 1 | 5 |
src/spacetimedb/react/__tests__/optimistic-store.test.tsx | 1 | 8 |
src/spacetimedb/react/__tests__/use-bulk-mutate.test.tsx | 1 | 5 |
src/spacetimedb/react/__tests__/use-list.test.tsx | 1 | 3 |
src/spacetimedb/react/__tests__/use-mutate.test.ts | 1 | 4 |
src/spacetimedb/server/__tests__/cache-crud.test.ts | 1 | 7 |
src/spacetimedb/server/__tests__/child.test.ts | 1 | 6 |
src/spacetimedb/server/__tests__/crud.test.ts | 1 | 5 |
src/spacetimedb/server/__tests__/file.test.ts | 1 | 8 |
src/spacetimedb/server/__tests__/helpers.test.ts | 1 | 17 |
src/spacetimedb/server/__tests__/kv.test.ts | 1 | 6 |
src/spacetimedb/server/__tests__/log.test.ts | 1 | 7 |
src/spacetimedb/server/__tests__/middleware.test.ts | 1 | 3 |
src/spacetimedb/server/__tests__/org-crud.test.ts | 1 | 9 |
src/spacetimedb/server/__tests__/org-invites.test.ts | 1 | 6 |
src/spacetimedb/server/__tests__/org-join.test.ts | 1 | 7 |
src/spacetimedb/server/__tests__/org-members.test.ts | 1 | 6 |
src/spacetimedb/server/__tests__/org.test.ts | 2 | 10 |
src/spacetimedb/server/__tests__/presence.test.ts | 1 | 5 |
src/spacetimedb/server/__tests__/quota.test.ts | 1 | 2 |
src/spacetimedb/server/__tests__/rls.test.ts | 1 | 11 |
src/spacetimedb/server/__tests__/schema-helpers.test.ts | 1 | 13 |
src/spacetimedb/server/__tests__/setup.test.ts | 1 | 14 |
src/spacetimedb/server/__tests__/singleton.test.ts | 1 | 4 |
src/spacetimedb/server/__tests__/stdb-tables.test.ts | 1 | 7 |
src/spacetimedb/server/__tests__/test.test.ts | 1 | 2 |
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.
| Demo | cvx files | cvx describe | cvx test | stdb files | stdb describe | stdb test |
|---|---|---|---|---|---|---|
blog | 5 | 2 | 52 | 5 | 2 | 52 |
chat | 2 | 6 | 26 | 2 | 6 | 26 |
movie | 1 | 0 | 14 | 1 | 0 | 14 |
org | 6 | 0 | 128 | 6 | 0 | 128 |
poll | 14 | 18 | 82 | 14 | 18 | 82 |
| total | 28 | — | 302 | 28 | — | 302 |