# Testing Strategy and Guide Comprehensive guide to testing Changemaker Lite V2, covering unit tests, integration tests, and end-to-end testing strategies. ## Overview **Current Status:** Phase 15 (Testing + Polish) in progress. Test infrastructure is being implemented. This guide covers: - Testing philosophy and strategy - Test frameworks (Jest, Vitest, React Testing Library) - Writing tests for API and Frontend - Running tests and generating coverage - Testing best practices ## Testing Philosophy ### Test Pyramid ``` /\ /E2E\ ← Few, high-value end-to-end tests /------\ /Integration\ ← Moderate integration tests /------------\ / Unit Tests \ ← Many, fast unit tests /----------------\ ``` **Unit Tests (70%):** - Test individual functions/components - Fast execution (milliseconds) - No external dependencies - Easy to write and maintain **Integration Tests (20%):** - Test multiple units working together - Test API routes with database - Test user flows in frontend - Moderate execution time **End-to-End Tests (10%):** - Test complete user journeys - Test across API and frontend - Slow execution (seconds) - Complex setup ### Testing Principles 1. **Test Behavior, Not Implementation** - Test what the code does, not how it does it - Allows refactoring without breaking tests 2. **Arrange-Act-Assert (AAA) Pattern** - **Arrange:** Set up test data and mocks - **Act:** Execute the code under test - **Assert:** Verify expected behavior 3. **Independent Tests** - Each test runs in isolation - No shared state between tests - Tests can run in any order 4. **Fast Feedback** - Tests run quickly (< 1 second each) - Run tests in watch mode during development - Run full suite in CI/CD 5. **Readable Tests** - Clear test names describing what is tested - Simple setup and assertions - Good error messages when tests fail ## Test Frameworks ### API Testing (Jest) **Framework:** Jest **Location:** `api/src/**/*.test.ts` **Config:** `api/jest.config.js` **Installation:** ```bash cd api npm install --save-dev jest @types/jest ts-jest npm install --save-dev @types/supertest supertest ``` **Configuration (jest.config.js):** ```javascript module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['/src'], testMatch: ['**/*.test.ts'], collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/*.test.ts' ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } } }; ``` ### Frontend Testing (Vitest + React Testing Library) **Framework:** Vitest (Vite-native test runner) **Component Testing:** React Testing Library **Location:** `admin/src/**/*.test.tsx`, `admin/src/**/*.spec.tsx` **Config:** `admin/vitest.config.ts` **Installation:** ```bash cd admin npm install --save-dev vitest @vitest/ui npm install --save-dev @testing-library/react @testing-library/jest-dom npm install --save-dev @testing-library/user-event ``` **Configuration (vitest.config.ts):** ```typescript import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts', coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'src/test/', '**/*.d.ts', '**/*.config.*', '**/mockData' ] } } }); ``` **Setup File (admin/src/test/setup.ts):** ```typescript import '@testing-library/jest-dom'; import { expect, afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; // Cleanup after each test afterEach(() => { cleanup(); }); ``` ## API Testing ### Unit Tests (Service Layer) Test business logic in service files: **Example: api/src/modules/auth/auth.service.test.ts** ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest'; import { AuthService } from './auth.service'; import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; // Mock Prisma vi.mock('@prisma/client'); describe('AuthService', () => { let authService: AuthService; let mockPrisma: any; beforeEach(() => { mockPrisma = { user: { findUnique: vi.fn(), create: vi.fn() } }; authService = new AuthService(mockPrisma); }); describe('login', () => { it('should return tokens for valid credentials', async () => { // Arrange const email = 'test@example.com'; const password = 'Password123!'; const hashedPassword = await bcrypt.hash(password, 10); mockPrisma.user.findUnique.mockResolvedValue({ id: 1, email, password: hashedPassword, role: 'USER' }); // Act const result = await authService.login(email, password); // Assert expect(result).toHaveProperty('accessToken'); expect(result).toHaveProperty('refreshToken'); expect(result.user.email).toBe(email); }); it('should throw error for invalid password', async () => { // Arrange const email = 'test@example.com'; const hashedPassword = await bcrypt.hash('correctpass', 10); mockPrisma.user.findUnique.mockResolvedValue({ id: 1, email, password: hashedPassword, role: 'USER' }); // Act & Assert await expect( authService.login(email, 'wrongpass') ).rejects.toThrow('Invalid credentials'); }); it('should throw error for non-existent user', async () => { // Arrange mockPrisma.user.findUnique.mockResolvedValue(null); // Act & Assert await expect( authService.login('nonexistent@example.com', 'password') ).rejects.toThrow('Invalid credentials'); }); }); describe('register', () => { it('should create new user with hashed password', async () => { // Arrange const email = 'new@example.com'; const password = 'Password123!'; mockPrisma.user.findUnique.mockResolvedValue(null); mockPrisma.user.create.mockResolvedValue({ id: 1, email, role: 'USER' }); // Act const result = await authService.register(email, password); // Assert expect(mockPrisma.user.create).toHaveBeenCalledWith({ data: expect.objectContaining({ email, password: expect.any(String), role: 'USER' }) }); expect(result.user.email).toBe(email); }); it('should throw error if user already exists', async () => { // Arrange mockPrisma.user.findUnique.mockResolvedValue({ id: 1, email: 'existing@example.com' }); // Act & Assert await expect( authService.register('existing@example.com', 'Password123!') ).rejects.toThrow('User already exists'); }); }); }); ``` ### Integration Tests (Routes) Test API endpoints with database: **Example: api/src/modules/auth/auth.routes.test.ts** ```typescript import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; import { app } from '../../server'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); describe('Auth Routes', () => { beforeAll(async () => { // Setup test database await prisma.$connect(); }); afterAll(async () => { // Cleanup await prisma.user.deleteMany(); await prisma.$disconnect(); }); describe('POST /api/auth/register', () => { it('should register new user', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'test@example.com', password: 'Password123!' }) .expect(201); expect(response.body).toHaveProperty('accessToken'); expect(response.body).toHaveProperty('refreshToken'); expect(response.body.user.email).toBe('test@example.com'); }); it('should return 400 for invalid email', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'invalid-email', password: 'Password123!' }) .expect(400); expect(response.body).toHaveProperty('error'); }); it('should return 409 for existing user', async () => { // Create user first await request(app) .post('/api/auth/register') .send({ email: 'existing@example.com', password: 'Password123!' }); // Try to create again const response = await request(app) .post('/api/auth/register') .send({ email: 'existing@example.com', password: 'Password123!' }) .expect(409); expect(response.body.error).toContain('already exists'); }); }); describe('POST /api/auth/login', () => { it('should login with valid credentials', async () => { // Register user first await request(app) .post('/api/auth/register') .send({ email: 'login@example.com', password: 'Password123!' }); // Login const response = await request(app) .post('/api/auth/login') .send({ email: 'login@example.com', password: 'Password123!' }) .expect(200); expect(response.body).toHaveProperty('accessToken'); expect(response.body).toHaveProperty('refreshToken'); }); it('should return 401 for invalid password', async () => { const response = await request(app) .post('/api/auth/login') .send({ email: 'login@example.com', password: 'WrongPassword!' }) .expect(401); expect(response.body.error).toContain('Invalid credentials'); }); }); }); ``` ### Database Testing Use separate test database: **Environment Variable (.env.test):** ```bash DATABASE_URL=postgresql://changemaker_v2:password@localhost:5433/changemaker_v2_test_db ``` **Setup Script (api/src/test/setup.ts):** ```typescript import { PrismaClient } from '@prisma/client'; import { execSync } from 'child_process'; const prisma = new PrismaClient(); export async function setupTestDatabase() { // Apply migrations execSync('npx prisma migrate deploy', { env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL } }); // Clean data await prisma.user.deleteMany(); await prisma.campaign.deleteMany(); // ... delete all tables } export async function teardownTestDatabase() { await prisma.$disconnect(); } ``` ## Frontend Testing ### Component Unit Tests Test individual React components: **Example: admin/src/components/UserCard.test.tsx** ```typescript import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { UserCard } from './UserCard'; describe('UserCard', () => { it('renders user information', () => { const user = { id: 1, email: 'test@example.com', role: 'USER', name: 'Test User' }; render(); expect(screen.getByText('Test User')).toBeInTheDocument(); expect(screen.getByText('test@example.com')).toBeInTheDocument(); expect(screen.getByText('USER')).toBeInTheDocument(); }); it('renders "No name" when name is null', () => { const user = { id: 1, email: 'test@example.com', role: 'USER', name: null }; render(); expect(screen.getByText('No name')).toBeInTheDocument(); }); }); ``` ### Component Integration Tests Test user interactions: **Example: admin/src/pages/LoginPage.test.tsx** ```typescript import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { LoginPage } from './LoginPage'; import { BrowserRouter } from 'react-router-dom'; import * as api from '../lib/api'; // Mock API vi.mock('../lib/api'); describe('LoginPage', () => { it('submits login form with valid credentials', async () => { const user = userEvent.setup(); const mockLogin = vi.spyOn(api, 'login').mockResolvedValue({ accessToken: 'token', refreshToken: 'refresh', user: { id: 1, email: 'test@example.com', role: 'USER' } }); render( ); // Fill form await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.type(screen.getByLabelText(/password/i), 'Password123!'); // Submit await user.click(screen.getByRole('button', { name: /login/i })); // Verify API called await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith({ email: 'test@example.com', password: 'Password123!' }); }); }); it('shows error for invalid credentials', async () => { const user = userEvent.setup(); vi.spyOn(api, 'login').mockRejectedValue( new Error('Invalid credentials') ); render( ); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.type(screen.getByLabelText(/password/i), 'wrong'); await user.click(screen.getByRole('button', { name: /login/i })); await waitFor(() => { expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument(); }); }); it('disables submit button while loading', async () => { const user = userEvent.setup(); vi.spyOn(api, 'login').mockImplementation( () => new Promise(resolve => setTimeout(resolve, 1000)) ); render( ); const submitButton = screen.getByRole('button', { name: /login/i }); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.type(screen.getByLabelText(/password/i), 'Password123!'); await user.click(submitButton); expect(submitButton).toBeDisabled(); }); }); ``` ### Testing Hooks Test custom React hooks: **Example: admin/src/hooks/useDebounce.test.ts** ```typescript import { describe, it, expect, vi } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { useDebounce } from './useDebounce'; describe('useDebounce', () => { it('debounces value changes', async () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 500 } } ); expect(result.current).toBe('initial'); // Change value rerender({ value: 'updated', delay: 500 }); // Value should not change immediately expect(result.current).toBe('initial'); // Wait for debounce await waitFor(() => { expect(result.current).toBe('updated'); }, { timeout: 600 }); }); }); ``` ### Testing Zustand Stores Test state management: **Example: admin/src/stores/auth.store.test.ts** ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useAuthStore } from './auth.store'; describe('Auth Store', () => { beforeEach(() => { // Reset store before each test const { result } = renderHook(() => useAuthStore()); act(() => { result.current.logout(); }); }); it('sets user on login', () => { const { result } = renderHook(() => useAuthStore()); act(() => { result.current.setUser({ id: 1, email: 'test@example.com', role: 'USER' }); }); expect(result.current.user).toEqual({ id: 1, email: 'test@example.com', role: 'USER' }); expect(result.current.isAuthenticated).toBe(true); }); it('clears user on logout', () => { const { result } = renderHook(() => useAuthStore()); act(() => { result.current.setUser({ id: 1, email: 'test@example.com', role: 'USER' }); }); expect(result.current.isAuthenticated).toBe(true); act(() => { result.current.logout(); }); expect(result.current.user).toBeNull(); expect(result.current.isAuthenticated).toBe(false); }); }); ``` ## Running Tests ### Run All Tests ```bash # API tests cd api npm test # Frontend tests cd admin npm test ``` ### Watch Mode Run tests automatically on file changes: ```bash # API tests (Jest watch) cd api npm run test:watch # Frontend tests (Vitest watch) cd admin npm run test:watch ``` ### Run Specific Tests ```bash # Run specific test file npm test -- auth.service.test.ts # Run tests matching pattern npm test -- --testNamePattern="login" # Run tests in specific directory npm test -- src/modules/auth/ ``` ### Coverage Reports Generate test coverage: ```bash # API coverage cd api npm run test:coverage # Frontend coverage cd admin npm run test:coverage ``` **Coverage output:** ``` File | % Stmts | % Branch | % Funcs | % Lines | --------------------|---------|----------|---------|---------| All files | 82.45 | 75.33 | 80.12 | 83.21 | auth/ | 95.23 | 89.47 | 93.75 | 96.15 | auth.service.ts | 97.14 | 91.67 | 100 | 98.21 | auth.routes.ts | 93.33 | 87.50 | 87.50 | 94.12 | ``` **HTML report:** - Located in `coverage/` directory - Open `coverage/index.html` in browser - Shows line-by-line coverage ### CI/CD Testing **GitHub Actions Example:** ```yaml name: Tests on: [push, pull_request] jobs: api-tests: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '20' - name: Install dependencies working-directory: ./api run: npm ci - name: Run migrations working-directory: ./api env: DATABASE_URL: postgresql://postgres:test@localhost:5432/test run: npx prisma migrate deploy - name: Run tests working-directory: ./api run: npm test -- --coverage - name: Upload coverage uses: codecov/codecov-action@v3 with: files: ./api/coverage/coverage-final.json frontend-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '20' - name: Install dependencies working-directory: ./admin run: npm ci - name: Run tests working-directory: ./admin run: npm test -- --coverage - name: Upload coverage uses: codecov/codecov-action@v3 with: files: ./admin/coverage/coverage-final.json ``` ## Mocking ### Mocking API Calls (Frontend) ```typescript // Mock axios vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } })); // Use in test import { api } from '../lib/api'; vi.mocked(api.get).mockResolvedValue({ data: { users: [] } }); ``` ### Mocking Database (Backend) ```typescript // Mock Prisma Client vi.mock('@prisma/client', () => ({ PrismaClient: vi.fn(() => ({ user: { findUnique: vi.fn(), findMany: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() } })) })); ``` ### Mocking External Services ```typescript // Mock email service vi.mock('../../services/email.service', () => ({ EmailService: { sendEmail: vi.fn().mockResolvedValue(true) } })); ``` ### Mocking Environment Variables ```typescript // Set env var for test process.env.JWT_ACCESS_SECRET = 'test-secret'; // Or use vi.stubEnv vi.stubEnv('API_URL', 'http://localhost:4000'); ``` ## Best Practices ### Test Naming Use descriptive test names: **Good:** ```typescript it('should return 401 for expired token', async () => {}); it('should create user with hashed password', async () => {}); it('should render error message for invalid email', () => {}); ``` **Bad:** ```typescript it('works', async () => {}); it('test login', async () => {}); it('should work correctly', () => {}); ``` ### Test Organization Group related tests: ```typescript describe('AuthService', () => { describe('login', () => { it('should return tokens for valid credentials', () => {}); it('should throw error for invalid password', () => {}); it('should throw error for non-existent user', () => {}); }); describe('register', () => { it('should create new user', () => {}); it('should hash password', () => {}); it('should throw error if user exists', () => {}); }); }); ``` ### Setup and Teardown Use beforeEach/afterEach for common setup: ```typescript describe('UserService', () => { let userService: UserService; let mockPrisma: any; beforeEach(() => { mockPrisma = createMockPrisma(); userService = new UserService(mockPrisma); }); afterEach(() => { vi.clearAllMocks(); }); it('...', () => {}); }); ``` ### Avoid Test Interdependence Each test should be independent: **Good:** ```typescript it('should create user', async () => { const user = await createUser({ email: 'test@example.com' }); expect(user.email).toBe('test@example.com'); }); it('should update user', async () => { const user = await createUser({ email: 'test@example.com' }); const updated = await updateUser(user.id, { name: 'New Name' }); expect(updated.name).toBe('New Name'); }); ``` **Bad:** ```typescript let userId; it('should create user', async () => { const user = await createUser({ email: 'test@example.com' }); userId = user.id; // ❌ Shared state }); it('should update user', async () => { const updated = await updateUser(userId, { name: 'New Name' }); // ❌ Depends on previous test }); ``` ### Test Edge Cases Test boundary conditions: ```typescript describe('pagination', () => { it('should handle page 1', () => {}); it('should handle last page', () => {}); it('should handle empty results', () => {}); it('should handle invalid page number', () => {}); it('should handle page exceeding total', () => {}); }); ``` ### Async Testing Always use async/await for async tests: **Good:** ```typescript it('should fetch users', async () => { const users = await userService.getUsers(); expect(users).toHaveLength(10); }); ``` **Bad:** ```typescript it('should fetch users', () => { userService.getUsers().then(users => { expect(users).toHaveLength(10); // ❌ May not run }); }); ``` ## Coverage Requirements Target coverage thresholds: ```javascript // jest.config.js / vitest.config.ts coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } } ``` **What to test:** - ✅ Business logic (services) - ✅ API routes - ✅ UI components - ✅ Custom hooks - ✅ Utilities - ❌ Type definitions - ❌ Config files - ❌ Test files themselves ## Troubleshooting ### Tests Timing Out **Problem:** Tests exceed timeout. **Solution:** ```typescript // Increase timeout for specific test it('slow operation', async () => { // ... }, 10000); // 10 second timeout // Or globally (vitest.config.ts) export default defineConfig({ test: { testTimeout: 10000 } }); ``` ### Mocks Not Working **Problem:** Mocks not being used. **Solution:** ```typescript // Mock must be at top of file, before imports vi.mock('../lib/api'); import { api } from '../lib/api'; // Verify mock is being used console.log(vi.isMockFunction(api.get)); // Should be true ``` ### Database Connection Errors **Problem:** Tests fail with DB connection errors. **Solution:** ```typescript // Use separate test database process.env.DATABASE_URL = 'postgresql://localhost/test_db'; // Or mock database entirely vi.mock('@prisma/client'); ``` ### React Testing Library Queries Failing **Problem:** `screen.getByText()` doesn't find element. **Solution:** ```typescript // Use findBy for async elements const element = await screen.findByText('Loading...'); // Use queryBy to check non-existence expect(screen.queryByText('Error')).not.toBeInTheDocument(); // Debug rendered output screen.debug(); ``` ## Related Documentation - **Setup:** [Local Development Setup](local-setup.md) - **Code Style:** [Code Style Guide](code-style.md) - **TypeScript:** [TypeScript Guide](typescript.md) - **Debugging:** [Debugging Guide](debugging.md) - **CI/CD:** [Deployment Guide](../deployment/production.md) ## Summary You now know: - ✅ Testing philosophy (test pyramid, AAA pattern) - ✅ Test frameworks (Jest, Vitest, React Testing Library) - ✅ How to write unit tests (services, components) - ✅ How to write integration tests (routes, user flows) - ✅ How to run tests and generate coverage - ✅ How to mock dependencies - ✅ Testing best practices - ✅ How to integrate tests in CI/CD **Quick Start:** ```bash # Install dependencies (when Phase 15 complete) cd api && npm install --save-dev jest @types/jest ts-jest cd admin && npm install --save-dev vitest @vitest/ui # Run tests npm test # Watch mode npm run test:watch # Coverage npm run test:coverage ```