第 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();
}
}创业团队行动清单
立即行动:
- 建立测试数据隔离机制
- 实现租户上下文和权限模拟
- 创建基础测试工厂和配置
一周内完成:
- 实现契约测试框架
- 建立测试指标收集机制
- 添加测试健康检查接口
一月内完善:
- 建立完整的测试金字塔
- 实现自动化测试部署
- 添加测试覆盖率监控
总结
测试支持是保障代码质量和系统稳定性的重要手段,正确的实现需要:
- 测试环境隔离:确保测试数据不会相互污染
- 上下文模拟:能够模拟各种运行时上下文
- 契约测试:保障微服务间的接口兼容性
- 测试金字塔:合理分配不同层次的测试资源
通过建立完善的测试支持体系,可以大大提高代码质量,减少缺陷,提升开发效率。