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

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