rwadurian/backend/services/planting-service/docs/TESTING.md

897 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 # 运行所有测试
```