NestJS健康检查控制器单元测试HttpModule mock及偶现失败问题
问题根因
测试偶现失败、mock不生效有两个核心原因:
- 控制器存在实例级缓存逻辑:
healthExpiry、isHealthy、healthCheckResult三个变量会缓存30秒内的健康检查结果,测试时如果命中缓存分支,根本不会触发HTTP请求逻辑,完全靠实例初始化的时间差决定走哪条分支,自然会出现随机过/失败的情况。 - HTTP层mock不符合依赖要求:原有代码mock的是底层
HttpService,但返回值结构不符合HttpHealthIndicator的校验规则(HttpHealthIndicator要求Axios响应的status字段为2xx数字类型状态码,原有mock返回的是字符串'ok'),且mock逻辑写死在beforeEach中,无法灵活切换服务正常/宕机场景。
最优实现方案
单元测试只需要验证控制器本身的逻辑,不需要验证@nestjs/terminus自带的pingCheck能力是否正常,因此直接mock控制器直接依赖的HttpHealthIndicator即可,比mock底层HttpService更稳定,不会因为Terminus库内部实现变更导致测试失效。
同时需要在每个测试用例执行前主动重置缓存状态,强制触发健康检查逻辑,彻底消除偶现问题。
修正后的测试代码
import { Test, TestingModule } from '@nestjs/testing'; import { HealthController } from './health.controller'; import { ConfigModule } from '@nestjs/config'; import MockEnvConfig from '../../test/resources/MockEnvConfig'; import { AppConfigModule } from '@app-config/app-config.module'; import { TerminusModule, HealthCheckService, HttpHealthIndicator } from '@nestjs/terminus'; import { InternalServerErrorException } from '@nestjs/common'; import * as moment from 'moment'; jest.mock('@rooster/node-services'); describe('HealthController', () => { let controller: HealthController; let httpHealthIndicator: jest.Mocked<HttpHealthIndicator>; beforeEach(async () => { // 重置所有mock状态 jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ ignoreEnvVars: true, ignoreEnvFile: true, load: [MockEnvConfig], }), AppConfigModule, TerminusModule, ], controllers: [HealthController], providers: [ // 直接mock控制器依赖的HttpHealthIndicator,跳过底层HTTP实现 { provide: HttpHealthIndicator, useValue: { pingCheck: jest.fn(), }, }, ], }).compile(); controller = module.get<HealthController>(HealthController); httpHealthIndicator = module.get(HttpHealthIndicator); }); it('should be defined', () => { expect(controller).toBeDefined(); }); describe('checkHealth()', () => { // 每个用例执行前强制重置缓存,触发实际健康检查逻辑,消除偶现 beforeEach(() => { controller['healthExpiry'] = moment().subtract(1, 'second'); controller['isHealthy'] = false; controller['healthCheckResult'] = null; }); it('服务正常时返回ok状态与健康检查结果', async () => { const mockPingResult = { 'payments-service': { status: 'up' } }; httpHealthIndicator.pingCheck.mockResolvedValue(mockPingResult); const expectedResult = { status: 'ok', info: mockPingResult, error: {}, details: mockPingResult, }; const result = await controller.check(); expect(result).toEqual(expectedResult); expect(controller['isHealthy']).toBe(true); expect(httpHealthIndicator.pingCheck).toHaveBeenCalledTimes(1); expect(httpHealthIndicator.pingCheck).toHaveBeenCalledWith( 'payments-service', expect.stringContaining('/health') ); }); it('payments-service宕机时抛出500错误', async () => { const mockError = new Error('connect ECONNREFUSED'); httpHealthIndicator.pingCheck.mockRejectedValue(mockError); await expect(controller.check()).rejects.toThrow(InternalServerErrorException); expect(controller['isHealthy']).toBe(false); expect(httpHealthIndicator.pingCheck).toHaveBeenCalledTimes(1); }); it('缓存有效期内不重复发起健康检查请求', async () => { // 先执行一次检查设置缓存 httpHealthIndicator.pingCheck.mockResolvedValue({ 'payments-service': { status: 'up' } }); await controller.check(); expect(httpHealthIndicator.pingCheck).toHaveBeenCalledTimes(1); // 立即第二次调用,应该命中缓存不发起请求 await controller.check(); expect(httpHealthIndicator.pingCheck).toHaveBeenCalledTimes(1); }); }); });
方案说明
- 彻底解决偶现问题:每个用例主动将
healthExpiry设置为过去时间,强制走实际检查逻辑,完全规避时间差、实例状态共享导致的随机结果。 - mock更稳定:直接mock控制器注入的
HttpHealthIndicator,不需要关心Terminus底层是调用HttpService.get还是request方法,也不需要匹配Axios响应结构,库升级也不会导致测试失效。 - 场景覆盖完整:除了服务正常、宕机两个场景,还可以覆盖缓存逻辑的测试,完全匹配控制器的实际业务逻辑。
内容的提问来源于stack exchange,提问作者J86
相关产品推荐
相关产品推荐

