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

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: 数据库连接失败

症状: ECONNREFUSEDConnection 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