- Updated skill documentation files to include structured metadata for better organization. - Enhanced bilingual descriptions and guidelines for clarity in both English and Vietnamese. - Refined sections on usage, best practices, and related skills to ensure consistency across all documentation. - Improved formatting and removed outdated references to streamline the documentation experience. - Added best practices checklists to relevant skills for better usability and adherence to standards.
492 lines
11 KiB
Markdown
492 lines
11 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)
|
|
|
|
## 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();
|
|
});
|
|
```
|
|
|
|
## 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
|
|
|
|
### 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 |