# 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 值对象测试 ```typescript // 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 值对象测试 ```typescript // 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 聚合测试 ```typescript // 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); }); }); }); ``` ### 运行单元测试 ```bash # 运行所有单元测试 npm test # 监听模式 npm run test:watch # 覆盖率报告 npm run test:cov # 运行特定文件 npm test -- money.vo.spec.ts # 运行特定测试 npm test -- --testNamePattern="should create USDT" ``` --- ## 应用服务测试 应用服务测试使用 Mock 仓储来隔离业务逻辑测试。 ### Mock 仓储设置 ```typescript // 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); }); }); ``` ### 命令处理测试 ```typescript 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'); }); }); ``` ### 查询处理测试 ```typescript 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 测试使用真实数据库验证完整的请求流程。 ### 测试配置 ```json // test/jest-e2e.json { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "moduleNameMapper": { "^@/(.*)$": "/../src/$1" } } ``` ### 测试环境设置 ```typescript // 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 端点测试 ```typescript 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); }); }); ``` ### 数据库完整性测试 ```typescript 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 容器 ```bash # 在 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 ``` ### 环境变量设置 ```bash # 使用容器 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 测试 ```bash # 初始化数据库 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 原生文件系统 ```bash # 不要使用 /mnt/c/ 路径 cp -r /mnt/c/project ~/project cd ~/project npm install npm run test:e2e ``` ### 问题 2: 数据库连接失败 **症状**: `ECONNREFUSED` 或 `Connection timeout` **原因**: 网络配置问题 **解决方案**: 使用 Docker 容器 IP 而非 localhost ```bash # 获取容器 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 被重复应用 **错误代码**: ```typescript // 错误 - 导致双重包装 app.useGlobalFilters(new DomainExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor()); ``` **正确代码**: ```typescript // 正确 - 只添加 ValidationPipe // Filter 和 Interceptor 由 AppModule 通过 APP_INTERCEPTOR 提供 app.useGlobalPipes(new ValidationPipe({...})); ``` ### 问题 4: Prisma Client 未生成 **症状**: `Cannot find module '@prisma/client'` **解决方案**: ```bash npx prisma generate ``` --- ## 测试覆盖率 ### 生成覆盖率报告 ```bash npm run test:cov ``` ### 覆盖率目标 | 层级 | 语句覆盖 | 分支覆盖 | 函数覆盖 | |-----|---------|---------|---------| | Domain Layer | 90%+ | 85%+ | 90%+ | | Application Layer | 80%+ | 75%+ | 85%+ | | API Layer | 70%+ | 65%+ | 80%+ | ### 查看报告 ```bash # HTML 报告 open coverage/lcov-report/index.html # 终端摘要 npm run test:cov -- --coverageReporters="text-summary" ``` --- ## CI/CD 集成 ### GitHub Actions 配置 ```yaml # .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. 测试命名 ```typescript // 好的命名 it('should increase USDT balance when deposit is processed') it('should throw InsufficientBalanceError when balance is insufficient') // 避免的命名 it('test deposit') it('works') ``` ### 2. 测试隔离 ```typescript // 每个测试独立 beforeEach(() => { wallet = WalletAccount.createNew(UserId.create(1)); }); afterEach(async () => { await cleanupTestData(); }); ``` ### 3. 使用工厂函数 ```typescript // 创建测试数据的工厂函数 const createMockWallet = (userId: bigint, balance = 0) => { return WalletAccount.reconstruct({ walletId: BigInt(1), userId, usdtAvailable: new Decimal(balance), // ... }); }; ``` ### 4. 断言明确 ```typescript // 好的断言 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 调试配置 ```json // .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" } } ] } ``` ### 单个测试调试 ```bash # 只运行特定测试 npm test -- --testNamePattern="should process deposit" # 运行特定文件 npm test -- wallet-account.aggregate.spec.ts # 显示详细输出 npm test -- --verbose ```