1130 lines
25 KiB
Markdown

# 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: ['<rootDir>/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(<UserCard user={user} />);
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(<UserCard user={user} />);
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(
<BrowserRouter>
<LoginPage />
</BrowserRouter>
);
// 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(
<BrowserRouter>
<LoginPage />
</BrowserRouter>
);
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(
<BrowserRouter>
<LoginPage />
</BrowserRouter>
);
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
```