Docs /development/testing

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


Need help with testing? Ask in team chat or check examples in the codebase!

Reading this with an agent? /docs/development/testing.md serves the raw markdown.

All docs