- 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.
14 KiB
14 KiB
name, description
| name | description |
|---|---|
| testing-patterns | 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
-
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)
- Location: Next to source files (
-
Integration Tests: Test component interactions
- Location:
__tests__/directory - Scope: Multiple components working together
- Dependencies: Some real, some mocked
- Speed: Medium (1-5s per test)
- Location:
-
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)
- Location:
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.
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
// 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
// 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.
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:
beforeAll: Setup that runs once for the entire suite (e.g., database connection)beforeEach: Setup that runs before each test (e.g., reset mocks, create test data)- Test Execution: The actual test code following AAA pattern
afterEach: Cleanup after each test (e.g., clear mocks, reset state)afterAll: Final cleanup after all tests (e.g., close database connection)
Testing Patterns
Unit Test Pattern
// 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
// 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
// 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.
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
// __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
// __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
// 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
// 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
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
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
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
// 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
// .vscode/launch.json
{
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"runtimeExecutable": "npm",
"runtimeArgs": ["test", "--", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}
Debug Tips
- Use
test.only()to run single test - Use
--detectOpenHandlesfor async issues - Use
--runInBandfor sequential execution - Add
console.log()statements temporarily - 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