.NET 8中PeriodicTimer结合TimeProvider的单元测试断言不稳定
问题场景
在ASP.NET Core 8 Web API中,基于BackgroundService实现了每小时执行一次的数据库备份定时任务,使用PeriodicTimer配合TimeProvider控制触发时机。编写单元测试时用FakeTimeProvider模拟时间推进,但测试断言不稳定:有时通过,有时失败,提示期望Backup方法被调用至少2次,实际仅调用1次。
相关代码
备份服务实现
public class OfflineHourlyDatabaseBackup( TimeProvider timeProvider, IServiceProvider serviceProvider) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var timer = new PeriodicTimer(TimeSpan.FromHours(1), timeProvider); while (await timer.WaitForNextTickAsync(stoppingToken) && !stoppingToken.IsCancellationRequested) { Backup(); } } private void Backup() { using var scope = serviceProvider.CreateScope(); var offlineDatabaseBackupService = scope.ServiceProvider.GetRequiredService<IOfflineDatabaseBackupService>(); offlineDatabaseBackupService.Backup(); } }
单元测试代码
[Fact] public async Task OfflineHourlyDatabaseBackup_ExecuteAsync() { // Arrange var serviceCollection = new ServiceCollection(); var mockOfflineDatabaseBackupService = new Mock<IOfflineDatabaseBackupService>(); serviceCollection.AddSingleton(_ => mockOfflineDatabaseBackupService.Object); var timeProvider = new FakeTimeProvider(); var serviceProvider = serviceCollection.BuildServiceProvider(); // Act using var offlineHourlyDatabaseBackup = new OfflineHourlyDatabaseBackup(timeProvider, serviceProvider); await offlineHourlyDatabaseBackup.StartAsync(default); timeProvider.Advance(TimeSpan.FromHours(1)); timeProvider.Advance(TimeSpan.FromHours(1)); await offlineHourlyDatabaseBackup.ExecuteTask!; // Assert offlineHourlyDatabaseBackup.ExecuteTask.IsCompletedSuccessfully.Should().BeTrue(); mockOfflineDatabaseBackupService.Verify(x => x.Backup(), Times.AtLeast(2)); }
失败错误信息
Moq.MockException : Expected invocation on the mock at least 2 times,
but was 1 times: x => x.Backup()
问题原因分析
未触发服务停止信号,导致任务无限等待
BackgroundService的ExecuteTask对应ExecuteAsync方法的返回任务,只有当收到停止信号(调用StopAsync)或PeriodicTimer被释放时才会完成。测试中未调用StopAsync,await ExecuteTask会无限阻塞,最终依赖测试框架超时终止。超时时间的不确定性导致有时任务还没完成两次备份就被终止。时间推进与异步调度的竞争条件
连续调用timeProvider.Advance时,第二次时间推进可能发生在异步任务尚未进入下一次WaitForNextTickAsync等待的阶段:- 第一次推进时间后,
WaitForNextTickAsync完成,任务回到线程池准备执行Backup; - 在
Backup执行完毕并进入下一次循环调用WaitForNextTickAsync之前,测试代码已完成第二次时间推进,此时PeriodicTimer尚未开始等待下一个周期,导致第二个时间推进无法触发tick,最终只执行一次备份。
- 第一次推进时间后,
PeriodicTimer的触发逻辑特性PeriodicTimer需要在WaitForNextTickAsync被调用后才会开始等待下一个周期。如果两次时间推进之间没有给异步任务足够的调度时间,就会错过第二次触发时机。
解决方案及优化后的测试代码
优化要点
- 主动调用
StopAsync触发停止信号,确保ExecuteAsync循环正常退出,避免无限等待; - 每次时间推进后调用
await Task.Yield(),释放当前线程,让异步任务有机会完成当前备份并进入下一次等待; - 将断言改为
Times.Exactly(2),更精确匹配预期行为。
优化后的测试代码
[Fact] public async Task OfflineHourlyDatabaseBackup_ExecuteAsync() { // Arrange var serviceCollection = new ServiceCollection(); var mockOfflineDatabaseBackupService = new Mock<IOfflineDatabaseBackupService>(); serviceCollection.AddSingleton(_ => mockOfflineDatabaseBackupService.Object); var timeProvider = new FakeTimeProvider(); var serviceProvider = serviceCollection.BuildServiceProvider(); using var offlineHourlyDatabaseBackup = new OfflineHourlyDatabaseBackup(timeProvider, serviceProvider); // Act await offlineHourlyDatabaseBackup.StartAsync(default); // 第一次推进时间,等待任务处理第一个备份周期 timeProvider.Advance(TimeSpan.FromHours(1)); await Task.Yield(); // 第二次推进时间,等待任务处理第二个备份周期 timeProvider.Advance(TimeSpan.FromHours(1)); await Task.Yield(); // 停止服务,让ExecuteAsync循环正常退出 await offlineHourlyDatabaseBackup.StopAsync(default); await offlineHourlyDatabaseBackup.ExecuteTask!; // Assert offlineHourlyDatabaseBackup.ExecuteTask.IsCompletedSuccessfully.Should().BeTrue(); mockOfflineDatabaseBackupService.Verify(x => x.Backup(), Times.Exactly(2)); }
内容的提问来源于stack exchange,提问作者fingers10

