Search code examples
c#.netunit-testingtimerxunit

How to assert timer callback in c# xunit unit test


I have a OfflineHourlyDatabaseBackup BackgroundService in .NET 8 as shown below.

OfflineHourlyDatabaseBackup:

public class OfflineHourlyDatabaseBackup(
    ILogger<OfflineHourlyDatabaseBackup> logger,
    TimeProvider timeProvider,
    IServiceProvider serviceProvider) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await Task.Yield();

        timeProvider.CreateTimer(Backup, null, TimeSpan.Zero, TimeSpan.FromHours(1));

        stoppingToken.Register(() => logger.LogWarning($"{nameof(OfflineHourlyDatabaseBackup)} is stopping due to host shut down."));
    }

    private void Backup(object? state)
    {
        using var scope = serviceProvider.CreateScope();
        var offlineDatabaseBackupService = scope.ServiceProvider.GetRequiredService<IOfflineDatabaseBackupService>();
        offlineDatabaseBackupService.Backup();
    }
}

I have written uni test to test the behavior. Here is my xunit unit test,

[Fact]
public async Task OfflineHourlyDatabaseBackup_ExecuteAsync()
{
    // Arrange
    var serviceCollection = new ServiceCollection();
    var mockOfflineDatabaseBackupService = new Mock<IOfflineDatabaseBackupService>();
    serviceCollection.AddSingleton(_ => mockOfflineDatabaseBackupService.Object);
    var now = DateTimeOffset.UtcNow;
    var timeProvider = new TestTimeProvider(now);
    var serviceProvider = serviceCollection.BuildServiceProvider();
    var logger = NullLogger<OfflineHourlyDatabaseBackup>.Instance;
    var configuration = new Mock<IConfiguration>();

    // Act
    using var offlineHourlyDatabaseBackup = new OfflineHourlyDatabaseBackup(logger, timeProvider, serviceProvider);
    await offlineHourlyDatabaseBackup.StartAsync(default);
    await offlineHourlyDatabaseBackup.ExecuteTask!; // --> Backup() should be called
    timeProvider.Advance(TimeSpan.FromHours(1)); // --> Backup() should be called

    // Assert
    offlineHourlyDatabaseBackup.ExecuteTask.IsCompletedSuccessfully.Should().BeTrue();
    mockOfflineDatabaseBackupService.Verify(x => x.Backup(), Times.AtLeast(2));
}

Here is my TestTimeProvider.cs,

internal class TestTimeProvider(DateTimeOffset now) : TimeProvider
{
    public override DateTimeOffset GetUtcNow() => now;

    public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
    {
        return base.CreateTimer(callback, state, TimeSpan.Zero, TimeSpan.FromHours(1));
    }

    public void Advance(TimeSpan timeSpan)
    {
        now = now.Add(timeSpan);
    }
}

When I run the test I expect Callback Backup method to be called two times. One because of await offlineHourlyDatabaseBackup.ExecuteTask!; and another time because of timeProvider.Advance(TimeSpan.FromHours(1)); but getting the exception message as follows

Expected invocation on the mock at least 2 times, but was 0 times: x => x.Backup()

Upon debugging I have noticed that before the first Backup() call gets executed the assertion line gets completed and throws above error.

Please can you assist me on what I'm doing wrong here ?


Solution

  • Finally I solved the above challenge.

    I came to know that there are test fakes available from Microsoft as Nuget Packages.

    For TimerProvider - Microsoft.Extensions.TimeProvider.Testing

    And simply changed the test as shown below with FakeTimeProvider

    [Fact]
    public async Task OfflineHourlyDatabaseBackup_ExecuteAsync()
    {
        // Arrange
        var serviceCollection = new ServiceCollection();
        var mockOfflineDatabaseBackupService = new Mock<IOfflineDatabaseBackupService>();
        serviceCollection.AddSingleton(_ => mockOfflineDatabaseBackupService.Object);
        var now = DateTimeOffset.UtcNow;
        var timeProvider = new FakeTimeProvider(); // --> Changed here
        var serviceProvider = serviceCollection.BuildServiceProvider();
        var logger = new FakeLogger<OfflineHourlyDatabaseBackup>();
        var configuration = new Mock<IConfiguration>();
    
        // Act
        using var offlineHourlyDatabaseBackup = new OfflineHourlyDatabaseBackup(logger, timeProvider, serviceProvider);
        await offlineHourlyDatabaseBackup.StartAsync(default);
        await offlineHourlyDatabaseBackup.ExecuteTask!;
        timeProvider.Advance(TimeSpan.FromHours(1));
    
        // Assert
        offlineHourlyDatabaseBackup.ExecuteTask.IsCompletedSuccessfully.Should().BeTrue();
        mockOfflineDatabaseBackupService.Verify(x => x.Backup(), Times.AtLeast(2));
    }
    

    Source: https://devblogs.microsoft.com/dotnet/fake-it-til-you-make-it-to-production/