Testing¶
!!! info "TL;DR" Canviq uses Vitest for unit tests and Playwright for e2e tests. All non-trivial changes require tests. Mock Supabase chains with vi.doMock, test both happy and failure paths, and ensure RLS policies have explicit test coverage.
Testing Tools¶
Canviq uses two testing frameworks:
- Vitest: Fast unit tests for Server Actions, utilities, hooks, and components
- Playwright: End-to-end browser tests for user workflows
Run tests locally:
npm run test # Run Vitest unit tests
npm run test:watch # Run Vitest in watch mode
npm run test:e2e # Run Playwright e2e tests
Coverage Expectations¶
Every non-trivial change must include tests. Coverage goals:
- Unit tests: 80%+ coverage for
lib/,hooks/, Server Actions - E2e tests: Critical user flows (submit, vote, comment, admin triage)
- RLS policies: Explicit tests for authorized and unauthorized access
- Realtime subscriptions: Integration tests for live updates
Flaky tests are treated as defects and must be fixed or quarantined with a follow-up issue.
Unit Testing with Vitest¶
Basic Test Structure¶
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createSubmission } from '@/app/[locale]/submit/actions'
describe('createSubmission', () => {
it('creates a submission with valid input', async () => {
// Arrange
const input = { title: 'Test', description: 'Test description' }
// Act
const result = await createSubmission(input)
// Assert
expect(result.success).toBe(true)
})
it('returns error for invalid input', async () => {
const input = { title: '', description: '' }
const result = await createSubmission(input)
expect(result.error).toBeDefined()
})
})
Mocking Supabase Chains¶
Supabase client methods chain (.from().insert().select()). Use vi.doMock for isolated mocks:
import { vi } from 'vitest'
vi.doMock('@/lib/supabase/server', () => ({
createServerClient: vi.fn(() => ({
from: vi.fn(() => ({
insert: vi.fn(() => ({
select: vi.fn(() => Promise.resolve({ data: mockData, error: null })),
})),
})),
})),
}))
For conditional chains (e.g., .eq() that sometimes returns a Promise):
const mockEq = vi.fn(() => Promise.resolve({ data: null, error: null }))
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Supabase chain workaround
const mockSelect = vi.fn(() => ({ eq: mockEq }))
Use vi.resetModules() between tests to clear mock state.
Testing Error Paths¶
Always test failure scenarios:
it('handles database errors', async () => {
vi.doMock('@/lib/supabase/server', () => ({
createServerClient: vi.fn(() => ({
from: vi.fn(() => ({
insert: vi.fn(() =>
Promise.resolve({ data: null, error: { message: 'DB error' } })
),
})),
})),
}))
const result = await createSubmission(validInput)
expect(result.error).toBe('DB error')
})
End-to-End Testing with Playwright¶
Test Structure¶
import { test, expect } from '@playwright/test'
test('user can submit feedback', async ({ page }) => {
await page.goto('http://localhost:3000/en')
await page.click('text=Submit Feedback')
await page.fill('input[name="title"]', 'Test Submission')
await page.fill('textarea[name="description"]', 'This is a test description.')
await page.click('button[type="submit"]')
await expect(page.locator('text=Submission created')).toBeVisible()
})
Testing Authentication¶
Use Playwright's storageState to persist authentication:
test.use({ storageState: 'e2e/.auth/user.json' })
test('authenticated user can vote', async ({ page }) => {
await page.goto('http://localhost:3000/en')
await page.click('button[aria-label="Vote"]')
await expect(page.locator('text=1 vote')).toBeVisible()
})
Testing Realtime Updates¶
Test live updates with multiple browser contexts:
test('vote count updates in realtime', async ({ browser }) => {
const context1 = await browser.newContext()
const context2 = await browser.newContext()
const page1 = await context1.newPage()
const page2 = await context2.newPage()
await page1.goto('http://localhost:3000/en/submissions/123')
await page2.goto('http://localhost:3000/en/submissions/123')
await page1.click('button[aria-label="Vote"]')
await expect(page2.locator('text=1 vote')).toBeVisible({ timeout: 5000 })
})
Testing RLS Policies¶
RLS policies require explicit test cases for authorized and unauthorized access:
describe('submissions RLS', () => {
it('allows public read access', async () => {
const supabase = createBrowserClient()
const { data, error } = await supabase.from('submissions').select()
expect(error).toBeNull()
expect(data).toBeDefined()
})
it('blocks unauthenticated submissions', async () => {
const supabase = createBrowserClient()
const { error } = await supabase.from('submissions').insert({
title: 'Test',
description: 'Test',
})
expect(error?.message).toContain('permission denied')
})
})
Common Patterns¶
Mocking Next.js Navigation¶
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => '/en',
}))
Mocking next-intl¶
Testing Zod Validation¶
it('validates input with Zod schema', () => {
const result = submissionSchema.safeParse({
title: 'Too short',
description: '',
})
expect(result.success).toBe(false)
expect(result.error?.issues[0].path).toEqual(['title'])
})
CI Integration¶
Tests run automatically in GitHub Actions on every PR. CI must pass before merging:
npm run test(Vitest unit tests)npx tsc --noEmit(Type checking)npm run lint(ESLint)npm run build(Production build)npm run test:e2e(Playwright on Vercel preview)
If CI fails, fix the failures locally and push again. Do not merge with failing CI.
What's next¶
- Understand the pull request process
- Review code style conventions
- Explore Architecture Decision Records