19 KiB
19 KiB
Wallet Service 测试文档
概述
本文档描述 Wallet Service 的测试架构和实现方式,包括单元测试、集成测试和端到端 (E2E) 测试。
测试架构
测试金字塔
┌─────────────┐
│ E2E 测试 │ ← 少量,真实数据库
│ (23 个) │
├─────────────┤
│ 集成测试 │ ← 应用服务层
│ (Mock) │
├─────────────────┤
│ 单元测试 │ ← 领域层,快速
│ (46 个) │
└─────────────────┘
测试类型分布
| 测试类型 | 测试数量 | 覆盖范围 | 执行时间 |
|---|---|---|---|
| 单元测试 | 46 | 领域层 (Value Objects, Aggregates) | ~2s |
| 应用服务测试 | 23 | 应用层 (Service, Commands, Queries) | ~3s |
| E2E 测试 | 23 | API 层 + 数据库 | ~7s |
目录结构
wallet-service/
├── src/
│ ├── domain/
│ │ ├── value-objects/
│ │ │ ├── money.vo.spec.ts # Money 值对象测试
│ │ │ ├── balance.vo.spec.ts # Balance 值对象测试
│ │ │ └── hashpower.vo.spec.ts # Hashpower 值对象测试
│ │ └── aggregates/
│ │ ├── wallet-account.aggregate.spec.ts
│ │ ├── ledger-entry.aggregate.spec.ts
│ │ ├── deposit-order.aggregate.spec.ts
│ │ └── settlement-order.aggregate.spec.ts
│ └── application/
│ └── services/
│ └── wallet-application.service.spec.ts
├── test/
│ ├── jest-e2e.json # E2E 测试配置
│ ├── app.e2e-spec.ts # 主 E2E 测试套件
│ └── simple.e2e-spec.ts # 简单连接测试
└── jest.config.js # Jest 配置
单元测试
值对象测试
值对象测试确保业务规则在值对象层面正确实现。
Money 值对象测试
// src/domain/value-objects/money.vo.spec.ts
describe('Money Value Object', () => {
describe('creation', () => {
it('should create USDT money', () => {
const money = Money.USDT(100);
expect(money.value).toBe(100);
expect(money.currency).toBe('USDT');
});
it('should throw on negative amount', () => {
expect(() => Money.USDT(-10)).toThrow('negative');
});
});
describe('operations', () => {
it('should add same currency', () => {
const a = Money.USDT(100);
const b = Money.USDT(50);
const sum = a.add(b);
expect(sum.value).toBe(150);
});
it('should throw on currency mismatch', () => {
const usdt = Money.USDT(100);
const bnb = Money.BNB(1);
expect(() => usdt.add(bnb)).toThrow('currency');
});
});
});
Balance 值对象测试
// src/domain/value-objects/balance.vo.spec.ts
describe('Balance Value Object', () => {
it('should freeze available to frozen', () => {
const balance = Balance.create(Money.USDT(100), Money.USDT(0));
const frozen = balance.freeze(Money.USDT(30));
expect(frozen.available.value).toBe(70);
expect(frozen.frozen.value).toBe(30);
});
it('should throw on insufficient balance for freeze', () => {
const balance = Balance.create(Money.USDT(50), Money.USDT(0));
expect(() => balance.freeze(Money.USDT(100))).toThrow('Insufficient');
});
});
聚合测试
聚合测试验证复杂的业务逻辑和领域事件。
WalletAccount 聚合测试
// src/domain/aggregates/wallet-account.aggregate.spec.ts
describe('WalletAccount Aggregate', () => {
let wallet: WalletAccount;
beforeEach(() => {
wallet = WalletAccount.createNew(UserId.create(1));
});
describe('deposit', () => {
it('should increase USDT balance on deposit', () => {
const amount = Money.USDT(100);
wallet.deposit(amount, 'KAVA', 'tx_hash_123');
expect(wallet.balances.usdt.available.value).toBe(100);
expect(wallet.domainEvents.length).toBe(1);
expect(wallet.domainEvents[0].eventType).toBe('DepositCompletedEvent');
});
it('should throw error when wallet is frozen', () => {
wallet.freezeWallet();
expect(() => wallet.deposit(Money.USDT(100), 'KAVA', 'tx')).toThrow('Wallet');
});
});
describe('rewards lifecycle', () => {
it('should move pending rewards to settleable', () => {
const usdt = Money.USDT(10);
const hashpower = Hashpower.create(5);
const expireAt = new Date(Date.now() + 86400000);
wallet.addPendingReward(usdt, hashpower, expireAt, 'order_123');
wallet.movePendingToSettleable();
expect(wallet.rewards.pendingUsdt.isZero()).toBe(true);
expect(wallet.rewards.settleableUsdt.value).toBe(10);
expect(wallet.hashpower.value).toBe(5);
});
});
});
运行单元测试
# 运行所有单元测试
npm test
# 监听模式
npm run test:watch
# 覆盖率报告
npm run test:cov
# 运行特定文件
npm test -- money.vo.spec.ts
# 运行特定测试
npm test -- --testNamePattern="should create USDT"
应用服务测试
应用服务测试使用 Mock 仓储来隔离业务逻辑测试。
Mock 仓储设置
// src/application/services/wallet-application.service.spec.ts
describe('WalletApplicationService', () => {
let service: WalletApplicationService;
let mockWalletRepo: any;
let mockLedgerRepo: any;
let mockDepositRepo: any;
beforeEach(async () => {
// 创建 Mock 仓储
mockWalletRepo = {
save: jest.fn(),
findByUserId: jest.fn(),
getOrCreate: jest.fn(),
};
mockLedgerRepo = {
save: jest.fn(),
findByUserId: jest.fn(),
};
mockDepositRepo = {
save: jest.fn(),
existsByTxHash: jest.fn(),
};
// 配置测试模块
const module: TestingModule = await Test.createTestingModule({
providers: [
WalletApplicationService,
{ provide: WALLET_ACCOUNT_REPOSITORY, useValue: mockWalletRepo },
{ provide: LEDGER_ENTRY_REPOSITORY, useValue: mockLedgerRepo },
{ provide: DEPOSIT_ORDER_REPOSITORY, useValue: mockDepositRepo },
],
}).compile();
service = module.get<WalletApplicationService>(WalletApplicationService);
});
});
命令处理测试
describe('handleDeposit', () => {
it('should process deposit successfully', async () => {
const command = new HandleDepositCommand('1', 100, ChainType.KAVA, 'tx_123');
const mockWallet = createMockWallet(BigInt(1), 0);
mockDepositRepo.existsByTxHash.mockResolvedValue(false);
mockWalletRepo.getOrCreate.mockResolvedValue(mockWallet);
mockDepositRepo.save.mockResolvedValue({});
mockWalletRepo.save.mockResolvedValue(mockWallet);
mockLedgerRepo.save.mockResolvedValue({});
await service.handleDeposit(command);
expect(mockDepositRepo.existsByTxHash).toHaveBeenCalledWith('tx_123');
expect(mockWalletRepo.getOrCreate).toHaveBeenCalledWith(BigInt(1));
expect(mockDepositRepo.save).toHaveBeenCalled();
expect(mockWalletRepo.save).toHaveBeenCalled();
expect(mockLedgerRepo.save).toHaveBeenCalled();
});
it('should throw error for duplicate transaction', async () => {
mockDepositRepo.existsByTxHash.mockResolvedValue(true);
const command = new HandleDepositCommand('1', 100, ChainType.KAVA, 'tx_dup');
await expect(service.handleDeposit(command)).rejects.toThrow('Duplicate');
});
});
查询处理测试
describe('getMyWallet', () => {
it('should return wallet DTO', async () => {
const query = new GetMyWalletQuery('1');
const mockWallet = createMockWallet(BigInt(1), 100);
mockWalletRepo.getOrCreate.mockResolvedValue(mockWallet);
const result = await service.getMyWallet(query);
expect(result.userId).toBe('1');
expect(result.balances.usdt.available).toBe(100);
expect(result.status).toBe('ACTIVE');
});
});
E2E 测试
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"
}
}
测试环境设置
// test/app.e2e-spec.ts
describe('Wallet Service (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let authToken: string;
const testUserId = '99999';
const jwtSecret = process.env.JWT_SECRET || 'test-secret';
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
// 只需要 ValidationPipe
// DomainExceptionFilter 和 TransformInterceptor 由 AppModule 提供
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
prisma = app.get(PrismaService);
await app.init();
// 生成测试 JWT Token
authToken = jwt.sign(
{ sub: testUserId, seq: 1001 },
jwtSecret,
{ expiresIn: '1h' },
);
await cleanupTestData();
});
afterAll(async () => {
await cleanupTestData();
await app.close();
});
async function cleanupTestData() {
await prisma.ledgerEntry.deleteMany({ where: { userId: BigInt(testUserId) } });
await prisma.depositOrder.deleteMany({ where: { userId: BigInt(testUserId) } });
await prisma.walletAccount.deleteMany({ where: { userId: BigInt(testUserId) } });
}
});
API 端点测试
describe('Health Check', () => {
it('/api/v1/health (GET) - should return health status', () => {
return request(app.getHttpServer())
.get('/api/v1/health')
.expect(200)
.expect(res => {
expect(res.body.success).toBe(true);
expect(res.body.data.status).toBe('ok');
});
});
});
describe('Wallet Operations', () => {
it('/api/v1/wallet/my-wallet (GET) - should return wallet info', async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/wallet/my-wallet')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty('walletId');
expect(res.body.data).toHaveProperty('balances');
expect(res.body.data.status).toBe('ACTIVE');
});
});
describe('Deposit Operations', () => {
it('/api/v1/wallet/deposit (POST) - should process deposit', async () => {
const res = await request(app.getHttpServer())
.post('/api/v1/wallet/deposit')
.send({
userId: testUserId,
amount: 100,
chainType: 'KAVA',
txHash: `test_tx_${Date.now()}`,
})
.expect(201);
expect(res.body.success).toBe(true);
// 验证余额更新
const walletRes = await request(app.getHttpServer())
.get('/api/v1/wallet/my-wallet')
.set('Authorization', `Bearer ${authToken}`);
expect(walletRes.body.data.balances.usdt.available).toBe(100);
});
});
数据库完整性测试
describe('Database Integrity', () => {
it('should persist wallet data correctly', async () => {
const wallet = await prisma.walletAccount.findFirst({
where: { userId: BigInt(testUserId) },
});
expect(wallet).not.toBeNull();
expect(wallet?.status).toBe('ACTIVE');
expect(Number(wallet?.usdtAvailable)).toBe(150);
});
it('should persist ledger entries correctly', async () => {
const entries = await prisma.ledgerEntry.findMany({
where: { userId: BigInt(testUserId) },
});
expect(entries.length).toBeGreaterThanOrEqual(2);
});
});
WSL2 环境配置
PostgreSQL Docker 容器
# 在 WSL2 中启动 PostgreSQL
docker run -d \
--name wallet-postgres-test \
-e POSTGRES_USER=wallet \
-e POSTGRES_PASSWORD=wallet123 \
-e POSTGRES_DB=wallet_test \
-p 5432:5432 \
postgres:15-alpine
# 获取容器 IP
docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' wallet-postgres-test
环境变量设置
# 使用容器 IP (推荐)
export DATABASE_URL="postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test?schema=public"
# 或使用 localhost (确保端口映射正常)
export DATABASE_URL="postgresql://wallet:wallet123@localhost:5432/wallet_test?schema=public"
export JWT_SECRET="test-jwt-secret-key-for-e2e-testing"
运行 E2E 测试
# 初始化数据库
npx prisma generate
npx prisma db push
# 运行 E2E 测试
npm run test:e2e
# 使用 dotenv 加载环境变量
npx dotenv -e .env.test -- npm run test:e2e
常见问题
问题 1: 测试超时
症状: E2E 测试超时或长时间无响应
原因: WSL2 跨文件系统性能问题
解决方案: 将项目复制到 WSL2 原生文件系统
# 不要使用 /mnt/c/ 路径
cp -r /mnt/c/project ~/project
cd ~/project
npm install
npm run test:e2e
问题 2: 数据库连接失败
症状: ECONNREFUSED 或 Connection timeout
原因: 网络配置问题
解决方案: 使用 Docker 容器 IP 而非 localhost
# 获取容器 IP
docker inspect wallet-postgres-test | grep IPAddress
# 更新 DATABASE_URL
export DATABASE_URL="postgresql://wallet:wallet123@172.17.0.2:5432/wallet_test"
问题 3: 响应结构断言失败
症状: expect(res.body.data).toHaveProperty('walletId') 失败
原因: TransformInterceptor 被重复应用
错误代码:
// 错误 - 导致双重包装
app.useGlobalFilters(new DomainExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
正确代码:
// 正确 - 只添加 ValidationPipe
// Filter 和 Interceptor 由 AppModule 通过 APP_INTERCEPTOR 提供
app.useGlobalPipes(new ValidationPipe({...}));
问题 4: Prisma Client 未生成
症状: Cannot find module '@prisma/client'
解决方案:
npx prisma generate
测试覆盖率
生成覆盖率报告
npm run test:cov
覆盖率目标
| 层级 | 语句覆盖 | 分支覆盖 | 函数覆盖 |
|---|---|---|---|
| Domain Layer | 90%+ | 85%+ | 90%+ |
| Application Layer | 80%+ | 75%+ | 85%+ |
| API Layer | 70%+ | 65%+ | 80%+ |
查看报告
# HTML 报告
open coverage/lcov-report/index.html
# 终端摘要
npm run test:cov -- --coverageReporters="text-summary"
CI/CD 集成
GitHub Actions 配置
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: wallet
POSTGRES_PASSWORD: wallet123
POSTGRES_DB: wallet_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma Client
run: npx prisma generate
- name: Push database schema
run: npx prisma db push
env:
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test
- name: Run unit tests
run: npm test
- name: Run E2E tests
run: npm run test:e2e
env:
DATABASE_URL: postgresql://wallet:wallet123@localhost:5432/wallet_test
JWT_SECRET: test-jwt-secret
- name: Upload coverage
uses: codecov/codecov-action@v3
测试最佳实践
1. 测试命名
// 好的命名
it('should increase USDT balance when deposit is processed')
it('should throw InsufficientBalanceError when balance is insufficient')
// 避免的命名
it('test deposit')
it('works')
2. 测试隔离
// 每个测试独立
beforeEach(() => {
wallet = WalletAccount.createNew(UserId.create(1));
});
afterEach(async () => {
await cleanupTestData();
});
3. 使用工厂函数
// 创建测试数据的工厂函数
const createMockWallet = (userId: bigint, balance = 0) => {
return WalletAccount.reconstruct({
walletId: BigInt(1),
userId,
usdtAvailable: new Decimal(balance),
// ...
});
};
4. 断言明确
// 好的断言
expect(wallet.balances.usdt.available.value).toBe(100);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty('walletId');
// 避免的断言
expect(wallet).toBeTruthy();
expect(res.body).toBeDefined();
调试测试
VS Code 调试配置
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--watchAll=false"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Debug E2E Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--config", "test/jest-e2e.json", "--runInBand"],
"console": "integratedTerminal",
"env": {
"DATABASE_URL": "postgresql://wallet:wallet123@localhost:5432/wallet_test",
"JWT_SECRET": "test-secret"
}
}
]
}
单个测试调试
# 只运行特定测试
npm test -- --testNamePattern="should process deposit"
# 运行特定文件
npm test -- wallet-account.aggregate.spec.ts
# 显示详细输出
npm test -- --verbose