1130 lines
25 KiB
Markdown
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
|
|
```
|