- Added request/response flow diagrams to api-design and api-gateway-advanced skills for better visualization of processes. - Introduced configuration loading flow in configuration-management skill to clarify the configuration process. - Included error propagation flow in error-handling-patterns skill to illustrate error handling across layers. - Enhanced various skills with additional diagrams to improve understanding of complex concepts. These updates aim to provide clearer guidance and improve the overall documentation experience for developers.
589 lines
14 KiB
Markdown
589 lines
14 KiB
Markdown
---
|
|
name: testing-patterns
|
|
description: Testing best practices for GoodGo microservices. Use when writing unit tests, integration tests, E2E tests, setting up Jest, mocking dependencies, or debugging test failures.
|
|
---
|
|
|
|
# Testing Patterns for GoodGo Microservices
|
|
|
|
## When to Use This Skill
|
|
|
|
Use this skill when:
|
|
- Writing unit tests for services, controllers, or repositories
|
|
- Creating integration tests for middleware chains
|
|
- Building E2E tests for API endpoints
|
|
- Setting up Jest configuration for a new service
|
|
- Mocking external dependencies (Prisma, Redis, Auth SDK)
|
|
- Debugging test failures
|
|
- Improving test coverage
|
|
|
|
## Core Concepts
|
|
|
|
### Test Types
|
|
|
|
1. **Unit Tests**: Test individual functions/classes in isolation
|
|
- Location: Next to source files (`*.test.ts`)
|
|
- Scope: Single function or class
|
|
- Dependencies: Mocked
|
|
- Speed: Fast (<1s per test)
|
|
|
|
2. **Integration Tests**: Test component interactions
|
|
- Location: `__tests__/` directory
|
|
- Scope: Multiple components working together
|
|
- Dependencies: Some real, some mocked
|
|
- Speed: Medium (1-5s per test)
|
|
|
|
3. **E2E Tests**: Test complete request/response cycles
|
|
- Location: `__tests__/*.e2e.ts`
|
|
- Scope: Full API workflow
|
|
- Dependencies: Test database, mocked external services
|
|
- Speed: Slow (5-10s per test)
|
|
|
|
### Testing Pyramid
|
|
|
|
The testing pyramid illustrates the recommended distribution of test types. Most tests should be fast unit tests, with fewer integration tests, and the fewest E2E tests.
|
|
|
|
```mermaid
|
|
graph TD
|
|
subgraph pyramid["Testing Pyramid"]
|
|
E2E["E2E Tests<br/>Fewest<br/>5-10s each<br/>Full stack"]
|
|
Integration["Integration Tests<br/>Medium<br/>1-5s each<br/>Components"]
|
|
Unit["Unit Tests<br/>Most<br/><1s each<br/>Isolated"]
|
|
end
|
|
|
|
Unit --> Integration
|
|
Integration --> E2E
|
|
|
|
style E2E fill:#e1f5ff
|
|
style Integration fill:#b3e5fc
|
|
style Unit fill:#81d4fa
|
|
```
|
|
|
|
**Key Principles:**
|
|
- **Unit Tests (Base)**: Many fast tests covering individual functions/classes
|
|
- **Integration Tests (Middle)**: Fewer tests verifying component interactions
|
|
- **E2E Tests (Top)**: Fewest tests validating complete workflows
|
|
|
|
## Jest Configuration
|
|
|
|
```typescript
|
|
// jest.config.ts
|
|
import type { Config } from 'jest';
|
|
|
|
const config: Config = {
|
|
preset: 'ts-jest',
|
|
testEnvironment: 'node',
|
|
roots: ['<rootDir>/src'],
|
|
testMatch: [
|
|
'**/__tests__/**/*.test.ts',
|
|
'**/__tests__/**/*.e2e.ts',
|
|
'**/?(*.)+(spec|test).ts'
|
|
],
|
|
collectCoverageFrom: [
|
|
'src/**/*.ts',
|
|
'!src/**/*.d.ts',
|
|
'!src/main.ts',
|
|
'!src/config/**/*.ts'
|
|
],
|
|
coverageDirectory: 'coverage',
|
|
coverageReporters: ['text', 'lcov', 'html'],
|
|
coverageThreshold: {
|
|
global: {
|
|
branches: 70,
|
|
functions: 70,
|
|
lines: 70,
|
|
statements: 70
|
|
}
|
|
},
|
|
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setupTests.ts'],
|
|
testTimeout: 10000,
|
|
clearMocks: true
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
## Setup Files
|
|
|
|
```typescript
|
|
// src/__tests__/setupTests.ts
|
|
import { mockDeep, mockReset } from 'jest-mock-extended';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
// Mock Prisma
|
|
jest.mock('../prisma', () => ({
|
|
__esModule: true,
|
|
default: mockDeep<PrismaClient>()
|
|
}));
|
|
|
|
// Mock Redis
|
|
jest.mock('ioredis', () => {
|
|
const Redis = jest.requireActual('ioredis-mock');
|
|
return Redis;
|
|
});
|
|
|
|
// Global test utilities
|
|
global.testUtils = {
|
|
generateId: () => `test-${Date.now()}`,
|
|
createMockRequest: () => ({
|
|
headers: {},
|
|
body: {},
|
|
query: {},
|
|
params: {}
|
|
}),
|
|
createMockResponse: () => {
|
|
const res: any = {};
|
|
res.status = jest.fn().mockReturnValue(res);
|
|
res.json = jest.fn().mockReturnValue(res);
|
|
res.send = jest.fn().mockReturnValue(res);
|
|
return res;
|
|
}
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
```
|
|
|
|
### Test Execution Flow
|
|
|
|
The test execution flow shows the lifecycle hooks and execution order in Jest test suites.
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
Start([Test Suite Starts]) --> BeforeAll[beforeAll<br/>Run once before all tests]
|
|
BeforeAll --> BeforeEach[beforeEach<br/>Run before each test]
|
|
BeforeEach --> Test[Execute Test<br/>Arrange-Act-Assert]
|
|
Test --> AfterEach[afterEach<br/>Run after each test]
|
|
AfterEach --> MoreTests{More Tests?}
|
|
MoreTests -->|Yes| BeforeEach
|
|
MoreTests -->|No| AfterAll[afterAll<br/>Run once after all tests]
|
|
AfterAll --> End([Test Suite Ends])
|
|
|
|
style Start fill:#c8e6c9
|
|
style End fill:#c8e6c9
|
|
style Test fill:#fff9c4
|
|
style BeforeAll fill:#e1bee7
|
|
style AfterAll fill:#e1bee7
|
|
style BeforeEach fill:#bbdefb
|
|
style AfterEach fill:#bbdefb
|
|
```
|
|
|
|
**Execution Order:**
|
|
1. `beforeAll`: Setup that runs once for the entire suite (e.g., database connection)
|
|
2. `beforeEach`: Setup that runs before each test (e.g., reset mocks, create test data)
|
|
3. **Test Execution**: The actual test code following AAA pattern
|
|
4. `afterEach`: Cleanup after each test (e.g., clear mocks, reset state)
|
|
5. `afterAll`: Final cleanup after all tests (e.g., close database connection)
|
|
|
|
## Testing Patterns
|
|
|
|
### Unit Test Pattern
|
|
|
|
```typescript
|
|
// feature.service.test.ts
|
|
import { FeatureService } from './feature.service';
|
|
import { mockDeep } from 'jest-mock-extended';
|
|
|
|
describe('FeatureService', () => {
|
|
let service: FeatureService;
|
|
let mockRepository: any;
|
|
|
|
beforeEach(() => {
|
|
mockRepository = {
|
|
findById: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
delete: jest.fn()
|
|
};
|
|
service = new FeatureService(mockRepository);
|
|
});
|
|
|
|
describe('findById', () => {
|
|
it('should return feature when found', async () => {
|
|
// Arrange
|
|
const mockFeature = { id: '1', name: 'Test Feature' };
|
|
mockRepository.findById.mockResolvedValue(mockFeature);
|
|
|
|
// Act
|
|
const result = await service.findById('1');
|
|
|
|
// Assert
|
|
expect(result).toEqual(mockFeature);
|
|
expect(mockRepository.findById).toHaveBeenCalledWith('1');
|
|
});
|
|
|
|
it('should throw error when feature not found', async () => {
|
|
// Arrange
|
|
mockRepository.findById.mockResolvedValue(null);
|
|
|
|
// Act & Assert
|
|
await expect(service.findById('999')).rejects.toThrow('Feature not found');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Integration Test Pattern
|
|
|
|
```typescript
|
|
// auth.middleware.test.ts
|
|
import { authMiddleware } from '../auth.middleware';
|
|
import { createMockRequest, createMockResponse } from '../../test-utils';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
describe('Auth Middleware', () => {
|
|
const mockNext = jest.fn();
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should call next when valid token provided', async () => {
|
|
// Arrange
|
|
const req = createMockRequest();
|
|
const res = createMockResponse();
|
|
const token = jwt.sign({ userId: '123' }, 'secret');
|
|
req.headers.authorization = `Bearer ${token}`;
|
|
|
|
// Act
|
|
await authMiddleware(req, res, mockNext);
|
|
|
|
// Assert
|
|
expect(mockNext).toHaveBeenCalled();
|
|
expect(req.user).toEqual({ userId: '123' });
|
|
});
|
|
|
|
it('should return 401 when no token provided', async () => {
|
|
// Arrange
|
|
const req = createMockRequest();
|
|
const res = createMockResponse();
|
|
|
|
// Act
|
|
await authMiddleware(req, res, mockNext);
|
|
|
|
// Assert
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(mockNext).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
```
|
|
|
|
### E2E Test Pattern
|
|
|
|
```typescript
|
|
// feature.e2e.ts
|
|
import supertest from 'supertest';
|
|
import { createApp } from '../app';
|
|
import { prisma } from '../prisma';
|
|
|
|
describe('Feature API E2E', () => {
|
|
let app: any;
|
|
let request: any;
|
|
|
|
beforeAll(async () => {
|
|
app = await createApp();
|
|
request = supertest(app);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await prisma.feature.deleteMany();
|
|
});
|
|
|
|
describe('POST /api/features', () => {
|
|
it('should create a new feature', async () => {
|
|
// Arrange
|
|
const featureData = {
|
|
name: 'New Feature',
|
|
description: 'Feature description'
|
|
};
|
|
|
|
// Act
|
|
const response = await request
|
|
.post('/api/features')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.send(featureData)
|
|
.expect(201);
|
|
|
|
// Assert
|
|
expect(response.body).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
name: 'New Feature',
|
|
description: 'Feature description'
|
|
}
|
|
});
|
|
|
|
const created = await prisma.feature.findFirst({
|
|
where: { name: 'New Feature' }
|
|
});
|
|
expect(created).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
## Mocking Strategies
|
|
|
|
### Mocking Strategy by Test Type
|
|
|
|
Different test types require different levels of mocking. This diagram shows what should be mocked at each test level.
|
|
|
|
```mermaid
|
|
graph TB
|
|
subgraph UnitTests["Unit Tests"]
|
|
UnitCode[Application Code]
|
|
UnitMock[All Dependencies Mocked<br/>- Database<br/>- External APIs<br/>- Services<br/>- Repositories]
|
|
UnitCode --> UnitMock
|
|
end
|
|
|
|
subgraph IntegrationTests["Integration Tests"]
|
|
IntCode[Application Code]
|
|
IntReal[Real Internal Components]
|
|
IntMock[External Dependencies Mocked<br/>- External APIs<br/>- Third-party Services]
|
|
IntCode --> IntReal
|
|
IntReal --> IntMock
|
|
end
|
|
|
|
subgraph E2ETests["E2E Tests"]
|
|
E2ECode[Application Code]
|
|
E2EReal[Real Internal Stack<br/>- Database<br/>- Services<br/>- Middleware]
|
|
E2EMock[Only External Services Mocked<br/>- Payment APIs<br/>- Email Services<br/>- Third-party APIs]
|
|
E2ECode --> E2EReal
|
|
E2EReal --> E2EMock
|
|
end
|
|
|
|
style UnitTests fill:#ffcdd2
|
|
style IntegrationTests fill:#fff9c4
|
|
style E2ETests fill:#c8e6c9
|
|
style UnitMock fill:#f8bbd0
|
|
style IntMock fill:#ffe082
|
|
style E2EMock fill:#a5d6a7
|
|
```
|
|
|
|
**Mocking Guidelines:**
|
|
- **Unit Tests**: Mock all external dependencies to test logic in isolation
|
|
- **Integration Tests**: Use real internal components, mock only external services
|
|
- **E2E Tests**: Use real database and internal stack, mock only third-party external services
|
|
|
|
### Mock Prisma
|
|
|
|
```typescript
|
|
// __mocks__/prisma.ts
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';
|
|
|
|
export const prismaMock = mockDeep<PrismaClient>();
|
|
|
|
jest.mock('../src/prisma', () => ({
|
|
__esModule: true,
|
|
default: prismaMock,
|
|
}));
|
|
|
|
// Usage in tests
|
|
import { prismaMock } from '../__mocks__/prisma';
|
|
|
|
test('should create user', async () => {
|
|
const user = { id: '1', email: 'test@example.com' };
|
|
prismaMock.user.create.mockResolvedValue(user);
|
|
|
|
const result = await createUser({ email: 'test@example.com' });
|
|
expect(result).toEqual(user);
|
|
});
|
|
```
|
|
|
|
### Mock Redis
|
|
|
|
```typescript
|
|
// __mocks__/redis.ts
|
|
import Redis from 'ioredis-mock';
|
|
|
|
export const redisMock = new Redis();
|
|
|
|
// Usage in tests
|
|
test('should cache value', async () => {
|
|
const cache = new CacheService(redisMock);
|
|
await cache.set('key', 'value');
|
|
|
|
const result = await cache.get('key');
|
|
expect(result).toBe('value');
|
|
});
|
|
```
|
|
|
|
### Mock External APIs
|
|
|
|
```typescript
|
|
// Mock axios
|
|
jest.mock('axios');
|
|
import axios from 'axios';
|
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
|
|
test('should fetch external data', async () => {
|
|
mockedAxios.get.mockResolvedValue({
|
|
data: { result: 'success' }
|
|
});
|
|
|
|
const result = await fetchExternalData();
|
|
expect(result).toEqual({ result: 'success' });
|
|
});
|
|
```
|
|
|
|
## Testing Utilities
|
|
|
|
```typescript
|
|
// test-utils.ts
|
|
export class TestFactory {
|
|
static createUser(overrides = {}) {
|
|
return {
|
|
id: 'test-user-1',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
createdAt: new Date(),
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
static createAuthToken(userId: string) {
|
|
return jwt.sign({ userId }, 'test-secret');
|
|
}
|
|
|
|
static async cleanDatabase() {
|
|
await prisma.user.deleteMany();
|
|
await prisma.feature.deleteMany();
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
const user = TestFactory.createUser({ name: 'Custom Name' });
|
|
const token = TestFactory.createAuthToken(user.id);
|
|
```
|
|
|
|
## Common Test Scenarios
|
|
|
|
### Testing Error Handling
|
|
|
|
```typescript
|
|
test('should handle database errors gracefully', async () => {
|
|
prismaMock.user.findUnique.mockRejectedValue(
|
|
new Error('Database connection failed')
|
|
);
|
|
|
|
const response = await request
|
|
.get('/api/users/123')
|
|
.expect(500);
|
|
|
|
expect(response.body).toEqual({
|
|
success: false,
|
|
error: {
|
|
code: 'INTERNAL_ERROR',
|
|
message: 'Internal server error'
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Validation
|
|
|
|
```typescript
|
|
describe('Validation', () => {
|
|
it('should reject invalid email', async () => {
|
|
const response = await request
|
|
.post('/api/auth/register')
|
|
.send({ email: 'invalid-email', password: '123456' })
|
|
.expect(400);
|
|
|
|
expect(response.body.error.code).toBe('VALIDATION_ERROR');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Pagination
|
|
|
|
```typescript
|
|
test('should paginate results', async () => {
|
|
// Create test data
|
|
const items = Array(25).fill(null).map((_, i) => ({
|
|
id: `item-${i}`,
|
|
name: `Item ${i}`
|
|
}));
|
|
|
|
prismaMock.item.findMany.mockResolvedValue(items.slice(0, 10));
|
|
prismaMock.item.count.mockResolvedValue(25);
|
|
|
|
const response = await request
|
|
.get('/api/items?page=1&limit=10')
|
|
.expect(200);
|
|
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
data: items.slice(0, 10),
|
|
pagination: {
|
|
page: 1,
|
|
limit: 10,
|
|
total: 25,
|
|
totalPages: 3
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
## Test Commands
|
|
|
|
```json
|
|
// package.json
|
|
{
|
|
"scripts": {
|
|
"test": "jest",
|
|
"test:watch": "jest --watch",
|
|
"test:coverage": "jest --coverage",
|
|
"test:unit": "jest --testPathPattern=\\.test\\.ts$",
|
|
"test:e2e": "jest --testPathPattern=\\.e2e\\.ts$",
|
|
"test:ci": "jest --coverage --silent --maxWorkers=2"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Debugging Tests
|
|
|
|
### Debug with VS Code
|
|
|
|
```json
|
|
// .vscode/launch.json
|
|
{
|
|
"configurations": [
|
|
{
|
|
"type": "node",
|
|
"request": "launch",
|
|
"name": "Debug Jest Tests",
|
|
"runtimeExecutable": "npm",
|
|
"runtimeArgs": ["test", "--", "--runInBand"],
|
|
"console": "integratedTerminal",
|
|
"internalConsoleOptions": "neverOpen"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Debug Tips
|
|
|
|
1. Use `test.only()` to run single test
|
|
2. Use `--detectOpenHandles` for async issues
|
|
3. Use `--runInBand` for sequential execution
|
|
4. Add `console.log()` statements temporarily
|
|
5. Use debugger breakpoints in VS Code
|
|
|
|
## Best Practices Checklist
|
|
|
|
- [ ] Each test is independent and isolated
|
|
- [ ] Tests follow AAA pattern (Arrange-Act-Assert)
|
|
- [ ] Mock external dependencies
|
|
- [ ] Test edge cases and error scenarios
|
|
- [ ] Keep tests simple and focused
|
|
- [ ] Use descriptive test names
|
|
- [ ] Maintain >70% code coverage
|
|
- [ ] Run tests before committing
|
|
- [ ] Keep test data realistic
|
|
- [ ] Clean up after tests |