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

22 KiB

测试指南

1. 测试策略概述

1.1 测试金字塔

        ┌───────────┐
        │   E2E     │  ← 端到端测试 (少量)
        │   Tests   │
       ─┴───────────┴─
      ┌───────────────┐
      │  Integration  │  ← 集成测试 (中等)
      │    Tests      │
     ─┴───────────────┴─
    ┌───────────────────┐
    │    Unit Tests     │  ← 单元测试 (大量)
    └───────────────────┘

1.2 测试类型分布

测试类型 数量 覆盖范围 执行时间
单元测试 20+ 领域层、值对象、聚合根 ~2s
集成测试 11+ 数据库仓储、应用服务 ~10s
E2E 测试 12+ HTTP API、完整流程 ~15s
Docker 测试 31+ 容器化环境全量测试 ~60s

2. 单元测试

2.1 测试范围

单元测试主要覆盖:

  • 值对象 (Value Objects): 不可变性、验证逻辑、相等性比较
  • 聚合根 (Aggregates): 业务规则、状态变更、领域事件
  • 领域服务: 业务逻辑计算

2.2 目录结构

src/
├── domain/
│   ├── value-objects/
│   │   ├── date-range.vo.ts
│   │   ├── date-range.spec.ts      ← 值对象单元测试
│   │   ├── report-period.vo.ts
│   │   ├── report-period.spec.ts
│   │   ├── export-format.vo.ts
│   │   └── export-format.spec.ts
│   └── aggregates/
│       ├── report-definition/
│       │   ├── report-definition.aggregate.ts
│       │   └── report-definition.spec.ts  ← 聚合根单元测试
│       └── report-snapshot/
│           ├── report-snapshot.aggregate.ts
│           └── report-snapshot.spec.ts

2.3 值对象测试示例

// src/domain/value-objects/date-range.spec.ts
describe('DateRange Value Object', () => {
  describe('create', () => {
    it('should create valid date range', () => {
      const startDate = new Date('2024-01-01');
      const endDate = new Date('2024-01-31');

      const dateRange = DateRange.create(startDate, endDate);

      expect(dateRange.startDate).toEqual(startDate);
      expect(dateRange.endDate).toEqual(endDate);
    });

    it('should throw error when start date is after end date', () => {
      const startDate = new Date('2024-01-31');
      const endDate = new Date('2024-01-01');

      expect(() => DateRange.create(startDate, endDate))
        .toThrow('Start date cannot be after end date');
    });
  });

  describe('equals', () => {
    it('should return true for equal date ranges', () => {
      const range1 = DateRange.create(
        new Date('2024-01-01'),
        new Date('2024-01-31')
      );
      const range2 = DateRange.create(
        new Date('2024-01-01'),
        new Date('2024-01-31')
      );

      expect(range1.equals(range2)).toBe(true);
    });
  });

  describe('getDays', () => {
    it('should calculate correct number of days', () => {
      const dateRange = DateRange.create(
        new Date('2024-01-01'),
        new Date('2024-01-31')
      );

      expect(dateRange.getDays()).toBe(31);
    });
  });
});

2.4 聚合根测试示例

// src/domain/aggregates/report-snapshot/report-snapshot.spec.ts
describe('ReportSnapshot Aggregate', () => {
  const mockDefinition = ReportDefinition.reconstitute({
    id: 1n,
    code: 'RPT_TEST',
    name: 'Test Report',
    description: 'Test description',
    category: 'TEST',
    dataSource: 'test_table',
    isActive: true,
    createdAt: new Date(),
    updatedAt: new Date(),
  });

  describe('create', () => {
    it('should create snapshot with pending status', () => {
      const snapshot = ReportSnapshot.create({
        definition: mockDefinition,
        period: ReportPeriod.DAILY,
        dateRange: DateRange.create(
          new Date('2024-01-01'),
          new Date('2024-01-01')
        ),
      });

      expect(snapshot.status).toBe(SnapshotStatus.PENDING);
      expect(snapshot.reportCode).toBe('RPT_TEST');
    });
  });

  describe('markAsProcessing', () => {
    it('should transition from pending to processing', () => {
      const snapshot = createPendingSnapshot();

      snapshot.markAsProcessing();

      expect(snapshot.status).toBe(SnapshotStatus.PROCESSING);
    });

    it('should throw error if not in pending status', () => {
      const snapshot = createCompletedSnapshot();

      expect(() => snapshot.markAsProcessing())
        .toThrow('Cannot start processing');
    });
  });

  describe('complete', () => {
    it('should set data and mark as completed', () => {
      const snapshot = createProcessingSnapshot();
      const data = { items: [{ id: 1 }], total: 1 };

      snapshot.complete(data);

      expect(snapshot.status).toBe(SnapshotStatus.COMPLETED);
      expect(snapshot.snapshotData).toEqual(data);
    });
  });
});

2.5 运行单元测试

# 运行所有单元测试
npm test

# 运行特定文件
npm test -- date-range.spec.ts

# Watch 模式
npm run test:watch

# 带覆盖率
npm run test:cov

3. 集成测试

3.1 测试范围

集成测试覆盖:

  • 仓储实现: Prisma 数据库操作
  • 应用服务: 命令/查询处理
  • 缓存服务: Redis 缓存操作

3.2 目录结构

src/
├── infrastructure/
│   └── repositories/
│       ├── prisma-report-definition.repository.ts
│       └── prisma-report-definition.repository.integration.spec.ts
├── application/
│   └── services/
│       ├── reporting-application.service.ts
│       └── reporting-application.service.integration.spec.ts

3.3 数据库集成测试

// prisma-report-definition.repository.integration.spec.ts
describe('PrismaReportDefinitionRepository (Integration)', () => {
  let repository: PrismaReportDefinitionRepository;
  let prismaService: PrismaService;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      providers: [
        PrismaReportDefinitionRepository,
        PrismaService,
      ],
    }).compile();

    repository = module.get(PrismaReportDefinitionRepository);
    prismaService = module.get(PrismaService);
  });

  beforeEach(async () => {
    // 清理测试数据
    await prismaService.reportSnapshot.deleteMany();
    await prismaService.reportDefinition.deleteMany();
  });

  afterAll(async () => {
    await prismaService.$disconnect();
  });

  describe('save', () => {
    it('should persist report definition to database', async () => {
      const definition = ReportDefinition.create({
        code: 'RPT_TEST',
        name: 'Test Report',
        description: 'Test',
        category: 'TEST',
        dataSource: 'test_table',
      });

      await repository.save(definition);

      const found = await prismaService.reportDefinition.findUnique({
        where: { code: 'RPT_TEST' },
      });
      expect(found).not.toBeNull();
      expect(found?.name).toBe('Test Report');
    });
  });

  describe('findByCode', () => {
    it('should return null for non-existent code', async () => {
      const result = await repository.findByCode('NON_EXISTENT');
      expect(result).toBeNull();
    });

    it('should return definition when exists', async () => {
      // 创建测试数据
      await prismaService.reportDefinition.create({
        data: {
          code: 'RPT_FIND',
          name: 'Find Test',
          description: 'Test',
          category: 'TEST',
          dataSource: 'test_table',
          isActive: true,
        },
      });

      const result = await repository.findByCode('RPT_FIND');

      expect(result).not.toBeNull();
      expect(result?.code).toBe('RPT_FIND');
    });
  });
});

3.4 应用服务集成测试

// reporting-application.service.integration.spec.ts
describe('ReportingApplicationService (Integration)', () => {
  let service: ReportingApplicationService;
  let prismaService: PrismaService;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    service = module.get(ReportingApplicationService);
    prismaService = module.get(PrismaService);
  });

  beforeEach(async () => {
    await cleanDatabase(prismaService);
    await seedTestData(prismaService);
  });

  describe('generateReport', () => {
    it('should create snapshot for valid report', async () => {
      const result = await service.generateReport({
        reportCode: 'RPT_LEADERBOARD',
        reportPeriod: ReportPeriod.DAILY,
        startDate: new Date('2024-01-01'),
        endDate: new Date('2024-01-01'),
      });

      expect(result).toBeDefined();
      expect(result.status).toBe(SnapshotStatus.COMPLETED);
    });

    it('should throw when report definition not found', async () => {
      await expect(
        service.generateReport({
          reportCode: 'NON_EXISTENT',
          reportPeriod: ReportPeriod.DAILY,
          startDate: new Date('2024-01-01'),
          endDate: new Date('2024-01-01'),
        })
      ).rejects.toThrow(ReportDefinitionNotFoundException);
    });
  });
});

3.5 测试数据库配置

# .env.test
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public
REDIS_HOST=localhost
REDIS_PORT=6380

3.6 运行集成测试

# 启动测试依赖
docker compose -f docker-compose.test.yml up -d postgres-test redis-test

# 推送 Schema
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rwadurian_reporting_test?schema=public" \
  npx prisma db push

# 运行集成测试
npm run test:integration

# 或使用 Makefile
make test-integration

4. E2E 测试

4.1 测试范围

E2E 测试覆盖:

  • HTTP API 端点
  • 请求验证
  • 响应格式
  • 错误处理
  • 完整业务流程

4.2 目录结构

test/
├── app.e2e-spec.ts          # 主 E2E 测试文件
├── jest-e2e.json            # E2E Jest 配置
└── setup-e2e.ts             # E2E 测试设置

4.3 E2E 测试配置

// test/jest-e2e.json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "testTimeout": 30000,
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  },
  "setupFilesAfterEnv": ["<rootDir>/setup-e2e.ts"],
  "moduleNameMapper": {
    "^@/(.*)$": "<rootDir>/../src/$1",
    "^@domain/(.*)$": "<rootDir>/../src/domain/$1",
    "^@application/(.*)$": "<rootDir>/../src/application/$1",
    "^@infrastructure/(.*)$": "<rootDir>/../src/infrastructure/$1",
    "^@api/(.*)$": "<rootDir>/../src/api/$1"
  }
}
// test/setup-e2e.ts
import * as dotenv from 'dotenv';
import * as path from 'path';

dotenv.config({ path: path.resolve(__dirname, '../.env.test') });

4.4 E2E 测试示例

// test/app.e2e-spec.ts
describe('Reporting Service (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.setGlobalPrefix('api/v1');
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );
    // 注意: TransformInterceptor 已在 AppModule 中注册
    // 不要重复注册,避免响应双重包装
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('Health Check', () => {
    it('/api/v1/health (GET) should return ok 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');
          expect(res.body.data.service).toBe('reporting-service');
        });
    });
  });

  describe('Reports API', () => {
    describe('POST /api/v1/reports/generate', () => {
      it('should validate request body', () => {
        return request(app.getHttpServer())
          .post('/api/v1/reports/generate')
          .send({})
          .expect(400);
      });

      it('should reject invalid report period', () => {
        return request(app.getHttpServer())
          .post('/api/v1/reports/generate')
          .send({
            reportCode: 'RPT_LEADERBOARD',
            reportPeriod: 'INVALID_PERIOD',
            startDate: '2024-01-01',
            endDate: '2024-01-31',
          })
          .expect(400);
      });
    });
  });

  describe('Export API', () => {
    describe('POST /api/v1/export', () => {
      it('should validate request body', () => {
        return request(app.getHttpServer())
          .post('/api/v1/export')
          .send({})
          .expect(400);
      });

      it('should reject invalid format', () => {
        return request(app.getHttpServer())
          .post('/api/v1/export')
          .send({
            snapshotId: '1',
            format: 'INVALID_FORMAT',
          })
          .expect(400);
      });
    });
  });
});

4.5 运行 E2E 测试

# 确保测试数据库运行
docker compose -f docker-compose.test.yml up -d postgres-test redis-test

# 运行 E2E 测试
npm run test:e2e

# 或使用 Makefile
make test-e2e

5. Docker 测试

5.1 测试架构

┌─────────────────────────────────────────────────────────┐
│                   Docker Network                         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │  PostgreSQL  │  │    Redis     │  │   Service    │  │
│  │    :5432     │  │    :6379     │  │    Test      │  │
│  │   (tmpfs)    │  │              │  │  Container   │  │
│  └──────────────┘  └──────────────┘  └──────────────┘  │
└─────────────────────────────────────────────────────────┘

5.2 Docker Compose 配置

# docker-compose.test.yml
version: '3.8'

services:
  postgres-test:
    image: postgres:15-alpine
    container_name: reporting-postgres-test
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: rwadurian_reporting_test
    ports:
      - "5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    tmpfs:
      - /var/lib/postgresql/data  # 使用内存存储加速测试

  redis-test:
    image: redis:7-alpine
    container_name: reporting-redis-test
    ports:
      - "6380:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

  reporting-service-test:
    build:
      context: .
      dockerfile: Dockerfile.test
    container_name: reporting-service-test
    depends_on:
      postgres-test:
        condition: service_healthy
      redis-test:
        condition: service_healthy
    environment:
      NODE_ENV: test
      DATABASE_URL: postgresql://postgres:postgres@postgres-test:5432/rwadurian_reporting_test?schema=public
      REDIS_HOST: redis-test
      REDIS_PORT: 6379
      JWT_SECRET: test-secret-key
    volumes:
      - ./coverage:/app/coverage
    command: sh -c "npx prisma db push --skip-generate && npm run test:cov"

5.3 测试 Dockerfile

# Dockerfile.test
FROM node:20-alpine

WORKDIR /app

# 安装 OpenSSL (Prisma 依赖)
RUN apk add --no-cache openssl openssl-dev

# 安装依赖
COPY package*.json ./
RUN npm ci

# 复制 Prisma Schema
COPY prisma ./prisma/

# 生成 Prisma Client
RUN npx prisma generate

# 复制源代码
COPY . .

# 构建应用
RUN npm run build

# 默认运行测试
CMD ["npm", "test"]

5.4 .dockerignore

node_modules
dist
coverage
.git
.env*
!.env.example
*.log
.claude/

5.5 运行 Docker 测试

# 构建并运行所有测试
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit

# 查看测试结果
docker compose -f docker-compose.test.yml logs reporting-service-test

# 清理
docker compose -f docker-compose.test.yml down -v

# 使用 Makefile
make test-docker-all

6. Makefile 命令

# Makefile
.PHONY: test test-unit test-integration test-e2e test-docker-all

# 运行所有测试
test: test-unit test-integration test-e2e

# 单元测试
test-unit:
	npm test

# 集成测试
test-integration:
	npm run test:integration

# E2E 测试
test-e2e:
	npm run test:e2e

# Docker 中运行所有测试
test-docker-all:
	docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
	docker compose -f docker-compose.test.yml down -v

# 测试覆盖率
test-cov:
	npm run test:cov

# 清理测试容器
test-clean:
	docker compose -f docker-compose.test.yml down -v

7. 测试最佳实践

7.1 测试命名规范

describe('ClassName', () => {
  describe('methodName', () => {
    it('should [expected behavior] when [condition]', () => {
      // test implementation
    });
  });
});

7.2 AAA 模式

it('should create valid date range', () => {
  // Arrange - 准备测试数据
  const startDate = new Date('2024-01-01');
  const endDate = new Date('2024-01-31');

  // Act - 执行被测试的操作
  const dateRange = DateRange.create(startDate, endDate);

  // Assert - 验证结果
  expect(dateRange.startDate).toEqual(startDate);
  expect(dateRange.endDate).toEqual(endDate);
});

7.3 测试隔离

describe('Repository Integration Tests', () => {
  beforeEach(async () => {
    // 每个测试前清理数据
    await prismaService.reportSnapshot.deleteMany();
    await prismaService.reportDefinition.deleteMany();
  });

  afterAll(async () => {
    // 测试结束后断开连接
    await prismaService.$disconnect();
  });
});

7.4 Mock 使用

// 使用 Jest mock
const mockRepository = {
  findByCode: jest.fn(),
  save: jest.fn(),
  findAll: jest.fn(),
};

beforeEach(() => {
  jest.clearAllMocks();
});

it('should call repository with correct parameters', async () => {
  mockRepository.findByCode.mockResolvedValue(mockDefinition);

  await service.getReportDefinition('RPT_TEST');

  expect(mockRepository.findByCode).toHaveBeenCalledWith('RPT_TEST');
});

8. CI/CD 集成

8.1 GitHub Actions

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: rwadurian_reporting_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5          

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --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: Push database schema
        run: npx prisma db push
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public

      - name: Run unit tests
        run: npm test

      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public
          REDIS_HOST: localhost
          REDIS_PORT: 6379

      - name: Run E2E tests
        run: npm run test:e2e
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/rwadurian_reporting_test?schema=public
          REDIS_HOST: localhost
          REDIS_PORT: 6379

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

9. 故障排除

9.1 常见问题

Q: 测试找不到模块

# 重新生成 Prisma Client
npx prisma generate

Q: 数据库连接失败

# 检查容器状态
docker ps

# 检查数据库日志
docker logs reporting-postgres-test

Q: E2E 测试响应格式错误

// 确保不要重复注册 TransformInterceptor
// AppModule 中已注册,测试中不需要再次注册

Q: Docker 测试 Prisma 错误

# 确保 Dockerfile.test 包含 OpenSSL
RUN apk add --no-cache openssl openssl-dev

9.2 调试测试

# 运行单个测试文件
npm test -- --testPathPattern=date-range.spec.ts

# 详细输出
npm test -- --verbose

# 调试模式
node --inspect-brk node_modules/.bin/jest --runInBand