Testing
Ship with confidence. Write unit tests to verify your logic works correctly, and E2E tests to ensure complete user flows don't break. Quackback uses Vitest and Playwright.
Test Commands
Run tests using these commands from the project root:
# Unit tests (Vitest)
bun run test # Run all unit tests
bun run test <file> # Run a specific test file
bun run test --watch # Watch mode for development
# E2E tests (Playwright)
bun run test:e2e # Run all E2E tests (headless)
bun run test:e2e:ui # Run with Playwright's interactive UI
bun run test:e2e:headed # Run in headed browser modeTest with Vitest
Unit tests verify individual functions, modules, and utilities in isolation.
Test File Locations
Unit tests are placed alongside the code they test or in __tests__ directories:
packages/
ids/
src/
__tests__/
core.test.ts # Tests for packages/ids/src/core.ts
zod.test.ts # Tests for packages/ids/src/zod.ts
Vitest Configuration
The root vitest.config.ts configures the test environment:
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
globals: true, // Use global describe, it, expect
environment: 'node', // Node.js environment
include: ['**/*.test.ts'], // Test file pattern
exclude: ['**/node_modules/**', '**/.next/**'],
env: {
DATABASE_URL: 'postgresql://postgres:password@localhost:5432/quackback_test',
},
},
resolve: {
alias: {
'@quackback/db/client': path.resolve(__dirname, './packages/db/src/client.ts'),
'@quackback/db/schema': path.resolve(__dirname, './packages/db/src/schema/index.ts'),
'@quackback/db/catalog': path.resolve(__dirname, './packages/db/src/catalog/index.ts'),
'@quackback/db/tenant': path.resolve(__dirname, './packages/db/src/tenant/index.ts'),
'@quackback/db/types': path.resolve(__dirname, './packages/db/src/types.ts'),
'@quackback/db': path.resolve(__dirname, './packages/db/index.ts'),
'@': path.resolve(__dirname, './apps/web/src'),
},
},
})Write unit tests
Basic test structure:
import { describe, it, expect } from 'vitest'
import { generateId, toUuid, fromUuid } from '../core'
describe('TypeID Core', () => {
describe('generateId', () => {
it('generates a valid TypeID with the correct prefix', () => {
const id = generateId('post')
expect(id).toMatch(/^post_[0-7][0-9a-hjkmnp-tv-z]{25}$/)
})
it('generates unique IDs on each call', () => {
const ids = new Set<string>()
for (let i = 0; i < 1000; i++) {
ids.add(generateId('post'))
}
expect(ids.size).toBe(1000)
})
})
describe('round-trip conversion', () => {
it('preserves ID through UUID round-trip', () => {
const original = generateId('post')
const uuid = toUuid(original)
const restored = fromUuid('post', uuid)
expect(restored).toBe(original)
})
})
})Test error cases
describe('toUuid', () => {
it('throws on invalid TypeID format', () => {
expect(() => toUuid('invalid')).toThrow()
expect(() => toUuid('post_invalid')).toThrow()
expect(() => toUuid('')).toThrow()
})
})
describe('fromUuid', () => {
it('throws on invalid UUID format', () => {
expect(() => fromUuid('post', 'invalid')).toThrow('Invalid UUID format')
expect(() => fromUuid('post', '01893d8c-7e80-7000')).toThrow()
})
})Test with Playwright
E2E tests verify complete user flows through the browser.
Test File Structure
E2E tests are in apps/web/e2e/:
apps/web/e2e/
tests/
admin/ # Tests requiring admin authentication
boards.spec.ts # Board management tests
post-management.spec.ts # Admin post operations
statuses.spec.ts # Status configuration tests
users.spec.ts # User management tests
auth/ # Authentication flow tests
admin-login.spec.ts # Admin login tests
public/ # Public portal tests (no auth needed)
comments.spec.ts # Comment functionality
post-list.spec.ts # Post listing and filtering
post-submission.spec.ts # Creating new posts
voting.spec.ts # Voting functionality
fixtures/
auth.ts # Test fixtures and credentials
utils/
helpers.ts # Reusable test utilities
db-helpers.ts # Database utilities for tests
scripts/
get-otp.ts # Script to fetch OTP codes
ensure-role.ts # Script to ensure user roles
global-setup.ts # Runs before all tests
global-teardown.ts # Runs after all tests
Playwright Configuration
apps/web/playwright.config.ts configures test projects:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://acme.localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
// Setup project - authenticates and saves state
{
name: 'setup',
testMatch: /global-setup\.ts/,
teardown: 'cleanup',
},
{
name: 'cleanup',
testMatch: /global-teardown\.ts/,
},
// Admin tests - uses saved auth state
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 },
storageState: 'e2e/.auth/admin.json',
},
dependencies: ['setup'],
testMatch: /tests\/admin\/.+\.spec\.ts/,
},
// Auth tests - no stored state
{
name: 'chromium-auth',
use: { ...devices['Desktop Chrome'] },
testMatch: /tests\/auth\/.+\.spec\.ts/,
},
// Public tests - no authentication needed
{
name: 'chromium-public',
use: { ...devices['Desktop Chrome'] },
testMatch: /tests\/public\/.+\.spec\.ts/,
},
],
webServer: {
command: 'bun run dev',
url: 'http://acme.localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
timeout: 30 * 1000,
expect: { timeout: 5 * 1000 },
})Authentication in Tests
Global Setup
The global-setup.ts authenticates once and saves the session:
import { test as setup, expect } from '@playwright/test'
import { getOtpCode, ensureTestUserHasRole } from './utils/db-helpers'
const ADMIN_EMAIL = 'demo@example.com'
const AUTH_FILE = 'e2e/.auth/admin.json'
const TEST_HOST = 'acme.localhost:3000'
setup('authenticate as admin', async ({ page }) => {
const request = page.request
// Request OTP code via Better-auth
await request.post('/api/auth/email-otp/send-verification-otp', {
data: { email: ADMIN_EMAIL, type: 'sign-in' },
})
// Get OTP from database
const code = await getOtpCode(ADMIN_EMAIL, TEST_HOST)
// Verify OTP and sign in
await request.post('/api/auth/sign-in/email-otp', {
data: { email: ADMIN_EMAIL, otp: code },
})
// Ensure admin role
ensureTestUserHasRole(ADMIN_EMAIL, 'admin')
// Navigate and verify
await page.goto('/admin')
await expect(page).toHaveURL(/\/admin/, { timeout: 10000 })
// Save auth state
await page.context().storageState({ path: AUTH_FILE })
})Test-Level Authentication
For tests that need their own auth context:
import { test, expect, Page, BrowserContext } from '@playwright/test'
import { getOtpCode } from '../../utils/db-helpers'
const TEST_EMAIL = 'demo@example.com'
const TEST_HOST = 'acme.localhost:3000'
test.describe.configure({ mode: 'serial' })
async function loginWithOTP(page: Page) {
const context = page.context()
// Request OTP
await context.request.post('/api/auth/email-otp/send-verification-otp', {
data: { email: TEST_EMAIL, type: 'sign-in' },
})
// Get and verify OTP
const code = await getOtpCode(TEST_EMAIL, TEST_HOST)
await context.request.post('/api/auth/sign-in/email-otp', {
data: { email: TEST_EMAIL, otp: code },
})
// Navigate to verify
await page.goto('/')
await page.waitForLoadState('networkidle')
}
let globalContext: BrowserContext
let globalPage: Page
test.beforeAll(async ({ browser }) => {
globalContext = await browser.newContext()
globalPage = await globalContext.newPage()
await loginWithOTP(globalPage)
})
test.afterAll(async () => {
await globalPage.close()
await globalContext.close()
})Write E2E tests
Basic Test Structure
import { test, expect } from '@playwright/test'
test.describe('Admin Board Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/admin/settings/boards')
await page.waitForLoadState('networkidle')
})
test('displays board settings page', async ({ page }) => {
await expect(page.getByText('General Settings')).toBeVisible({ timeout: 10000 })
})
test('can edit board name', async ({ page }) => {
const nameInput = page.getByRole('textbox', { name: 'Board name', exact: true })
await nameInput.clear()
await nameInput.fill('Test Board Name')
const saveButton = page.getByRole('button', { name: 'Save changes' })
await saveButton.click()
await page.waitForLoadState('networkidle')
})
})Test forms
test('can create a new board', async ({ page }) => {
// Open dialog
await page.getByRole('button', { name: 'New board' }).click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
// Fill form (scope selectors to dialog)
const testBoardName = `E2E Test Board ${Date.now()}`
await dialog.getByLabel('Board name').fill(testBoardName)
await dialog.getByLabel('Description').fill('Board created by Playwright test')
// Verify default state
const publicSwitch = dialog.getByRole('switch', { name: 'Public board' })
await expect(publicSwitch).toBeChecked()
// Submit
await dialog.getByRole('button', { name: 'Create board' }).click()
await expect(dialog).toBeHidden({ timeout: 10000 })
// Verify result
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('board-switcher')).toContainText(testBoardName)
})Test rich text editors (TipTap)
test('can type in TipTap editor', async ({ page }) => {
const editor = page.locator('.tiptap')
await editor.click()
await page.keyboard.type('This is plain text content')
await expect(editor).toContainText('This is plain text content')
})
test('can format text as bold', async ({ page }) => {
const editor = page.locator('.tiptap')
await editor.click()
await page.keyboard.type('bold text')
// Select all and apply bold
await editor.click({ clickCount: 3 })
const boldButton = page.locator('button:has(svg.lucide-bold)')
await boldButton.click()
await expect(editor.locator('strong')).toContainText('bold text')
})Serial Test Execution
For tests that must run in order or modify shared state:
test.describe('Board Deletion Flow', () => {
test.describe.configure({ mode: 'serial' })
test('can delete a board after typing confirmation', async ({ page }) => {
// Create board first
await page.goto('/admin/settings/boards')
const testBoardName = `Test Delete Board ${Date.now()}`
// ... create board ...
// Delete it
const deleteButton = page.getByRole('button', { name: 'Delete board', exact: true })
const confirmInput = page.getByPlaceholder(testBoardName)
await confirmInput.fill(testBoardName)
await deleteButton.click()
await expect(page).toHaveURL(/\/admin\/settings\/boards/)
})
})Test Utilities
Helper Functions (utils/helpers.ts)
import { Page, expect } from '@playwright/test'
// Wait for toast notifications
export async function waitForToast(page: Page, text: string | RegExp) {
const toast = page.locator('[data-sonner-toast]').filter({ hasText: text })
await expect(toast).toBeVisible({ timeout: 5000 })
return toast
}
// Select from Radix Select component
export async function selectOption(page: Page, triggerLabel: string, optionText: string) {
await page.getByRole('combobox', { name: triggerLabel }).click()
await page.getByRole('option', { name: optionText }).click()
}
// Fill TipTap rich text editor
export async function fillRichTextEditor(page: Page, content: string) {
const editor = page.locator('.ProseMirror[contenteditable="true"]')
await editor.click()
await editor.fill(content)
}
// Open and close dialogs
export async function openDialog(page: Page, triggerText: string | RegExp) {
await page.getByRole('button', { name: triggerText }).click()
await expect(page.getByRole('dialog')).toBeVisible()
}
export async function closeDialog(page: Page) {
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toBeHidden()
}Database Helpers (utils/db-helpers.ts)
import { execSync } from 'child_process'
import { resolve } from 'path'
// Get OTP code for a user
export function getOtpCode(email: string, host: string): string {
const scriptPath = resolve(__dirname, '../scripts/get-otp.ts')
const result = execSync(
`dotenv -e ../../.env -- bun "${scriptPath}" "${email}" "${host}"`,
{ encoding: 'utf-8', cwd: resolve(__dirname, '../..') }
)
return result.trim()
}
// Ensure user has the required role
export function ensureTestUserHasRole(email: string, role: string = 'admin'): void {
const scriptPath = resolve(__dirname, '../scripts/ensure-role.ts')
execSync(
`dotenv -e ../../.env -- bun "${scriptPath}" "${email}" "${role}"`,
{ encoding: 'utf-8', cwd: resolve(__dirname, '../..') }
)
}Best Practices
Locator Strategies
// Prefer role-based selectors
page.getByRole('button', { name: 'Save changes' })
page.getByRole('textbox', { name: 'Board name' })
page.getByRole('dialog')
// Use labels for form fields
page.getByLabel('Board name')
page.getByLabel('Description')
// Use placeholder for inputs
page.getByPlaceholder("What's your idea?")
// Use text content
page.getByText('General Settings')
// Use test IDs for complex cases
page.getByTestId('board-switcher')
// Scope to parent for disambiguation
const dialog = page.getByRole('dialog')
await dialog.getByLabel('Board name').fill('test')Waiting Strategies
// Wait for network to settle
await page.waitForLoadState('networkidle')
// Wait for elements
await expect(page.getByText('Success')).toBeVisible({ timeout: 10000 })
// Wait for URL changes
await expect(page).toHaveURL(/\/admin/, { timeout: 10000 })
// Wait for dialogs to close
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10000 })
// Wait for loading states
await expect(page.getByRole('button', { name: 'Saving...' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible()Unique Test Data
// Use timestamps for unique names
const uniqueTitle = `E2E Test Post ${Date.now()}`
const testBoardName = `Test Board ${Date.now()}`Run tests
# All E2E tests
bun run test:e2e
# Specific test file
bunx playwright test e2e/tests/admin/boards.spec.ts
# Specific test
bunx playwright test -g "can create a new board"
# With UI mode (recommended for debugging)
bun run test:e2e:ui
# Headed mode (see browser)
bun run test:e2e:headed
# Debug mode
bunx playwright test --debugDebug tests
- Use UI mode:
bun run test:e2e:uifor interactive debugging - Add screenshots: Automatic on failure, or
await page.screenshot({ path: 'debug.png' }) - Use traces:
await page.context().tracing.start({ screenshots: true }) - Console logs:
console.login tests appears in terminal - Slow down:
slowMo: 100in config for visual debugging
Related Documentation
- Server Functions - Testing server function logic
- Architecture - Understanding the codebase structure
- Setup - Setting up the development environment