23 KiB
23 KiB
Planting Service 测试文档
目录
测试策略
测试金字塔
┌─────────┐
│ 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)
{
"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)
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/../src/$1"
}
}
单元测试
概述
单元测试专注于测试独立的业务逻辑单元,不依赖外部服务或数据库。
测试领域聚合
// 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();
});
});
});
测试值对象
// 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
});
});
});
测试领域服务
// 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);
});
});
});
运行单元测试
# 运行所有单元测试
npm run test
# 或
make test-unit
# 监听模式
npm run test:watch
# 运行特定文件
npm run test -- planting-order.aggregate.spec.ts
# 详细输出
npm run test -- --verbose
集成测试
概述
集成测试验证多个组件协同工作,使用 Mock 替代外部依赖。
应用服务集成测试
// 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();
});
});
});
运行集成测试
# 运行集成测试
npm run test -- --testPathPattern=integration --runInBand
# 或
make test-integration
端到端测试
概述
E2E 测试通过 HTTP 请求验证完整的 API 功能。
E2E 测试实现
// 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 测试
# 运行 E2E 测试
npm run test:e2e
# 或
make test-e2e
测试覆盖率
生成覆盖率报告
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
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
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 测试
# 运行所有 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 示例
# .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. 测试命名规范
describe('被测试的单元', () => {
describe('方法名', () => {
it('应该 + 预期行为', () => {
// ...
});
});
});
2. AAA 模式
it('应该创建有效的订单', () => {
// Arrange - 准备
const userId = BigInt(1);
const treeCount = 5;
// Act - 执行
const order = PlantingOrder.create(userId, treeCount);
// Assert - 断言
expect(order.treeCount).toBe(5);
});
3. 避免测试实现细节
// 不好 - 测试实现细节
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. 独立的测试数据
// 每个测试使用独立数据
beforeEach(() => {
jest.clearAllMocks();
});
// 使用工厂函数创建测试数据
function createTestOrder(overrides = {}) {
return PlantingOrder.create(BigInt(1), 1, {
...defaultProps,
...overrides,
});
}
5. Mock 外部依赖
// 只 mock 必要的外部依赖
const walletService = {
getBalance: jest.fn().mockResolvedValue({ available: 100000 }),
deductForPlanting: jest.fn().mockResolvedValue(true),
};
常用命令速查
# 单元测试
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 # 运行所有测试