Skip to content

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

vi.mock('next-intl', () => ({
  useTranslations: () => (key: string) => key,
}))

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:

  1. npm run test (Vitest unit tests)
  2. npx tsc --noEmit (Type checking)
  3. npm run lint (ESLint)
  4. npm run build (Production build)
  5. 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