Testing Guide
Comprehensive guide to writing and running tests in Izri
๐ Overview
This guide covers testing strategies, tools, and best practices for Izri. Learn how to write unit tests, integration tests, and end-to-end tests for both frontend and backend.
๐ฏ Testing Philosophy
Our Approach
- โ Test behavior, not implementation
- โ Write tests that provide confidence
- โ Test user interactions, not internals
- โ Keep tests simple and readable
- โ Fast feedback loops
Testing Pyramid
/\
/ \ E2E Tests (Few)
/____\ - Full user flows
/ \ - Critical paths
/________\
/ \ Integration Tests (Some)
/____________\ - API + Database
/ \ - Component + Data
/________________\
/ \ Unit Tests (Many)
/____________________\ - Pure functions
- Utilities
- Business logic
๐ ๏ธ Testing Stack
Frontend
| Tool | Purpose |
|---|---|
| Vitest | Test runner (fast, Vite-based) |
| Testing Library | React component testing |
| MSW | API mocking |
| Playwright | E2E testing |
Backend
| Tool | Purpose |
|---|---|
| Vitest | Test runner |
| Supertest | HTTP testing |
| Testcontainers | Database testing |
๐ Running Tests
Quick Start
# Run all tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Run tests for specific package
pnpm test --filter @izri/web
pnpm test --filter @izri/api
# Run with coverage
pnpm test:coverage
Test Commands
// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
๐งช Unit Testing
Testing Utilities
Example: Testing a utility function
// packages/shared/src/utils/format.ts
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
Test file: packages/shared/src/utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatDate, slugify } from './format'
describe('formatDate', () => {
it('formats date in US format', () => {
const date = new Date('2025-01-10')
expect(formatDate(date)).toBe('January 10, 2025')
})
it('handles leap years', () => {
const date = new Date('2024-02-29')
expect(formatDate(date)).toBe('February 29, 2024')
})
})
describe('slugify', () => {
it('converts text to lowercase slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('removes special characters', () => {
expect(slugify('Hello@World!')).toBe('hello-world')
})
it('handles multiple spaces', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('trims leading and trailing dashes', () => {
expect(slugify('-Hello World-')).toBe('hello-world')
})
})
Run tests:
cd packages/shared
pnpm test
Testing Validation Schemas
Example: Zod schema
// packages/shared/src/schemas/project.ts
import { z } from 'zod'
export const createProjectSchema = z.object({
name: z.string().min(1).max(255),
repository: z.string().url(),
branch: z.string().default('main'),
language: z.enum(['typescript', 'javascript', 'python']),
})
export type CreateProjectInput = z.infer<typeof createProjectSchema>
Test file:
// packages/shared/src/schemas/project.test.ts
import { describe, it, expect } from 'vitest'
import { createProjectSchema } from './project'
describe('createProjectSchema', () => {
it('validates correct input', () => {
const input = {
name: 'My Project',
repository: 'https://github.com/user/repo',
branch: 'main',
language: 'typescript',
}
expect(() => createProjectSchema.parse(input)).not.toThrow()
})
it('rejects empty name', () => {
const input = {
name: '',
repository: 'https://github.com/user/repo',
language: 'typescript',
}
expect(() => createProjectSchema.parse(input)).toThrow()
})
it('rejects invalid URL', () => {
const input = {
name: 'Project',
repository: 'not-a-url',
language: 'typescript',
}
expect(() => createProjectSchema.parse(input)).toThrow()
})
it('defaults branch to main', () => {
const input = {
name: 'Project',
repository: 'https://github.com/user/repo',
language: 'typescript',
}
const result = createProjectSchema.parse(input)
expect(result.branch).toBe('main')
})
})
โ๏ธ React Component Testing
Setup
Install dependencies:
cd apps/web
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event
Vitest config: apps/web/vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './test/setup.ts',
},
})
Test setup: apps/web/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock environment variables
process.env.VITE_API_URL = 'http://localhost:4000'
Testing Components
Example component: apps/web/app/components/ui/button.tsx
import { type ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
export function Button({
variant = 'primary',
size = 'md',
className,
children,
...props
}: ButtonProps) {
return (
<button
className={cn(
'rounded font-medium',
variant === 'primary' && 'bg-blue-500 text-white',
variant === 'secondary' && 'bg-gray-200 text-gray-900',
variant === 'danger' && 'bg-red-500 text-white',
size === 'sm' && 'px-2 py-1 text-sm',
size === 'md' && 'px-4 py-2',
size === 'lg' && 'px-6 py-3 text-lg',
className
)}
{...props}
>
{children}
</button>
)
}
Test file: apps/web/app/components/ui/button.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('applies primary variant by default', () => {
render(<Button>Primary</Button>)
const button = screen.getByText('Primary')
expect(button).toHaveClass('bg-blue-500')
})
it('applies secondary variant', () => {
render(<Button variant="secondary">Secondary</Button>)
const button = screen.getByText('Secondary')
expect(button).toHaveClass('bg-gray-200')
})
it('applies danger variant', () => {
render(<Button variant="danger">Danger</Button>)
const button = screen.getByText('Danger')
expect(button).toHaveClass('bg-red-500')
})
it('handles click events', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick}>Click</Button>)
await user.click(screen.getByText('Click'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('can be disabled', () => {
render(<Button disabled>Disabled</Button>)
expect(screen.getByText('Disabled')).toBeDisabled()
})
})
Testing with tRPC
Mock tRPC client:
// apps/web/test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { createTRPCReact } from '@trpc/react-query'
import { type ReactNode } from 'react'
import type { AppRouter } from '@izri/trpc'
export const trpc = createTRPCReact<AppRouter>()
export function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:4000/trpc',
}),
],
})
return function Wrapper({ children }: { children: ReactNode }) {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
)
}
}
Test component with tRPC:
// apps/web/app/components/projects/project-list.test.tsx
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { ProjectList } from './project-list'
import { createWrapper } from '../../../test/utils'
// Mock API responses
const server = setupServer(
http.post('http://localhost:4000/trpc/projects.list', () => {
return HttpResponse.json({
result: {
data: [
{ id: '1', name: 'Project 1', repository: 'https://github.com/a/b' },
{ id: '2', name: 'Project 2', repository: 'https://github.com/c/d' },
],
},
})
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('ProjectList', () => {
it('renders loading state', () => {
render(<ProjectList />, { wrapper: createWrapper() })
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('renders projects', async () => {
render(<ProjectList />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText('Project 1')).toBeInTheDocument()
expect(screen.getByText('Project 2')).toBeInTheDocument()
})
})
it('handles error state', async () => {
server.use(
http.post('http://localhost:4000/trpc/projects.list', () => {
return HttpResponse.json({ error: 'Failed' }, { status: 500 })
})
)
render(<ProjectList />, { wrapper: createWrapper() })
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
})
๐ Backend Testing
tRPC Procedure Testing
Example procedure:
// packages/trpc/src/routers/projects.ts
import { router, protectedProcedure } from '../trpc'
import { z } from 'zod'
import { projects } from '@izri/database/schema'
export const projectRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db
.select()
.from(projects)
.where(eq(projects.userId, ctx.userId))
}),
create: protectedProcedure
.input(z.object({
name: z.string(),
repository: z.string().url(),
}))
.mutation(async ({ ctx, input }) => {
const [project] = await ctx.db
.insert(projects)
.values({
...input,
userId: ctx.userId,
})
.returning()
return project
}),
})
Test file:
// packages/trpc/src/routers/projects.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { appRouter } from '../router'
import { createTestContext } from '../../test/utils'
describe('projectRouter', () => {
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(async () => {
await ctx.cleanup()
})
describe('list', () => {
it('returns user projects', async () => {
const caller = appRouter.createCaller(ctx)
// Create test projects
await caller.projects.create({
name: 'Project 1',
repository: 'https://github.com/a/b',
})
await caller.projects.create({
name: 'Project 2',
repository: 'https://github.com/c/d',
})
// Query projects
const projects = await caller.projects.list()
expect(projects).toHaveLength(2)
expect(projects[0].name).toBe('Project 1')
expect(projects[1].name).toBe('Project 2')
})
it('only returns current user projects', async () => {
const caller = appRouter.createCaller(ctx)
// Create project for current user
await caller.projects.create({
name: 'My Project',
repository: 'https://github.com/a/b',
})
// Create context for different user
const otherCtx = await createTestContext({ userId: 'other-user' })
const otherCaller = appRouter.createCaller(otherCtx)
const projects = await otherCaller.projects.list()
expect(projects).toHaveLength(0)
await otherCtx.cleanup()
})
})
describe('create', () => {
it('creates a project', async () => {
const caller = appRouter.createCaller(ctx)
const project = await caller.projects.create({
name: 'New Project',
repository: 'https://github.com/user/repo',
})
expect(project).toMatchObject({
name: 'New Project',
repository: 'https://github.com/user/repo',
userId: ctx.userId,
})
expect(project.id).toBeDefined()
})
it('validates input', async () => {
const caller = appRouter.createCaller(ctx)
await expect(
caller.projects.create({
name: '',
repository: 'not-a-url',
})
).rejects.toThrow()
})
})
})
Test Context Setup
// packages/trpc/test/utils.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from '@izri/database/schema'
export async function createTestContext(overrides?: Partial<Context>) {
// Use test database
const connection = postgres(process.env.TEST_DATABASE_URL!)
const db = drizzle(connection, { schema })
const userId = overrides?.userId || 'test-user-123'
const context = {
db,
redis: null, // Mock Redis if needed
userId,
...overrides,
}
const cleanup = async () => {
// Clean up test data
await db.delete(schema.projects).where(eq(schema.projects.userId, userId))
await connection.end()
}
return { ...context, cleanup }
}
Database Integration Tests
Using Testcontainers:
// packages/database/test/integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { PostgreSqlContainer } from '@testcontainers/postgresql'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import * as schema from '../src/schema'
describe('Database Integration', () => {
let container: PostgreSqlContainer
let db: ReturnType<typeof drizzle>
beforeAll(async () => {
// Start PostgreSQL container
container = await new PostgreSqlContainer().start()
const connection = postgres(container.getConnectionUri())
db = drizzle(connection, { schema })
// Run migrations
await migrate(db, { migrationsFolder: './migrations' })
}, 60000) // Increase timeout for container startup
afterAll(async () => {
await container.stop()
})
it('creates and retrieves projects', async () => {
const [project] = await db
.insert(schema.projects)
.values({
name: 'Test Project',
repository: 'https://github.com/test/repo',
userId: 'user-123',
})
.returning()
expect(project.name).toBe('Test Project')
const retrieved = await db
.select()
.from(schema.projects)
.where(eq(schema.projects.id, project.id))
expect(retrieved[0]).toMatchObject(project)
})
})
๐ E2E Testing with Playwright
Setup
Install Playwright:
cd apps/web
pnpm add -D @playwright/test
pnpm exec playwright install
Config: apps/web/playwright.config.ts
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,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})
Writing E2E Tests
// apps/web/e2e/projects.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Projects', () => {
test('user can create a project', async ({ page }) => {
// Navigate to projects page
await page.goto('/projects')
// Click "New Project" button
await page.click('text=New Project')
// Fill form
await page.fill('input[name="name"]', 'My Test Project')
await page.fill('input[name="repository"]', 'https://github.com/test/repo')
await page.selectOption('select[name="language"]', 'typescript')
// Submit
await page.click('button[type="submit"]')
// Verify redirect to project page
await expect(page).toHaveURL(/\/projects\/[a-z0-9-]+/)
// Verify project details
await expect(page.locator('h1')).toContainText('My Test Project')
})
test('user can view project list', async ({ page }) => {
await page.goto('/projects')
// Wait for projects to load
await page.waitForSelector('[data-testid="project-card"]')
// Verify at least one project exists
const projects = page.locator('[data-testid="project-card"]')
await expect(projects).not.toHaveCount(0)
})
test('user can delete a project', async ({ page }) => {
await page.goto('/projects')
// Click first project
await page.click('[data-testid="project-card"]:first-child')
// Click delete button
await page.click('text=Delete Project')
// Confirm deletion
await page.click('text=Confirm')
// Verify redirect to projects list
await expect(page).toHaveURL('/projects')
// Verify toast message
await expect(page.locator('[role="alert"]')).toContainText('Project deleted')
})
})
Run E2E tests:
# Run all E2E tests
pnpm exec playwright test
# Run in UI mode
pnpm exec playwright test --ui
# Run specific test
pnpm exec playwright test projects.spec.ts
# Debug mode
pnpm exec playwright test --debug
๐ Test Coverage
Generating Coverage
# Run tests with coverage
pnpm test:coverage
# View coverage report
open coverage/index.html
Coverage Goals
- Unit tests: > 80% coverage
- Integration tests: Critical paths covered
- E2E tests: Happy paths covered
Vitest Coverage Config
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'test/',
'**/*.test.ts',
'**/*.spec.ts',
],
},
},
})
โ Best Practices
Do's
- โ Test user behavior, not implementation
- โ
Use descriptive test names:
it('creates project with valid input') - โ Arrange-Act-Assert pattern
- โ One assertion per test (when possible)
- โ Clean up after tests (database, mocks)
- โ Use test data builders for complex objects
- โ Mock external dependencies
Don'ts
- โ Don't test implementation details
- โ Don't write flaky tests
- โ Don't test third-party libraries
- โ Don't share state between tests
- โ Don't skip tests (fix or remove them)
Test Structure
describe('Feature', () => {
// Setup
beforeEach(() => {
// Arrange
})
it('does something when condition', () => {
// Arrange
const input = createTestData()
// Act
const result = functionUnderTest(input)
// Assert
expect(result).toBe(expected)
})
// Cleanup
afterEach(() => {
cleanup()
})
})
๐ Related Documentation
- Debugging - Debugging techniques
- Running Services - Service management
- Local Setup - IDE configuration
Need help with testing? Ask in team chat or check examples in the codebase!