You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

.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()

问题原因分析

  1. 未触发服务停止信号,导致任务无限等待
    BackgroundService的ExecuteTask对应ExecuteAsync方法的返回任务,只有当收到停止信号(调用StopAsync)或PeriodicTimer被释放时才会完成。测试中未调用StopAsync,await ExecuteTask会无限阻塞,最终依赖测试框架超时终止。超时时间的不确定性导致有时任务还没完成两次备份就被终止。

  2. 时间推进与异步调度的竞争条件
    连续调用timeProvider.Advance时,第二次时间推进可能发生在异步任务尚未进入下一次WaitForNextTickAsync等待的阶段:

    • 第一次推进时间后,WaitForNextTickAsync完成,任务回到线程池准备执行Backup;
    • 在Backup执行完毕并进入下一次循环调用WaitForNextTickAsync之前,测试代码已完成第二次时间推进,此时PeriodicTimer尚未开始等待下一个周期,导致第二个时间推进无法触发tick,最终只执行一次备份。
  3. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 11:42:32