Skip to content

第 12 篇:测试支持 —— 如果难测,那一定是架构错了

在前面的文章中,我们探讨了 API 版本与兼容性管理等重要话题。现在,让我们关注最后一个关键的横切关注点:测试支持。

测试是保障代码质量、减少缺陷、提高开发效率的重要手段。如果系统难以测试,往往说明架构设计存在问题。

如何 mock 租户上下文和权限?

在多租户系统中,测试需要模拟租户上下文和权限:

typescript
// 测试工具模块
@Module({
  providers: [
    TestTenantContext,
    TestAuthService,
    TestPermissionService,
  ],
  exports: [
    TestTenantContext,
    TestAuthService,
    TestPermissionService,
  ],
})
export class TestSupportModule {}

// 测试租户上下文
@Injectable()
export class TestTenantContext {
  private static store: Map<string, any> = new Map();

  static setTenantId(tenantId: string): void {
    const store = new Map<string, any>();
    store.set('tenantId', tenantId);
    this.store = store;
  }

  static getTenantId(): string | undefined {
    return this.store.get('tenantId');
  }

  static clear(): void {
    this.store.clear();
  }

  // 测试用的上下文运行器
  static runWithTenant<T>(tenantId: string, callback: () => T): T {
    const originalStore = this.store;
    this.setTenantId(tenantId);
    
    try {
      return callback();
    } finally {
      this.store = originalStore;
    }
  }
}

// 测试认证服务
@Injectable()
export class TestAuthService {
  private currentUser: TestUser | null = null;

  setCurrentUser(user: TestUser): void {
    this.currentUser = user;
  }

  getCurrentUser(): TestUser | null {
    return this.currentUser;
  }

  clear(): void {
    this.currentUser = null;
  }

  // 创建测试用户
  createTestUser(overrides: Partial<TestUser> = {}): TestUser {
    return {
      id: uuidv4(),
      username: 'testuser',
      email: 'test@example.com',
      tenantId: 'test-tenant',
      roles: ['user'],
      permissions: [],
      ...overrides,
    };
  }

  // 创建管理员用户
  createAdminUser(overrides: Partial<TestUser> = {}): TestUser {
    return this.createTestUser({
      roles: ['admin'],
      permissions: ['*'],
      ...overrides,
    });
  }
}

interface TestUser {
  id: string;
  username: string;
  email: string;
  tenantId: string;
  roles: string[];
  permissions: string[];
}

// 测试权限服务
@Injectable()
export class TestPermissionService {
  private permissionRules: Map<string, boolean> = new Map();

  // 设置特定权限规则
  setPermission(userId: string, resource: string, action: string, allowed: boolean): void {
    const key = `${userId}:${resource}:${action}`;
    this.permissionRules.set(key, allowed);
  }

  // 检查权限
  async checkPermission(user: TestUser, resource: string, action: string): Promise<boolean> {
    // 首先检查管理员权限
    if (user.roles.includes('admin')) {
      return true;
    }

    // 检查特定规则
    const key = `${user.id}:${resource}:${action}`;
    const rule = this.permissionRules.get(key);
    if (rule !== undefined) {
      return rule;
    }

    // 默认权限检查逻辑
    return this.defaultPermissionCheck(user, resource, action);
  }

  private defaultPermissionCheck(user: TestUser, resource: string, action: string): boolean {
    // 简化的默认权限逻辑
    // 用户只能操作自己的资源
    if (resource.startsWith(`user:${user.id}`)) {
      return true;
    }

    // 根据角色判断
    switch (action) {
      case 'read':
        return user.roles.includes('user') || user.roles.includes('viewer');
      case 'write':
        return user.roles.includes('user') || user.roles.includes('editor');
      case 'delete':
        return user.roles.includes('admin');
      default:
        return false;
    }
  }

  clear(): void {
    this.permissionRules.clear();
  }
}

// 测试中间件
@Injectable()
export class TestAuthMiddleware implements NestMiddleware {
  constructor(
    private readonly testAuthService: TestAuthService,
  ) {}

  use(req: Request, res: Response, next: NextFunction) {
    const user = this.testAuthService.getCurrentUser();
    
    if (user) {
      (req as any).user = user;
      (req as any).tenantId = user.tenantId;
    }
    
    next();
  }
}

// 测试守卫
@Injectable()
export class TestAuthGuard implements CanActivate {
  constructor(
    private readonly testAuthService: TestAuthService,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const user = this.testAuthService.getCurrentUser();
    
    if (!user) {
      throw new UnauthorizedException('Test user not set');
    }
    
    request.user = user;
    return true;
  }
}

// 测试权限守卫
@Injectable()
export class TestPermissionGuard implements CanActivate {
  constructor(
    private readonly testAuthService: TestAuthService,
    private readonly testPermissionService: TestPermissionService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = this.testAuthService.getCurrentUser();
    
    if (!user) {
      throw new UnauthorizedException('Test user not set');
    }
    
    // 获取资源和操作信息
    const handler = context.getHandler();
    const resource = Reflect.getMetadata('resource', handler) || 'default';
    const action = Reflect.getMetadata('action', handler) || 'read';
    
    const hasPermission = await this.testPermissionService.checkPermission(user, resource, action);
    
    if (!hasPermission) {
      throw new ForbiddenException('Insufficient permissions');
    }
    
    request.user = user;
    return true;
  }
}

集成测试中的数据隔离(每个测试用独立 tenant)

集成测试需要确保数据隔离,避免测试之间相互影响:

typescript
// 测试数据管理器
@Injectable()
export class TestDataManager {
  constructor(
    @InjectRepository(User) private readonly userRepository: Repository<User>,
    @InjectRepository(Order) private readonly orderRepository: Repository<Order>,
    @InjectRepository(Product) private readonly productRepository: Repository<Product>,
  ) {}

  // 创建测试租户环境
  async createTestTenant(): Promise<TestTenant> {
    const tenantId = `test-${uuidv4()}`;
    
    // 创建测试用户
    const user = this.userRepository.create({
      username: `testuser-${tenantId}`,
      email: `test-${tenantId}@example.com`,
      tenantId,
      roles: ['user'],
    });
    await this.userRepository.save(user);
    
    // 创建测试产品
    const product = this.productRepository.create({
      name: `Test Product ${tenantId}`,
      price: 100,
      stock: 10,
      tenantId,
    });
    await this.productRepository.save(product);
    
    return {
      id: tenantId,
      user,
      product,
    };
  }

  // 清理测试数据
  async cleanupTestTenant(tenantId: string): Promise<void> {
    // 按正确顺序删除数据以避免外键约束问题
    await this.orderRepository.delete({ tenantId });
    await this.productRepository.delete({ tenantId });
    await this.userRepository.delete({ tenantId });
  }

  // 清理所有测试数据
  async cleanupAllTestData(): Promise<void> {
    // 删除所有测试租户数据
    await this.orderRepository
      .createQueryBuilder()
      .delete()
      .where('tenantId LIKE :pattern', { pattern: 'test-%' })
      .execute();
      
    await this.productRepository
      .createQueryBuilder()
      .delete()
      .where('tenantId LIKE :pattern', { pattern: 'test-%' })
      .execute();
      
    await this.userRepository
      .createQueryBuilder()
      .delete()
      .where('tenantId LIKE :pattern', { pattern: 'test-%' })
      .execute();
  }
}

interface TestTenant {
  id: string;
  user: User;
  product: Product;
}

// 测试基类
export class IntegrationTestBase {
  protected testDataManager: TestDataManager;
  protected testTenantContext: TestTenantContext;
  protected testAuthService: TestAuthService;
  protected tenant: TestTenant;

  async beforeAll(): Promise<void> {
    // 初始化测试服务
    this.testDataManager = new TestDataManager();
    this.testTenantContext = new TestTenantContext();
    this.testAuthService = new TestAuthService();
  }

  async beforeEach(): Promise<void> {
    // 为每个测试创建独立的租户环境
    this.tenant = await this.testDataManager.createTestTenant();
    
    // 设置测试上下文
    this.testTenantContext.setTenantId(this.tenant.id);
    this.testAuthService.setCurrentUser(this.tenant.user);
  }

  async afterEach(): Promise<void> {
    // 清理测试数据
    if (this.tenant) {
      await this.testDataManager.cleanupTestTenant(this.tenant.id);
    }
    
    // 清理测试上下文
    this.testTenantContext.clear();
    this.testAuthService.clear();
  }

  async afterAll(): Promise<void> {
    // 清理所有测试数据
    await this.testDataManager.cleanupAllTestData();
  }
}

// 具体测试示例
describe('OrderService', () => {
  let orderService: OrderService;
  let testBase: IntegrationTestBase;

  beforeAll(async () => {
    testBase = new IntegrationTestBase();
    await testBase.beforeAll();
  });

  beforeEach(async () => {
    await testBase.beforeEach();
    // 初始化被测试的服务
    orderService = new OrderService();
  });

  afterEach(async () => {
    await testBase.afterEach();
  });

  afterAll(async () => {
    await testBase.afterAll();
  });

  it('should create order successfully', async () => {
    const createOrderDto: CreateOrderDto = {
      userId: testBase.tenant.user.id,
      items: [
        {
          productId: testBase.tenant.product.id,
          quantity: 1,
        },
      ],
    };

    const order = await orderService.createOrder(createOrderDto);
    
    expect(order).toBeDefined();
    expect(order.userId).toBe(testBase.tenant.user.id);
    expect(order.totalAmount).toBe(testBase.tenant.product.price);
  });

  it('should fail when insufficient stock', async () => {
    const createOrderDto: CreateOrderDto = {
      userId: testBase.tenant.user.id,
      items: [
        {
          productId: testBase.tenant.product.id,
          quantity: 999, // 超过库存
        },
      ],
    };

    await expect(orderService.createOrder(createOrderDto))
      .rejects
      .toThrow('Insufficient stock');
  });
});

契约测试保障微服务兼容性

在微服务架构中,契约测试确保服务间的接口兼容性:

typescript
// 契约测试框架
export interface ContractTest {
  name: string;
  provider: string;
  consumer: string;
  interactions: Interaction[];
}

export interface Interaction {
  description: string;
  request: {
    method: string;
    path: string;
    headers?: Record<string, string>;
    body?: any;
  };
  response: {
    status: number;
    headers?: Record<string, string>;
    body?: any;
  };
}

// 契约测试运行器
@Injectable()
export class ContractTestRunner {
  private contracts: Map<string, ContractTest> = new Map();

  // 注册契约
  registerContract(contract: ContractTest): void {
    const key = `${contract.provider}-${contract.consumer}`;
    this.contracts.set(key, contract);
  }

  // 运行契约测试
  async runContractTests(provider: string, consumer: string): Promise<ContractTestResult> {
    const key = `${provider}-${consumer}`;
    const contract = this.contracts.get(key);
    
    if (!contract) {
      throw new Error(`Contract not found for ${provider} -> ${consumer}`);
    }

    const results: InteractionTestResult[] = [];
    
    for (const interaction of contract.interactions) {
      try {
        const result = await this.testInteraction(interaction);
        results.push({
          interaction: interaction.description,
          passed: true,
          error: null,
          response: result,
        });
      } catch (error) {
        results.push({
          interaction: interaction.description,
          passed: false,
          error: error.message,
          response: null,
        });
      }
    }

    const allPassed = results.every(r => r.passed);
    
    return {
      contract: contract.name,
      provider,
      consumer,
      passed: allPassed,
      results,
      timestamp: new Date(),
    };
  }

  private async testInteraction(interaction: Interaction): Promise<any> {
    // 这里应该实际调用被测试的服务端点
    // 为简化示例,我们模拟响应
    
    // 模拟 HTTP 请求
    const response = await this.makeRequest(interaction.request);
    
    // 验证响应
    this.validateResponse(response, interaction.response);
    
    return response;
  }

  private async makeRequest(request: Interaction['request']): Promise<any> {
    // 模拟 HTTP 客户端
    // 在实际实现中,这里会使用真实的 HTTP 客户端
    
    // 模拟不同的响应
    switch (request.path) {
      case '/api/users/1':
        return {
          status: 200,
          headers: { 'content-type': 'application/json' },
          body: {
            id: '1',
            username: 'testuser',
            email: 'test@example.com',
          },
        };
      
      case '/api/orders':
        if (request.method === 'POST') {
          return {
            status: 201,
            headers: { 'content-type': 'application/json' },
            body: {
              id: '123',
              userId: '1',
              totalAmount: 100,
              status: 'created',
            },
          };
        }
        break;
    }
    
    // 默认响应
    return {
      status: 404,
      headers: { 'content-type': 'application/json' },
      body: { error: 'Not Found' },
    };
  }

  private validateResponse(actual: any, expected: Interaction['response']): void {
    // 验证状态码
    if (actual.status !== expected.status) {
      throw new Error(`Status mismatch: expected ${expected.status}, got ${actual.status}`);
    }

    // 验证响应体(简化验证)
    if (expected.body) {
      this.deepValidate(actual.body, expected.body);
    }
  }

  private deepValidate(actual: any, expected: any): void {
    if (typeof expected === 'object' && expected !== null) {
      for (const key in expected) {
        if (expected.hasOwnProperty(key)) {
          if (!(key in actual)) {
            throw new Error(`Missing property: ${key}`);
          }
          this.deepValidate(actual[key], expected[key]);
        }
      }
    } else {
      if (actual !== expected) {
        throw new Error(`Value mismatch: expected ${expected}, got ${actual}`);
      }
    }
  }
}

interface InteractionTestResult {
  interaction: string;
  passed: boolean;
  error: string | null;
  response: any;
}

interface ContractTestResult {
  contract: string;
  provider: string;
  consumer: string;
  passed: boolean;
  results: InteractionTestResult[];
  timestamp: Date;
}

// 用户服务契约定义
export const UserOrderServiceContract: ContractTest = {
  name: 'User-Order Service Contract',
  provider: 'user-service',
  consumer: 'order-service',
  interactions: [
    {
      description: 'Get user by ID',
      request: {
        method: 'GET',
        path: '/api/users/1',
        headers: {
          'Accept': 'application/json',
        },
      },
      response: {
        status: 200,
        headers: {
          'content-type': 'application/json',
        },
        body: {
          id: '1',
          username: 'testuser',
          email: 'test@example.com',
        },
      },
    },
    {
      description: 'Get non-existent user',
      request: {
        method: 'GET',
        path: '/api/users/999',
      },
      response: {
        status: 404,
        body: {
          error: 'User not found',
        },
      },
    },
  ],
};

// 契约测试服务
@Injectable()
export class ContractTestingService {
  constructor(
    private readonly contractTestRunner: ContractTestRunner,
  ) {
    // 注册契约
    this.contractTestRunner.registerContract(UserOrderServiceContract);
  }

  // 运行所有契约测试
  async runAllContractTests(): Promise<ContractTestResult[]> {
    const results: ContractTestResult[] = [];
    
    // 运行用户服务相关契约测试
    const userOrderResult = await this.contractTestRunner.runContractTests(
      'user-service',
      'order-service',
    );
    results.push(userOrderResult);
    
    return results;
  }
}

// 契约测试控制器
@Controller('contract-tests')
export class ContractTestController {
  constructor(
    private readonly contractTestingService: ContractTestingService,
  ) {}

  @Post('run')
  async runTests(): Promise<ContractTestResult[]> {
    return await this.contractTestingService.runAllContractTests();
  }
}

创业团队的测试金字塔:重单元,轻 E2E

创业团队应该遵循测试金字塔原则,合理分配测试资源:

typescript
// 测试配置服务
@Injectable()
export class TestConfigurationService {
  // 根据环境返回不同的测试配置
  getTestConfig(environment: 'unit' | 'integration' | 'e2e' = 'unit'): TestConfig {
    switch (environment) {
      case 'unit':
        return this.getUnitTestConfig();
      
      case 'integration':
        return this.getIntegrationTestConfig();
      
      case 'e2e':
        return this.getE2ETestConfig();
      
      default:
        return this.getUnitTestConfig();
    }
  }

  private getUnitTestConfig(): TestConfig {
    return {
      database: {
        type: 'sqlite',
        database: ':memory:',
        synchronize: true,
        dropSchema: true,
      },
      cache: {
        store: 'memory',
      },
      externalServices: {
        mock: true,
      },
      testTimeout: 5000,
      testConcurrency: 10,
    };
  }

  private getIntegrationTestConfig(): TestConfig {
    return {
      database: {
        type: 'postgres',
        host: 'localhost',
        port: 5433, // 测试数据库端口
        username: 'testuser',
        password: 'testpass',
        database: 'testdb',
        synchronize: true,
        dropSchema: true,
      },
      cache: {
        store: 'redis',
        host: 'localhost',
        port: 6380, // 测试 Redis 端口
      },
      externalServices: {
        mock: false,
      },
      testTimeout: 30000,
      testConcurrency: 5,
    };
  }

  private getE2ETestConfig(): TestConfig {
    return {
      database: {
        type: 'postgres',
        host: 'localhost',
        port: 5434, // E2E 测试数据库端口
        username: 'e2euser',
        password: 'e2epass',
        database: 'e2edb',
        synchronize: false, // 生产环境配置
      },
      cache: {
        store: 'redis',
        host: 'localhost',
        port: 6381, // E2E 测试 Redis 端口
      },
      externalServices: {
        mock: false,
      },
      testTimeout: 60000,
      testConcurrency: 1,
    };
  }
}

interface TestConfig {
  database: any;
  cache: any;
  externalServices: {
    mock: boolean;
  };
  testTimeout: number;
  testConcurrency: number;
}

// 测试工厂
@Injectable()
export class TestFactory {
  constructor(
    private readonly testConfigurationService: TestConfigurationService,
  ) {}

  // 创建单元测试应用
  async createUnitTestApp(): Promise<INestApplication> {
    const config = this.testConfigurationService.getTestConfig('unit');
    
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    })
    .overrideProvider(DatabaseConfigService)
    .useValue({
      getDatabaseConfig: () => config.database,
    })
    .overrideProvider(CacheConfigService)
    .useValue({
      getCacheConfig: () => config.cache,
    })
    .compile();

    const app = moduleRef.createNestApplication();
    await app.init();
    
    return app;
  }

  // 创建集成测试应用
  async createIntegrationTestApp(): Promise<INestApplication> {
    const config = this.testConfigurationService.getTestConfig('integration');
    
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    })
    .overrideProvider(DatabaseConfigService)
    .useValue({
      getDatabaseConfig: () => config.database,
    })
    .overrideProvider(CacheConfigService)
    .useValue({
      getCacheConfig: () => config.cache,
    })
    .compile();

    const app = moduleRef.createNestApplication();
    await app.init();
    
    return app;
  }

  // 创建 E2E 测试应用
  async createE2ETestApp(): Promise<INestApplication> {
    const config = this.testConfigurationService.getTestConfig('e2e');
    
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    })
    .overrideProvider(DatabaseConfigService)
    .useValue({
      getDatabaseConfig: () => config.database,
    })
    .overrideProvider(CacheConfigService)
    .useValue({
      getCacheConfig: () => config.cache,
    })
    .compile();

    const app = moduleRef.createNestApplication();
    await app.init();
    
    return app;
  }
}

// 测试指标收集
@Injectable()
export class TestMetricsCollector {
  private readonly metrics: TestMetrics = {
    unitTests: { total: 0, passed: 0, failed: 0, duration: 0 },
    integrationTests: { total: 0, passed: 0, failed: 0, duration: 0 },
    e2eTests: { total: 0, passed: 0, failed: 0, duration: 0 },
  };

  recordUnitTest(result: TestResult, duration: number): void {
    this.metrics.unitTests.total++;
    if (result.passed) {
      this.metrics.unitTests.passed++;
    } else {
      this.metrics.unitTests.failed++;
    }
    this.metrics.unitTests.duration += duration;
  }

  recordIntegrationTest(result: TestResult, duration: number): void {
    this.metrics.integrationTests.total++;
    if (result.passed) {
      this.metrics.integrationTests.passed++;
    } else {
      this.metrics.integrationTests.failed++;
    }
    this.metrics.integrationTests.duration += duration;
  }

  recordE2ETest(result: TestResult, duration: number): void {
    this.metrics.e2eTests.total++;
    if (result.passed) {
      this.metrics.e2eTests.passed++;
    } else {
      this.metrics.e2eTests.failed++;
    }
    this.metrics.e2eTests.duration += duration;
  }

  getMetrics(): TestMetrics {
    return { ...this.metrics };
  }

  getTestPyramidHealth(): TestPyramidHealth {
    const unitCoverage = this.metrics.unitTests.total > 0 
      ? this.metrics.unitTests.passed / this.metrics.unitTests.total 
      : 1;
      
    const integrationCoverage = this.metrics.integrationTests.total > 0
      ? this.metrics.integrationTests.passed / this.metrics.integrationTests.total
      : 1;
      
    const e2eCoverage = this.metrics.e2eTests.total > 0
      ? this.metrics.e2eTests.passed / this.metrics.e2eTests.total
      : 1;

    return {
      unit: {
        count: this.metrics.unitTests.total,
        coverage: unitCoverage,
        health: unitCoverage >= 0.9 ? 'healthy' : 'needs_attention',
      },
      integration: {
        count: this.metrics.integrationTests.total,
        coverage: integrationCoverage,
        health: integrationCoverage >= 0.8 ? 'healthy' : 'needs_attention',
      },
      e2e: {
        count: this.metrics.e2eTests.total,
        coverage: e2eCoverage,
        health: e2eCoverage >= 0.7 ? 'healthy' : 'needs_attention',
      },
      pyramidBalance: this.checkPyramidBalance(),
    };
  }

  private checkPyramidBalance(): PyramidBalance {
    const unitCount = this.metrics.unitTests.total;
    const integrationCount = this.metrics.integrationTests.total;
    const e2eCount = this.metrics.e2eTests.total;
    
    const total = unitCount + integrationCount + e2eCount;
    
    if (total === 0) {
      return 'balanced';
    }
    
    const unitRatio = unitCount / total;
    const integrationRatio = integrationCount / total;
    const e2eRatio = e2eCount / total;
    
    // 理想比例:70% 单元测试,20% 集成测试,10% E2E 测试
    if (unitRatio >= 0.6 && integrationRatio >= 0.15 && e2eRatio <= 0.15) {
      return 'balanced';
    }
    
    if (e2eRatio > 0.2) {
      return 'top_heavy';
    }
    
    if (unitRatio < 0.5) {
      return 'bottom_light';
    }
    
    return 'imbalanced';
  }
}

interface TestMetrics {
  unitTests: TestStats;
  integrationTests: TestStats;
  e2eTests: TestStats;
}

interface TestStats {
  total: number;
  passed: number;
  failed: number;
  duration: number;
}

interface TestResult {
  passed: boolean;
  error?: string;
}

interface TestPyramidHealth {
  unit: TestLayerHealth;
  integration: TestLayerHealth;
  e2e: TestLayerHealth;
  pyramidBalance: PyramidBalance;
}

interface TestLayerHealth {
  count: number;
  coverage: number;
  health: 'healthy' | 'needs_attention';
}

type PyramidBalance = 'balanced' | 'top_heavy' | 'bottom_light' | 'imbalanced';

// 测试健康检查控制器
@Controller('test-health')
export class TestHealthController {
  constructor(
    private readonly testMetricsCollector: TestMetricsCollector,
  ) {}

  @Get()
  getTestHealth(): TestPyramidHealth {
    return this.testMetricsCollector.getTestPyramidHealth();
  }
}

创业团队行动清单

  1. 立即行动

    • 建立测试数据隔离机制
    • 实现租户上下文和权限模拟
    • 创建基础测试工厂和配置
  2. 一周内完成

    • 实现契约测试框架
    • 建立测试指标收集机制
    • 添加测试健康检查接口
  3. 一月内完善

    • 建立完整的测试金字塔
    • 实现自动化测试部署
    • 添加测试覆盖率监控

总结

测试支持是保障代码质量和系统稳定性的重要手段,正确的实现需要:

  1. 测试环境隔离:确保测试数据不会相互污染
  2. 上下文模拟:能够模拟各种运行时上下文
  3. 契约测试:保障微服务间的接口兼容性
  4. 测试金字塔:合理分配不同层次的测试资源

通过建立完善的测试支持体系,可以大大提高代码质量,减少缺陷,提升开发效率。