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

24 KiB
Raw Blame History

Presence Service 测试文档

1. 测试策略概述

本服务采用测试金字塔策略,包含三个层次的自动化测试:

        ┌───────────────┐
        │    E2E 测试    │  ← 少量,验证完整流程
        │   (20 tests)   │
        ├───────────────┤
        │   集成测试      │  ← 适量,验证模块协作
        │  (22 tests)    │
        ├───────────────┤
        │   单元测试      │  ← 大量,验证核心逻辑
        │  (123 tests)   │
        └───────────────┘
            测试金字塔

测试统计

测试类型 套件数 用例数 覆盖范围
单元测试 9 123 领域层
集成测试 3 22 应用层
E2E 测试 3 20 API 层
总计 15 165 -

2. 测试架构

2.1 目录结构

test/
├── setup.ts                    # 全局测试设置
├── unit/                       # 单元测试
│   ├── domain/
│   │   ├── entities/
│   │   │   ├── event-log.entity.spec.ts
│   │   │   └── online-snapshot.entity.spec.ts
│   │   ├── aggregates/
│   │   │   └── daily-active-stats.aggregate.spec.ts
│   │   ├── value-objects/
│   │   │   ├── install-id.vo.spec.ts
│   │   │   ├── event-name.vo.spec.ts
│   │   │   └── time-window.vo.spec.ts
│   │   └── services/
│   │       ├── online-detection.service.spec.ts
│   │       └── dau-calculation.service.spec.ts
│   └── shared/
│       └── filters/
│           └── global-exception.filter.spec.ts
├── integration/                # 集成测试
│   └── application/
│       ├── commands/
│       │   └── record-heartbeat.handler.spec.ts
│       └── queries/
│           ├── get-online-count.handler.spec.ts
│           └── get-online-history.handler.spec.ts
└── e2e/                        # E2E 测试
    ├── setup-e2e.ts            # E2E 测试设置
    ├── health.e2e-spec.ts
    ├── presence.e2e-spec.ts
    └── analytics.e2e-spec.ts

2.2 Jest 配置

// jest.config.js
const baseConfig = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: '.',
  transform: {
    '^.+\\.(t|j)s$': 'ts-jest',
  },
  testEnvironment: 'node',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

module.exports = {
  ...baseConfig,
  collectCoverageFrom: ['src/**/*.(t|j)s', '!src/main.ts', '!src/**/*.module.ts'],
  coverageDirectory: './coverage',
  projects: [
    {
      ...baseConfig,
      displayName: 'unit',
      testMatch: ['<rootDir>/test/unit/**/*.spec.ts'],
      setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
    },
    {
      ...baseConfig,
      displayName: 'integration',
      testMatch: ['<rootDir>/test/integration/**/*.spec.ts'],
      setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
    },
    {
      ...baseConfig,
      displayName: 'e2e',
      testMatch: ['<rootDir>/test/e2e/**/*.(spec|e2e-spec).ts'],
      setupFilesAfterEnv: ['<rootDir>/test/setup.ts', '<rootDir>/test/e2e/setup-e2e.ts'],
    },
  ],
};

3. 单元测试

3.1 测试目标

单元测试覆盖领域层的核心业务逻辑:

  • 值对象的创建和校验
  • 实体的行为和状态变化
  • 聚合根的业务规则
  • 领域服务的计算逻辑

3.2 测试原则

  1. 隔离性: 不依赖外部服务数据库、Redis、网络
  2. 快速性: 毫秒级执行
  3. 可重复性: 每次执行结果一致
  4. 自包含: 不依赖其他测试的状态

3.3 示例:值对象测试

// test/unit/domain/value-objects/install-id.vo.spec.ts
import { InstallId } from '@/domain/value-objects/install-id.vo';

describe('InstallId', () => {
  describe('fromString', () => {
    it('should create InstallId with valid value', () => {
      const installId = InstallId.fromString('valid-install-id-123');
      expect(installId.value).toBe('valid-install-id-123');
    });

    it('should throw error for empty value', () => {
      expect(() => InstallId.fromString('')).toThrow();
    });

    it('should throw error for value shorter than 8 characters', () => {
      expect(() => InstallId.fromString('short')).toThrow();
    });

    it('should throw error for value longer than 64 characters', () => {
      const longValue = 'a'.repeat(65);
      expect(() => InstallId.fromString(longValue)).toThrow();
    });

    it('should throw error for value with invalid characters', () => {
      expect(() => InstallId.fromString('invalid@id#123')).toThrow();
    });
  });

  describe('equals', () => {
    it('should return true for same value', () => {
      const id1 = InstallId.fromString('test-install-id');
      const id2 = InstallId.fromString('test-install-id');
      expect(id1.equals(id2)).toBe(true);
    });

    it('should return false for different values', () => {
      const id1 = InstallId.fromString('test-install-id-1');
      const id2 = InstallId.fromString('test-install-id-2');
      expect(id1.equals(id2)).toBe(false);
    });
  });
});

3.4 示例:领域服务测试

// test/unit/domain/services/online-detection.service.spec.ts
import { OnlineDetectionService } from '@/domain/services/online-detection.service';

describe('OnlineDetectionService', () => {
  let service: OnlineDetectionService;

  beforeEach(() => {
    service = new OnlineDetectionService();
  });

  describe('isOnline', () => {
    it('should return true when heartbeat is within window', () => {
      const now = Date.now();
      const lastHeartbeat = new Date(now - 60 * 1000); // 1 minute ago
      const windowSeconds = 180; // 3 minutes

      expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(true);
    });

    it('should return false when heartbeat is outside window', () => {
      const now = Date.now();
      const lastHeartbeat = new Date(now - 200 * 1000); // 200 seconds ago
      const windowSeconds = 180; // 3 minutes

      expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(false);
    });

    it('should return true for heartbeat exactly at window boundary', () => {
      const now = Date.now();
      const lastHeartbeat = new Date(now - 180 * 1000); // exactly 3 minutes ago
      const windowSeconds = 180;

      expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(true);
    });
  });

  describe('calculateThresholdTime', () => {
    it('should calculate correct threshold time', () => {
      jest.useFakeTimers();
      const now = new Date('2025-01-01T12:00:00Z');
      jest.setSystemTime(now);

      const threshold = service.calculateThresholdTime(180);

      expect(threshold.getTime()).toBe(now.getTime() - 180 * 1000);

      jest.useRealTimers();
    });
  });
});

3.5 示例:聚合根测试

// test/unit/domain/aggregates/daily-active-stats.aggregate.spec.ts
import { DailyActiveStats } from '@/domain/aggregates/daily-active-stats/daily-active-stats.aggregate';

describe('DailyActiveStats', () => {
  describe('create', () => {
    it('should create new stats with initial values', () => {
      const day = new Date('2025-01-01');
      const stats = DailyActiveStats.create(day);

      expect(stats.day).toEqual(day);
      expect(stats.dauCount).toBe(0);
      expect(stats.version).toBe(1);
    });
  });

  describe('updateStats', () => {
    it('should update dau count and increment version', () => {
      const stats = DailyActiveStats.create(new Date('2025-01-01'));
      const byProvince = new Map([['广东省', 1000]]);
      const byCity = new Map([['深圳市', 500]]);

      stats.updateStats(5000, byProvince, byCity);

      expect(stats.dauCount).toBe(5000);
      expect(stats.dauByProvince.get('广东省')).toBe(1000);
      expect(stats.dauByCity.get('深圳市')).toBe(500);
      expect(stats.version).toBe(2);
    });

    it('should throw error for negative count', () => {
      const stats = DailyActiveStats.create(new Date('2025-01-01'));

      expect(() => stats.updateStats(-1, new Map(), new Map())).toThrow();
    });
  });

  describe('reconstitute', () => {
    it('should reconstitute from persistence data', () => {
      const data = {
        day: new Date('2025-01-01'),
        dauCount: 5000,
        dauByProvince: new Map([['广东省', 1000]]),
        dauByCity: new Map([['深圳市', 500]]),
        calculatedAt: new Date(),
        version: 3,
      };

      const stats = DailyActiveStats.reconstitute(data);

      expect(stats.dauCount).toBe(5000);
      expect(stats.version).toBe(3);
    });
  });
});

4. 集成测试

4.1 测试目标

集成测试验证应用层的 Command/Query Handler

  • Handler 与 Mock 仓储的协作
  • 业务流程的正确性
  • 事务边界

4.2 测试策略

  • Mock 外部依赖仓储、Redis、Kafka
  • 使用 NestJS Testing Module
  • 验证 Handler 的输入输出

4.3 示例Command Handler 测试

// test/integration/application/commands/record-heartbeat.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { RecordHeartbeatHandler } from '@/application/commands/record-heartbeat/record-heartbeat.handler';
import { RecordHeartbeatCommand } from '@/application/commands/record-heartbeat/record-heartbeat.command';
import { PresenceRedisRepository } from '@/infrastructure/redis/presence-redis.repository';
import { KafkaEventPublisher } from '@/infrastructure/kafka/kafka-event.publisher';

describe('RecordHeartbeatHandler', () => {
  let handler: RecordHeartbeatHandler;
  let mockRedisRepo: jest.Mocked<PresenceRedisRepository>;
  let mockKafkaPublisher: jest.Mocked<KafkaEventPublisher>;

  beforeEach(async () => {
    mockRedisRepo = {
      updateUserPresence: jest.fn(),
    } as any;

    mockKafkaPublisher = {
      publish: jest.fn(),
    } as any;

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        RecordHeartbeatHandler,
        { provide: PresenceRedisRepository, useValue: mockRedisRepo },
        { provide: KafkaEventPublisher, useValue: mockKafkaPublisher },
      ],
    }).compile();

    handler = module.get(RecordHeartbeatHandler);
  });

  describe('execute', () => {
    it('should update presence and publish event', async () => {
      const command = new RecordHeartbeatCommand(
        BigInt(12345),
        'test-install-id',
        '1.0.0',
        Date.now(),
      );

      await handler.execute(command);

      expect(mockRedisRepo.updateUserPresence).toHaveBeenCalledWith(
        '12345',
        expect.any(Number),
      );
      expect(mockKafkaPublisher.publish).toHaveBeenCalled();
    });

    it('should handle missing userId gracefully', async () => {
      const command = new RecordHeartbeatCommand(
        undefined,
        'test-install-id',
        '1.0.0',
        Date.now(),
      );

      await handler.execute(command);

      expect(mockRedisRepo.updateUserPresence).toHaveBeenCalledWith(
        'test-install-id', // fallback to installId
        expect.any(Number),
      );
    });
  });
});

4.4 示例Query Handler 测试

// test/integration/application/queries/get-online-history.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { GetOnlineHistoryHandler } from '@/application/queries/get-online-history/get-online-history.handler';
import { GetOnlineHistoryQuery } from '@/application/queries/get-online-history/get-online-history.query';
import { ONLINE_SNAPSHOT_REPOSITORY } from '@/domain/repositories/online-snapshot.repository.interface';
import { OnlineSnapshot } from '@/domain/entities/online-snapshot.entity';

describe('GetOnlineHistoryHandler', () => {
  let handler: GetOnlineHistoryHandler;
  let mockRepo: any;

  beforeEach(async () => {
    mockRepo = {
      findByTimeRange: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        GetOnlineHistoryHandler,
        { provide: ONLINE_SNAPSHOT_REPOSITORY, useValue: mockRepo },
      ],
    }).compile();

    handler = module.get(GetOnlineHistoryHandler);
  });

  describe('execute', () => {
    it('should return online history with summary', async () => {
      const snapshots = [
        createSnapshot(new Date('2025-01-01T12:00:00Z'), 1000),
        createSnapshot(new Date('2025-01-01T12:05:00Z'), 1200),
        createSnapshot(new Date('2025-01-01T12:10:00Z'), 1100),
      ];
      mockRepo.findByTimeRange.mockResolvedValue(snapshots);

      const query = new GetOnlineHistoryQuery(
        new Date('2025-01-01T12:00:00Z'),
        new Date('2025-01-01T13:00:00Z'),
        '5m',
      );

      const result = await handler.execute(query);

      expect(result.data).toHaveLength(3);
      expect(result.summary.max).toBe(1200);
      expect(result.summary.min).toBe(1000);
      expect(result.summary.avg).toBe(1100);
    });

    it('should return empty result for no data', async () => {
      mockRepo.findByTimeRange.mockResolvedValue([]);

      const query = new GetOnlineHistoryQuery(
        new Date('2025-01-01T12:00:00Z'),
        new Date('2025-01-01T13:00:00Z'),
        '5m',
      );

      const result = await handler.execute(query);

      expect(result.data).toHaveLength(0);
      expect(result.total).toBe(0);
    });
  });
});

function createSnapshot(ts: Date, onlineCount: number): OnlineSnapshot {
  return OnlineSnapshot.reconstitute({
    id: BigInt(Math.floor(Math.random() * 1000000)),
    ts,
    onlineCount,
    windowSeconds: 180,
  });
}

5. E2E 测试

5.1 测试目标

E2E 测试验证完整的 API 流程

  • HTTP 请求/响应
  • 认证和授权
  • 参数校验
  • 数据库交互

5.2 测试环境

E2E 测试需要真实的基础设施:

# docker-compose.test.yml
services:
  postgres-test:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: presence_test
    ports:
      - "5434:5432"

  redis-test:
    image: redis:7-alpine
    ports:
      - "6381:6379"

5.3 测试设置

// test/e2e/setup-e2e.ts
beforeAll(async () => {
  if (!process.env.DATABASE_URL) {
    console.warn('WARNING: DATABASE_URL not set.');
  }
  if (!process.env.REDIS_HOST) {
    console.warn('WARNING: REDIS_HOST not set.');
  }
});

5.4 示例API 测试

// test/e2e/presence.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
import { GlobalExceptionFilter } from '../../src/shared/filters/global-exception.filter';
import { JwtAuthGuard } from '../../src/shared/guards/jwt-auth.guard';

describe('Presence API (E2E)', () => {
  let app: INestApplication;
  const mockUserId = BigInt(12345);

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideGuard(JwtAuthGuard)
      .useValue({
        canActivate: (context: ExecutionContext) => {
          const req = context.switchToHttp().getRequest();
          req.user = { userId: mockUserId.toString() };
          return true;
        },
      })
      .compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalFilters(new GlobalExceptionFilter());
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: true,
    }));
    app.setGlobalPrefix('api/v1');

    await app.init();
  });

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

  describe('POST /api/v1/presence/heartbeat', () => {
    it('should record heartbeat successfully', async () => {
      const response = await request(app.getHttpServer())
        .post('/api/v1/presence/heartbeat')
        .send({
          installId: 'test-install-id-12345',
          appVersion: '1.0.0',
          clientTs: Date.now(),
        })
        .expect(201);

      expect(response.body).toHaveProperty('ok', true);
      expect(response.body).toHaveProperty('serverTs');
    });

    it('should validate installId type', async () => {
      const response = await request(app.getHttpServer())
        .post('/api/v1/presence/heartbeat')
        .send({
          installId: 12345, // Invalid: not a string
          appVersion: '1.0.0',
          clientTs: Date.now(),
        })
        .expect(400);

      expect(response.body.statusCode).toBe(400);
    });
  });

  describe('GET /api/v1/presence/online-count', () => {
    it('should return online count', async () => {
      const response = await request(app.getHttpServer())
        .get('/api/v1/presence/online-count')
        .expect(200);

      expect(response.body).toHaveProperty('count');
      expect(typeof response.body.count).toBe('number');
      expect(response.body).toHaveProperty('windowSeconds', 180);
    });
  });

  describe('GET /api/v1/presence/online-history', () => {
    it('should return online history', async () => {
      const response = await request(app.getHttpServer())
        .get('/api/v1/presence/online-history')
        .query({
          startTime: new Date(Date.now() - 3600000).toISOString(),
          endTime: new Date().toISOString(),
          interval: '5m',
        })
        .expect(200);

      expect(response.body).toHaveProperty('data');
      expect(Array.isArray(response.body.data)).toBe(true);
      expect(response.body).toHaveProperty('interval', '5m');
    });

    it('should validate interval enum', async () => {
      await request(app.getHttpServer())
        .get('/api/v1/presence/online-history')
        .query({
          startTime: new Date(Date.now() - 3600000).toISOString(),
          endTime: new Date().toISOString(),
          interval: '10m', // Invalid
        })
        .expect(400);
    });
  });
});

6. 运行测试

6.1 命令行运行

# 运行所有测试
npm test

# 运行单元测试
npm run test:unit

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

# 运行 E2E 测试 (需要 Docker)
npm run test:e2e

# 生成覆盖率报告
npm run test:cov

# 监视模式
npm run test:watch

# 运行特定测试文件
npm test -- --testPathPattern="install-id.vo.spec.ts"

6.2 使用 Make 命令

# 启动测试基础设施
make test-docker-up

# 运行单元测试
make test-unit

# 运行集成测试
make test-integration

# 运行 E2E 测试
make test-e2e

# 运行所有测试
make test-all

# 停止测试基础设施
make test-docker-down

# Docker 内运行全部测试
make test-docker-all

6.3 在 WSL2 中运行

# 进入 WSL2
wsl -d Ubuntu

# 设置环境变量并运行
export DATABASE_URL='postgresql://test:test@localhost:5434/presence_test'
export REDIS_HOST='localhost'
export REDIS_PORT='6381'

npm test

7. 覆盖率报告

7.1 生成报告

npm run test:cov

7.2 覆盖率阈值

// package.json
{
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

7.3 查看报告

覆盖率报告生成在 coverage/ 目录:

  • coverage/lcov-report/index.html - HTML 报告
  • coverage/lcov.info - LCOV 格式

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: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: presence_test
        ports:
          - 5434:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5          

      redis:
        image: redis:7-alpine
        ports:
          - 6381: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://test:test@localhost:5434/presence_test

      - name: Run unit tests
        run: npm run test:unit

      - name: Run integration tests
        run: npm run test:integration

      - name: Run E2E tests
        run: npm run test:e2e
        env:
          DATABASE_URL: postgresql://test:test@localhost:5434/presence_test
          REDIS_HOST: localhost
          REDIS_PORT: 6381

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

9. 测试最佳实践

9.1 命名规范

describe('ClassName/FunctionName', () => {
  describe('methodName', () => {
    it('should <expected behavior> when <condition>', () => {
      // Arrange - Given
      // Act - When
      // Assert - Then
    });
  });
});

9.2 AAA 模式

it('should calculate correct DAU count', () => {
  // Arrange (Given)
  const events = [createEvent('user1'), createEvent('user2')];
  const service = new DauCalculationService();

  // Act (When)
  const result = service.calculateDau(events);

  // Assert (Then)
  expect(result.total).toBe(2);
});

9.3 避免测试反模式

// ❌ 测试实现细节
it('should call repository.save once', () => {
  expect(mockRepo.save).toHaveBeenCalledTimes(1);
});

// ✅ 测试行为
it('should persist the event', async () => {
  await handler.execute(command);
  const saved = await mockRepo.findById(eventId);
  expect(saved).toBeDefined();
});

// ❌ 过度 Mock
it('should return mocked value', () => {
  mockService.calculate.mockReturnValue(100);
  expect(service.calculate()).toBe(100); // 测试的是 Mock不是代码
});

// ✅ 测试真实逻辑
it('should calculate correct value', () => {
  const service = new CalculationService();
  expect(service.calculate(10, 20)).toBe(30);
});

9.4 测试数据工厂

// test/factories/event-log.factory.ts
export function createEventLog(overrides: Partial<EventLogProps> = {}): EventLog {
  return EventLog.create({
    userId: BigInt(12345),
    installId: InstallId.fromString('test-install-id'),
    eventName: EventName.fromString('app_session_start'),
    eventTime: new Date(),
    properties: EventProperties.fromData({}),
    ...overrides,
  });
}