897 lines
23 KiB
Markdown
897 lines
23 KiB
Markdown
# Planting Service 测试文档
|
||
|
||
## 目录
|
||
|
||
- [测试策略](#测试策略)
|
||
- [测试架构](#测试架构)
|
||
- [单元测试](#单元测试)
|
||
- [集成测试](#集成测试)
|
||
- [端到端测试](#端到端测试)
|
||
- [测试覆盖率](#测试覆盖率)
|
||
- [Docker 测试](#docker-测试)
|
||
- [CI/CD 集成](#cicd-集成)
|
||
- [最佳实践](#最佳实践)
|
||
|
||
---
|
||
|
||
## 测试策略
|
||
|
||
### 测试金字塔
|
||
|
||
```
|
||
┌─────────┐
|
||
│ E2E │ ◄── 少量,验证完整流程
|
||
│ Tests │
|
||
─┴─────────┴─
|
||
┌─────────────┐
|
||
│ Integration │ ◄── 中等数量,验证模块协作
|
||
│ Tests │
|
||
─┴─────────────┴─
|
||
┌─────────────────┐
|
||
│ Unit Tests │ ◄── 大量,验证业务逻辑
|
||
│ │
|
||
└─────────────────┘
|
||
```
|
||
|
||
### 测试分布
|
||
|
||
| 测试类型 | 数量 | 覆盖范围 | 执行时间 |
|
||
|---------|------|---------|---------|
|
||
| 单元测试 | 45+ | 领域逻辑、值对象 | < 10s |
|
||
| 集成测试 | 12+ | 应用服务、用例 | < 30s |
|
||
| E2E 测试 | 17+ | API 端点、认证 | < 60s |
|
||
|
||
---
|
||
|
||
## 测试架构
|
||
|
||
### 目录结构
|
||
|
||
```
|
||
planting-service/
|
||
├── src/
|
||
│ ├── domain/
|
||
│ │ ├── aggregates/
|
||
│ │ │ ├── planting-order.aggregate.ts
|
||
│ │ │ └── planting-order.aggregate.spec.ts # 单元测试
|
||
│ │ ├── services/
|
||
│ │ │ ├── fund-allocation.service.ts
|
||
│ │ │ └── fund-allocation.service.spec.ts # 单元测试
|
||
│ │ └── value-objects/
|
||
│ │ ├── tree-count.vo.ts
|
||
│ │ └── tree-count.vo.spec.ts # 单元测试
|
||
│ └── application/
|
||
│ └── services/
|
||
│ ├── planting-application.service.ts
|
||
│ └── planting-application.service.integration.spec.ts # 集成测试
|
||
└── test/
|
||
├── app.e2e-spec.ts # E2E 测试
|
||
└── jest-e2e.json # E2E 配置
|
||
```
|
||
|
||
### 测试配置
|
||
|
||
**Jest 配置 (package.json)**
|
||
|
||
```json
|
||
{
|
||
"jest": {
|
||
"moduleFileExtensions": ["js", "json", "ts"],
|
||
"rootDir": "src",
|
||
"testRegex": ".*\\.spec\\.ts$",
|
||
"transform": {
|
||
"^.+\\.(t|j)s$": "ts-jest"
|
||
},
|
||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||
"coverageDirectory": "../coverage",
|
||
"testEnvironment": "node",
|
||
"moduleNameMapper": {
|
||
"^@/(.*)$": "<rootDir>/$1"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**E2E 配置 (test/jest-e2e.json)**
|
||
|
||
```json
|
||
{
|
||
"moduleFileExtensions": ["js", "json", "ts"],
|
||
"rootDir": ".",
|
||
"testEnvironment": "node",
|
||
"testRegex": ".e2e-spec.ts$",
|
||
"transform": {
|
||
"^.+\\.(t|j)s$": "ts-jest"
|
||
},
|
||
"moduleNameMapper": {
|
||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 单元测试
|
||
|
||
### 概述
|
||
|
||
单元测试专注于测试独立的业务逻辑单元,不依赖外部服务或数据库。
|
||
|
||
### 测试领域聚合
|
||
|
||
```typescript
|
||
// src/domain/aggregates/planting-order.aggregate.spec.ts
|
||
|
||
import { PlantingOrder } from './planting-order.aggregate';
|
||
import { PlantingOrderStatus } from '../value-objects/planting-order-status.enum';
|
||
|
||
describe('PlantingOrder', () => {
|
||
describe('create', () => {
|
||
it('应该创建有效的订单', () => {
|
||
const order = PlantingOrder.create(BigInt(1), 5);
|
||
|
||
expect(order.userId).toBe(BigInt(1));
|
||
expect(order.treeCount).toBe(5);
|
||
expect(order.totalAmount).toBe(5 * 2199);
|
||
expect(order.status).toBe(PlantingOrderStatus.CREATED);
|
||
});
|
||
|
||
it('应该生成唯一的订单号', () => {
|
||
const order1 = PlantingOrder.create(BigInt(1), 1);
|
||
const order2 = PlantingOrder.create(BigInt(1), 1);
|
||
|
||
expect(order1.orderNo).not.toBe(order2.orderNo);
|
||
expect(order1.orderNo).toMatch(/^PO\d{14}$/);
|
||
});
|
||
|
||
it('应该产生领域事件', () => {
|
||
const order = PlantingOrder.create(BigInt(1), 5);
|
||
const events = order.getDomainEvents();
|
||
|
||
expect(events).toHaveLength(1);
|
||
expect(events[0].constructor.name).toBe('PlantingOrderCreatedEvent');
|
||
});
|
||
});
|
||
|
||
describe('selectProvinceCity', () => {
|
||
it('应该允许在CREATED状态下选择省市', () => {
|
||
const order = PlantingOrder.create(BigInt(1), 1);
|
||
|
||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||
|
||
expect(order.provinceCode).toBe('440000');
|
||
expect(order.cityCode).toBe('440100');
|
||
});
|
||
|
||
it('不应该允许在非CREATED状态下选择省市', () => {
|
||
const order = PlantingOrder.create(BigInt(1), 1);
|
||
order.cancel();
|
||
|
||
expect(() => {
|
||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||
}).toThrow();
|
||
});
|
||
});
|
||
|
||
describe('confirmProvinceCity', () => {
|
||
it('应该在5秒后允许确认', () => {
|
||
jest.useFakeTimers();
|
||
const order = PlantingOrder.create(BigInt(1), 1);
|
||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||
|
||
jest.advanceTimersByTime(5000);
|
||
|
||
expect(() => order.confirmProvinceCity()).not.toThrow();
|
||
expect(order.status).toBe(PlantingOrderStatus.PROVINCE_CITY_CONFIRMED);
|
||
|
||
jest.useRealTimers();
|
||
});
|
||
|
||
it('应该在5秒内拒绝确认', () => {
|
||
jest.useFakeTimers();
|
||
const order = PlantingOrder.create(BigInt(1), 1);
|
||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||
|
||
jest.advanceTimersByTime(3000); // 只过了3秒
|
||
|
||
expect(() => order.confirmProvinceCity()).toThrow('还需等待');
|
||
|
||
jest.useRealTimers();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 测试值对象
|
||
|
||
```typescript
|
||
// src/domain/value-objects/tree-count.vo.spec.ts
|
||
|
||
import { TreeCount } from './tree-count.vo';
|
||
|
||
describe('TreeCount', () => {
|
||
describe('create', () => {
|
||
it('应该创建有效的数量', () => {
|
||
const count = TreeCount.create(5);
|
||
expect(count.value).toBe(5);
|
||
});
|
||
|
||
it('应该拒绝0或负数', () => {
|
||
expect(() => TreeCount.create(0)).toThrow('认种数量必须大于0');
|
||
expect(() => TreeCount.create(-1)).toThrow('认种数量必须大于0');
|
||
});
|
||
|
||
it('应该拒绝超过最大限制', () => {
|
||
expect(() => TreeCount.create(1001)).toThrow('单次认种数量不能超过1000');
|
||
});
|
||
|
||
it('应该拒绝小数', () => {
|
||
expect(() => TreeCount.create(1.5)).toThrow('认种数量必须为整数');
|
||
});
|
||
});
|
||
|
||
describe('checkLimit', () => {
|
||
it('应该正确检查限购', () => {
|
||
expect(TreeCount.checkLimit(900, 100)).toBe(true); // 刚好1000
|
||
expect(TreeCount.checkLimit(901, 100)).toBe(false); // 超过1000
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 测试领域服务
|
||
|
||
```typescript
|
||
// src/domain/services/fund-allocation.service.spec.ts
|
||
|
||
import { FundAllocationDomainService } from './fund-allocation.service';
|
||
|
||
describe('FundAllocationDomainService', () => {
|
||
let service: FundAllocationDomainService;
|
||
|
||
beforeEach(() => {
|
||
service = new FundAllocationDomainService();
|
||
});
|
||
|
||
describe('calculateAllocations', () => {
|
||
it('应该返回10种分配', () => {
|
||
const allocations = service.calculateAllocations(1, {
|
||
referralChain: [],
|
||
nearestProvinceAuth: null,
|
||
nearestCityAuth: null,
|
||
nearestCommunity: null,
|
||
});
|
||
|
||
expect(allocations).toHaveLength(10);
|
||
});
|
||
|
||
it('应该正确计算资金池分配', () => {
|
||
const allocations = service.calculateAllocations(1, {
|
||
referralChain: [],
|
||
nearestProvinceAuth: null,
|
||
nearestCityAuth: null,
|
||
nearestCommunity: null,
|
||
});
|
||
|
||
const poolAllocation = allocations.find(a => a.targetType === 'POOL');
|
||
expect(poolAllocation?.amount).toBe(1979.1); // 2199 * 90%
|
||
});
|
||
|
||
it('应该正确计算总金额', () => {
|
||
const allocations = service.calculateAllocations(5, {
|
||
referralChain: [],
|
||
nearestProvinceAuth: null,
|
||
nearestCityAuth: null,
|
||
nearestCommunity: null,
|
||
});
|
||
|
||
const total = allocations.reduce((sum, a) => sum + a.amount, 0);
|
||
expect(total).toBeCloseTo(5 * 2199, 2);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 运行单元测试
|
||
|
||
```bash
|
||
# 运行所有单元测试
|
||
npm run test
|
||
# 或
|
||
make test-unit
|
||
|
||
# 监听模式
|
||
npm run test:watch
|
||
|
||
# 运行特定文件
|
||
npm run test -- planting-order.aggregate.spec.ts
|
||
|
||
# 详细输出
|
||
npm run test -- --verbose
|
||
```
|
||
|
||
---
|
||
|
||
## 集成测试
|
||
|
||
### 概述
|
||
|
||
集成测试验证多个组件协同工作,使用 Mock 替代外部依赖。
|
||
|
||
### 应用服务集成测试
|
||
|
||
```typescript
|
||
// src/application/services/planting-application.service.integration.spec.ts
|
||
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { PlantingApplicationService } from './planting-application.service';
|
||
import { FundAllocationDomainService } from '../../domain/services/fund-allocation.service';
|
||
import {
|
||
IPlantingOrderRepository,
|
||
PLANTING_ORDER_REPOSITORY,
|
||
} from '../../domain/repositories/planting-order.repository.interface';
|
||
import { WalletServiceClient } from '../../infrastructure/external/wallet-service.client';
|
||
import { ReferralServiceClient } from '../../infrastructure/external/referral-service.client';
|
||
|
||
describe('PlantingApplicationService (Integration)', () => {
|
||
let service: PlantingApplicationService;
|
||
let orderRepository: jest.Mocked<IPlantingOrderRepository>;
|
||
let walletService: jest.Mocked<WalletServiceClient>;
|
||
let referralService: jest.Mocked<ReferralServiceClient>;
|
||
|
||
beforeEach(async () => {
|
||
// 创建 Mocks
|
||
orderRepository = {
|
||
save: jest.fn(),
|
||
findById: jest.fn(),
|
||
findByOrderNo: jest.fn(),
|
||
findByUserId: jest.fn(),
|
||
countTreesByUserId: jest.fn(),
|
||
} as any;
|
||
|
||
walletService = {
|
||
getBalance: jest.fn(),
|
||
deductForPlanting: jest.fn(),
|
||
allocateFunds: jest.fn(),
|
||
} as any;
|
||
|
||
referralService = {
|
||
getReferralContext: jest.fn(),
|
||
} as any;
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
PlantingApplicationService,
|
||
FundAllocationDomainService,
|
||
{ provide: PLANTING_ORDER_REPOSITORY, useValue: orderRepository },
|
||
{ provide: WalletServiceClient, useValue: walletService },
|
||
{ provide: ReferralServiceClient, useValue: referralService },
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<PlantingApplicationService>(PlantingApplicationService);
|
||
});
|
||
|
||
describe('createOrder', () => {
|
||
it('应该成功创建订单', async () => {
|
||
// Arrange
|
||
orderRepository.countTreesByUserId.mockResolvedValue(0);
|
||
walletService.getBalance.mockResolvedValue({
|
||
userId: '1',
|
||
available: 100000,
|
||
locked: 0,
|
||
currency: 'USDT',
|
||
});
|
||
orderRepository.save.mockResolvedValue(undefined);
|
||
|
||
// Act
|
||
const result = await service.createOrder(BigInt(1), 5);
|
||
|
||
// Assert
|
||
expect(result.treeCount).toBe(5);
|
||
expect(result.totalAmount).toBe(5 * 2199);
|
||
expect(orderRepository.save).toHaveBeenCalled();
|
||
});
|
||
|
||
it('应该拒绝超过限购数量', async () => {
|
||
orderRepository.countTreesByUserId.mockResolvedValue(950);
|
||
|
||
await expect(service.createOrder(BigInt(1), 100))
|
||
.rejects.toThrow('超过个人最大认种数量限制');
|
||
});
|
||
|
||
it('应该拒绝余额不足', async () => {
|
||
orderRepository.countTreesByUserId.mockResolvedValue(0);
|
||
walletService.getBalance.mockResolvedValue({
|
||
userId: '1',
|
||
available: 100, // 余额不足
|
||
locked: 0,
|
||
currency: 'USDT',
|
||
});
|
||
|
||
await expect(service.createOrder(BigInt(1), 5))
|
||
.rejects.toThrow('余额不足');
|
||
});
|
||
});
|
||
|
||
describe('payOrder', () => {
|
||
it('应该成功支付订单并完成资金分配', async () => {
|
||
jest.useFakeTimers();
|
||
|
||
// 准备订单
|
||
const order = PlantingOrder.create(BigInt(1), 1);
|
||
order.selectProvinceCity('440000', '广东省', '440100', '广州市');
|
||
jest.advanceTimersByTime(5000);
|
||
order.confirmProvinceCity();
|
||
|
||
// 设置 mocks
|
||
orderRepository.findByOrderNo.mockResolvedValue(order);
|
||
walletService.deductForPlanting.mockResolvedValue(true);
|
||
referralService.getReferralContext.mockResolvedValue({
|
||
referralChain: [],
|
||
nearestProvinceAuth: null,
|
||
nearestCityAuth: null,
|
||
nearestCommunity: null,
|
||
});
|
||
walletService.allocateFunds.mockResolvedValue(true);
|
||
|
||
// Act
|
||
const result = await service.payOrder(order.orderNo, BigInt(1));
|
||
|
||
// Assert
|
||
expect(result.allocations.length).toBe(10);
|
||
expect(walletService.deductForPlanting).toHaveBeenCalled();
|
||
expect(walletService.allocateFunds).toHaveBeenCalled();
|
||
|
||
jest.useRealTimers();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 运行集成测试
|
||
|
||
```bash
|
||
# 运行集成测试
|
||
npm run test -- --testPathPattern=integration --runInBand
|
||
# 或
|
||
make test-integration
|
||
```
|
||
|
||
---
|
||
|
||
## 端到端测试
|
||
|
||
### 概述
|
||
|
||
E2E 测试通过 HTTP 请求验证完整的 API 功能。
|
||
|
||
### E2E 测试实现
|
||
|
||
```typescript
|
||
// test/app.e2e-spec.ts
|
||
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common';
|
||
import * as request from 'supertest';
|
||
import { HealthController } from '../src/api/controllers/health.controller';
|
||
import { PlantingOrderController } from '../src/api/controllers/planting-order.controller';
|
||
import { PlantingApplicationService } from '../src/application/services/planting-application.service';
|
||
import { JwtAuthGuard } from '../src/api/guards/jwt-auth.guard';
|
||
|
||
// Mock 服务
|
||
const mockPlantingService = {
|
||
createOrder: jest.fn(),
|
||
selectProvinceCity: jest.fn(),
|
||
payOrder: jest.fn(),
|
||
getUserOrders: jest.fn(),
|
||
getUserPosition: jest.fn(),
|
||
};
|
||
|
||
describe('PlantingController (e2e) - Unauthorized', () => {
|
||
let app: INestApplication;
|
||
|
||
beforeAll(async () => {
|
||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||
controllers: [HealthController, PlantingOrderController],
|
||
providers: [
|
||
{ provide: PlantingApplicationService, useValue: mockPlantingService },
|
||
],
|
||
})
|
||
.overrideGuard(JwtAuthGuard)
|
||
.useValue({ canActivate: () => false })
|
||
.compile();
|
||
|
||
app = moduleFixture.createNestApplication();
|
||
app.setGlobalPrefix('api/v1');
|
||
app.useGlobalPipes(new ValidationPipe({
|
||
whitelist: true,
|
||
forbidNonWhitelisted: true,
|
||
transform: true,
|
||
}));
|
||
await app.init();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app.close();
|
||
});
|
||
|
||
describe('/health (GET)', () => {
|
||
it('应该返回健康状态', () => {
|
||
return request(app.getHttpServer())
|
||
.get('/api/v1/health')
|
||
.expect(200)
|
||
.expect((res) => {
|
||
expect(res.body.status).toBe('ok');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('/planting/orders (POST)', () => {
|
||
it('应该拒绝未认证的请求', () => {
|
||
return request(app.getHttpServer())
|
||
.post('/api/v1/planting/orders')
|
||
.send({ treeCount: 1 })
|
||
.expect(403);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('PlantingController (e2e) - Authorized', () => {
|
||
let app: INestApplication;
|
||
|
||
beforeAll(async () => {
|
||
// 设置认证通过的 Guard
|
||
const mockAuthGuard = {
|
||
canActivate: (context: ExecutionContext) => {
|
||
const req = context.switchToHttp().getRequest();
|
||
req.user = { id: '1', username: 'testuser' };
|
||
return true;
|
||
},
|
||
};
|
||
|
||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||
controllers: [HealthController, PlantingOrderController],
|
||
providers: [
|
||
{ provide: PlantingApplicationService, useValue: mockPlantingService },
|
||
],
|
||
})
|
||
.overrideGuard(JwtAuthGuard)
|
||
.useValue(mockAuthGuard)
|
||
.compile();
|
||
|
||
app = moduleFixture.createNestApplication();
|
||
app.setGlobalPrefix('api/v1');
|
||
app.useGlobalPipes(new ValidationPipe({
|
||
whitelist: true,
|
||
forbidNonWhitelisted: true,
|
||
transform: true,
|
||
}));
|
||
await app.init();
|
||
});
|
||
|
||
describe('/planting/orders (POST)', () => {
|
||
it('应该成功创建订单', async () => {
|
||
mockPlantingService.createOrder.mockResolvedValue({
|
||
orderNo: 'PO202411300001',
|
||
treeCount: 5,
|
||
totalAmount: 10995,
|
||
status: 'CREATED',
|
||
});
|
||
|
||
const response = await request(app.getHttpServer())
|
||
.post('/api/v1/planting/orders')
|
||
.send({ treeCount: 5 })
|
||
.expect(201);
|
||
|
||
expect(response.body.orderNo).toBe('PO202411300001');
|
||
});
|
||
|
||
it('应该验证 treeCount 必须为正整数', async () => {
|
||
await request(app.getHttpServer())
|
||
.post('/api/v1/planting/orders')
|
||
.send({ treeCount: 0 })
|
||
.expect(400);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 运行 E2E 测试
|
||
|
||
```bash
|
||
# 运行 E2E 测试
|
||
npm run test:e2e
|
||
# 或
|
||
make test-e2e
|
||
```
|
||
|
||
---
|
||
|
||
## 测试覆盖率
|
||
|
||
### 生成覆盖率报告
|
||
|
||
```bash
|
||
npm run test:cov
|
||
# 或
|
||
make test-cov
|
||
```
|
||
|
||
### 覆盖率指标
|
||
|
||
| 指标 | 目标 | 当前 |
|
||
|-----|------|-----|
|
||
| 语句覆盖率 | > 80% | 34% |
|
||
| 分支覆盖率 | > 70% | 17% |
|
||
| 函数覆盖率 | > 80% | 36% |
|
||
| 行覆盖率 | > 80% | 34% |
|
||
|
||
### 核心模块覆盖率
|
||
|
||
| 模块 | 覆盖率 | 说明 |
|
||
|-----|-------|------|
|
||
| planting-application.service | 89% | 核心应用服务 |
|
||
| fund-allocation.service | 93% | 资金分配服务 |
|
||
| planting-order.aggregate | 69% | 订单聚合 |
|
||
| tree-count.vo | 100% | 数量值对象 |
|
||
|
||
### 覆盖率报告位置
|
||
|
||
覆盖率报告生成在 `coverage/` 目录:
|
||
|
||
```
|
||
coverage/
|
||
├── lcov-report/
|
||
│ └── index.html # HTML 报告
|
||
├── lcov.info # LCOV 格式
|
||
└── clover.xml # Clover 格式
|
||
```
|
||
|
||
---
|
||
|
||
## Docker 测试
|
||
|
||
### Docker 测试配置
|
||
|
||
**docker-compose.test.yml**
|
||
|
||
```yaml
|
||
version: '3.8'
|
||
|
||
services:
|
||
db:
|
||
image: postgres:16-alpine
|
||
environment:
|
||
POSTGRES_USER: postgres
|
||
POSTGRES_PASSWORD: postgres
|
||
POSTGRES_DB: rwadurian_planting_test
|
||
ports:
|
||
- "5433:5432"
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
test:
|
||
build:
|
||
context: .
|
||
dockerfile: Dockerfile.test
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
environment:
|
||
NODE_ENV: test
|
||
DATABASE_URL: postgresql://postgres:postgres@db:5432/rwadurian_planting_test
|
||
JWT_SECRET: test-jwt-secret
|
||
volumes:
|
||
- ./src:/app/src
|
||
- ./test:/app/test
|
||
- ./prisma:/app/prisma
|
||
```
|
||
|
||
**Dockerfile.test**
|
||
|
||
```dockerfile
|
||
FROM node:20-alpine
|
||
|
||
WORKDIR /app
|
||
|
||
COPY package*.json ./
|
||
RUN npm ci
|
||
|
||
COPY prisma ./prisma/
|
||
RUN npx prisma generate
|
||
|
||
COPY . .
|
||
|
||
RUN npm run build
|
||
|
||
CMD ["npm", "run", "test"]
|
||
```
|
||
|
||
### 运行 Docker 测试
|
||
|
||
```bash
|
||
# 运行所有 Docker 测试
|
||
make test-docker-all
|
||
|
||
# 分步运行
|
||
docker-compose -f docker-compose.test.yml up -d db
|
||
docker-compose -f docker-compose.test.yml run --rm test npm run test
|
||
docker-compose -f docker-compose.test.yml down -v
|
||
```
|
||
|
||
---
|
||
|
||
## CI/CD 集成
|
||
|
||
### GitHub Actions 示例
|
||
|
||
```yaml
|
||
# .github/workflows/test.yml
|
||
name: Test
|
||
|
||
on:
|
||
push:
|
||
branches: [main, develop]
|
||
pull_request:
|
||
branches: [main, develop]
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
|
||
services:
|
||
postgres:
|
||
image: postgres:16-alpine
|
||
env:
|
||
POSTGRES_USER: postgres
|
||
POSTGRES_PASSWORD: postgres
|
||
POSTGRES_DB: rwadurian_planting_test
|
||
ports:
|
||
- 5432:5432
|
||
options: >-
|
||
--health-cmd pg_isready
|
||
--health-interval 10s
|
||
--health-timeout 5s
|
||
--health-retries 5
|
||
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Setup Node.js
|
||
uses: actions/setup-node@v4
|
||
with:
|
||
node-version: '20'
|
||
cache: 'npm'
|
||
|
||
- name: Install dependencies
|
||
run: npm ci
|
||
|
||
- name: Generate Prisma Client
|
||
run: npx prisma generate
|
||
|
||
- name: Run migrations
|
||
run: npx prisma migrate deploy
|
||
env:
|
||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test
|
||
|
||
- name: Run unit tests
|
||
run: npm run test
|
||
|
||
- name: Run E2E tests
|
||
run: npm run test:e2e
|
||
env:
|
||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_planting_test
|
||
|
||
- name: Upload coverage
|
||
uses: codecov/codecov-action@v3
|
||
with:
|
||
file: ./coverage/lcov.info
|
||
```
|
||
|
||
---
|
||
|
||
## 最佳实践
|
||
|
||
### 1. 测试命名规范
|
||
|
||
```typescript
|
||
describe('被测试的单元', () => {
|
||
describe('方法名', () => {
|
||
it('应该 + 预期行为', () => {
|
||
// ...
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### 2. AAA 模式
|
||
|
||
```typescript
|
||
it('应该创建有效的订单', () => {
|
||
// Arrange - 准备
|
||
const userId = BigInt(1);
|
||
const treeCount = 5;
|
||
|
||
// Act - 执行
|
||
const order = PlantingOrder.create(userId, treeCount);
|
||
|
||
// Assert - 断言
|
||
expect(order.treeCount).toBe(5);
|
||
});
|
||
```
|
||
|
||
### 3. 避免测试实现细节
|
||
|
||
```typescript
|
||
// 不好 - 测试实现细节
|
||
it('应该调用 repository.save', () => {
|
||
await service.createOrder(1, 5);
|
||
expect(repository.save).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// 好 - 测试行为
|
||
it('应该返回创建的订单', () => {
|
||
const result = await service.createOrder(1, 5);
|
||
expect(result.orderNo).toBeDefined();
|
||
expect(result.treeCount).toBe(5);
|
||
});
|
||
```
|
||
|
||
### 4. 独立的测试数据
|
||
|
||
```typescript
|
||
// 每个测试使用独立数据
|
||
beforeEach(() => {
|
||
jest.clearAllMocks();
|
||
});
|
||
|
||
// 使用工厂函数创建测试数据
|
||
function createTestOrder(overrides = {}) {
|
||
return PlantingOrder.create(BigInt(1), 1, {
|
||
...defaultProps,
|
||
...overrides,
|
||
});
|
||
}
|
||
```
|
||
|
||
### 5. Mock 外部依赖
|
||
|
||
```typescript
|
||
// 只 mock 必要的外部依赖
|
||
const walletService = {
|
||
getBalance: jest.fn().mockResolvedValue({ available: 100000 }),
|
||
deductForPlanting: jest.fn().mockResolvedValue(true),
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 常用命令速查
|
||
|
||
```bash
|
||
# 单元测试
|
||
make test-unit # 运行单元测试
|
||
npm run test -- --watch # 监听模式
|
||
npm run test -- --verbose # 详细输出
|
||
npm run test -- file.spec # 运行特定文件
|
||
|
||
# 集成测试
|
||
make test-integration # 运行集成测试
|
||
|
||
# E2E 测试
|
||
make test-e2e # 运行 E2E 测试
|
||
|
||
# 覆盖率
|
||
make test-cov # 生成覆盖率报告
|
||
|
||
# Docker 测试
|
||
make test-docker-all # Docker 中运行所有测试
|
||
|
||
# 所有测试
|
||
make test-all # 运行所有测试
|
||
```
|