.NET Core中NUnit测试ILogger依赖注入共享状态问题排查
问题场景
我有一个通过依赖注入(DI)注入ILogger<Microsoft.Extensions.Logging>的IHostedService,该服务用Timer类每60秒执行一次,需要验证日志记录器被调用一次。原本单个测试类里用[OneTimeSetup]创建ILogger<Type>的模拟对象时测试正常,但新增第二个同样注入ILogger的测试类(同一测试项目,测试不同类)后,第一个测试开始失败。
仅在以下场景测试会失败:
- 调试模式下运行
- 与另一个测试类一起运行
已排查共享状态:两个测试类都用[OneTimeSetup]创建独立的模拟对象,不存在共享实例的情况。
移除失败的TestOne后,TestOne里的另一个测试会无限运行,必须手动关闭VS才能终止。
同时运行测试时,TestOne抛出异常:
Expected invocation on the mock once, but was 0 times:
logger => logger.Log<It.IsAnyType>(It.Is(logLevel => (int)logLevel == 2),
It.IsAny(),
It.IsAny<It.IsAnyType>(),
It.IsAny(),
It.IsAny<Func<It.IsAnyType, Exception, string>>())
代码示例
TestOne类
internal sealed class TestOne { private Mock<ILogger<ClassToTest>> _logger; private ClassToTest _classToTest; [OneTimeSetUp] public void OneTimeSetup() { _logger = new(); _classToTest = new(_logger.Object); } [Test] public void StartAsync_CannotContinue_LogInformation() { // Act _classToTest.StartAsync(It.IsAny<CancellationToken>()); // Assert _logger.Verify(logger => logger.Log( It.Is<LogLevel>(logLevel => logLevel == LogLevel.Information), It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(), It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once); } }
TestTwo类
internal sealed class TestTwo { private Mock<ILogger<AnotherClassToTest>> _logger; private AnotherClassToTest _anotherClassToTest; [OneTimeSetUp] public void OneTimeSetup() { _logger = new(); _anotherClassToTest = new(_logger.Object); } [Test] public void SomeMethod_IsSuccessfull_LogInfo() { // Act _anotherClassToTest.SomeMethod(It.IsAny<string>(), It.IsAny<CancellationToken>()); // Assert _logger.Verify(logger => logger.Log( It.Is<LogLevel>(logLevel => logLevel == LogLevel.Information), It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(), It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once); } }
解决方案
1. 替换[OneTimeSetup]为[SetUp]
[OneTimeSetup]会在测试类所有测试执行前仅初始化一次,而IHostedService结合Timer会在后台持续运行,容易导致跨测试的状态污染。改用[SetUp],每个测试方法执行前都重新创建模拟对象和被测实例,确保测试完全隔离:
修改TestOne的初始化方法:
[SetUp] public void Setup() { _logger = new(); _classToTest = new(_logger.Object); }
TestTwo同理替换[OneTimeSetUp]为[SetUp]。
2. 手动终止HostedService的Timer和后台任务
在测试的[TearDown]方法中添加逻辑,停止Timer并释放资源,避免后台线程持续运行导致的无限循环或状态残留:
如果ClassToTest暴露了停止Timer的方法:
[TearDown] public void TearDown() { _classToTest.StopTimer(); // 自定义方法,停止Timer并释放资源 }
如果没有暴露方法,可通过反射获取Timer实例并停止:
[TearDown] public void TearDown() { var timerField = typeof(ClassToTest).GetField("_timer", BindingFlags.NonPublic | BindingFlags.Instance); if (timerField != null) { var timer = (Timer)timerField.GetValue(_classToTest); timer.Dispose(); } }
3. 精确匹配日志调用(可选)
若日志验证仍失败,可通过匹配日志消息模板的方式,避免泛型匹配的歧义:
_logger.Verify( x => x.Log( LogLevel.Information, It.IsAny<EventId>(), It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("你的日志消息关键词")), It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once);
原因说明
[OneTimeSetup]的实例复用导致IHostedService的Timer在后台持续运行,跨测试时状态未重置,模拟对象的调用记录被干扰。- 调试模式下测试执行速度较慢,后台Timer的执行时机与测试断言时机冲突,导致断言时日志还未被调用。
- 未及时释放Timer资源,后台线程无法终止,出现无限运行的情况。
内容的提问来源于stack exchange,提问作者Luis Leuppi

