如何为我的后台服务编写单元测试?

22

我正在使用 .NET Core 中的 HostBuilder(而不是 WebHost!)。

我的应用程序中有一个托管服务,覆盖了后台服务的 ExecuteAsync/StopAsync 方法,并且我想对其进行单元测试。

这是我的 HostedService:

public class DeviceToCloudMessageHostedService : BackgroundService
{
    private readonly IDeviceToCloudMessageService _deviceToCloudMessageService;
    private readonly AppConfig _appConfig;

    public DeviceToCloudMessageHostedService(IDeviceToCloudMessageService deviceToCloudMessageService, IOptionsMonitor<AppConfig> appConfig)
    {
        _deviceToCloudMessageService = deviceToCloudMessageService;
        _appConfig = appConfig.CurrentValue;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await _deviceToCloudMessageService.DoStuff(stoppingToken);
            await Task.Delay(_appConfig.Parameter1, stoppingToken);
        }
    }
    
    public override Task StopAsync(CancellationToken cancellationToken)
    {
        Log.Information("Task Cancelled");
        _deviceToCloudMessageService.EndStuff();
        return base.StopAsync(cancellationToken);
    }

我已经找到了这篇文章:在.NET Core中测试托管服务的集成测试

但是它是针对QueuedBackgroundService解释的,我不确定是否可以用同样的方式来测试我的服务。

我只想知道我的代码是否被执行。我不需要任何特定的结果。 你有什么办法可以帮我测试吗?


你应该能够遵循相同的格式。模拟依赖项并注入它们,调用被测试的方法并断言预期行为。 - Nkosi
请查看此链接 https://dev59.com/Wbnoa4cB1Zd3GeqPZ-zp#65356623 - Artur
3个回答

25
你仍然应该能够按照链接答案中类似的格式进行操作。 模拟依赖项并注入它们,调用被测试的方法并断言预期的行为。 以下使用Moq来模拟依赖项,以及ServiceCollection来完成注入依赖项的重要工作。
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

[TestMethod]
public async Task DeviceToCloudMessageHostedService_Should_DoStuff() {
    //Arrange
    IServiceCollection services = new ServiceCollection();
    services.AddSingleton<IHostedService, DeviceToCloudMessageHostedService>();
    //mock the dependencies for injection
    services.AddSingleton(Mock.Of<IDeviceToCloudMessageService>(_ =>
        _.DoStuff(It.IsAny<CancellationToken>()) == Task.CompletedTask
    ));
    services.AddSingleton(Mock.Of<IOptionsMonitor<AppConfig>>(_ =>
        _.CurrentValue == Mock.Of<AppConfig>(c => 
            c.Parameter1 == TimeSpan.FromMilliseconds(1000)
        )
    ));
    var serviceProvider = services.BuildServiceProvider();
    var hostedService = serviceProvider.GetService<IHostedService>();

    //Act
    await hostedService.StartAsync(CancellationToken.None);
    await Task.Delay(1000);//Give some time to invoke the methods under test
    await hostedService.StopAsync(CancellationToken.None);

    //Assert
    var deviceToCloudMessageService = serviceProvider
        .GetRequiredService<IDeviceToCloudMessageService>();
    //extracting mock to do verifications
    var mock = Mock.Get(deviceToCloudMessageService);
    //assert expected behavior
    mock.Verify(_ => _.DoStuff(It.IsAny<CancellationToken>()), Times.AtLeastOnce);
    mock.Verify(_ => _.EndStuff(), Times.AtLeastOnce());
}

现在,理想情况下,这应该算是测试框架代码,因为您基本上正在测试BackgroundService在运行时的行为是否符合预期,但它应该足以说明如何在隔离环境中测试此类服务。


5
这种方法存在一些问题。首先,它使用基于时间的单元测试,这种测试本质上是脆弱的。其次,如果出现问题,你将无法得到有意义的异常信息--你只会发现你的验证调用失败,并且你需要使用调试器逐步调试。更好的做法有两个:(1)尽可能多地将服务代码放入可测试的类中,并将该类的实例注入到“BackgroundService”中。然后,你只需要证明被注入的类被调用了。(2)使用反射调用“ExecuteAsync”方法。 - RB.
14
@RB. 如果您有更好的建议,为什么不把它作为答案呢? - Jimenemex
1
hostedService.StartAsync 是阻塞的,所以你的代码无法工作。 - JobaDiniz
@RB. 如果您打算采用这种方法,我建议使用MediatR - WBuck

0
基于 @Nkosi 出色的答案,这是另一个例子。我在测试这个 StartupBackgroundService,它有一个名为 ExecuteAsyncprotected 方法。
public class StartupBackgroundService : BackgroundService
{
    private readonly StartupHealthCheck _healthCheck;

    public StartupBackgroundService(StartupHealthCheck healthCheck)
        => _healthCheck = healthCheck;

    protected override Task ExecuteAsync(CancellationToken stoppingToken) 
    {
        _healthCheck.StartupCompleted = true;
        return Task.CompletedTask;
    }
}

我无法将作用域从protected更改为internal,并使用[assembly: InternalsVisibleTo("TestsAssembly")]公开它,因为它是从抽象类派生的。

所以我想出了这个魔法,它调用ExecuteAsync而不是StartAsync

[Test]
public async Task Should_Setup_StartupBackgroundService()
{
    //Arrange
    var startUpBackServ = new StartupBackgroundService(new Base.HealthCheck.StartupHealthCheck());

    // Act
    startUpBackServ.StartAsync(It.IsAny<CancellationToken>()); // It calls ExecuteAsync magically! 

    //Assert

}

这真是太神奇了! 输入图像描述

这里是StartupHealthCheck:

public class StartupHealthCheck : IHealthCheck
    {
        public bool StartupCompleted { get; set; }

        public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context = null, CancellationToken cancellationToken = default)
        {
            if (StartupCompleted)
            {
                return Task.FromResult(HealthCheckResult.Healthy("The startup task has completed."));
            }

            return Task.FromResult(HealthCheckResult.Unhealthy("That startup task is still running."));
        }
    }

1
BackgroundService.StartAsync会调用ExecuteAsync,这就是为什么您在调试器中看到它执行的原因;但是它不会等待它完成,因此如果ExecuteAsync中的代码在单元测试完成后运行,那么您将不会看到任何异常。 - Mog0

0
上述解决方案对我来说不起作用。因为后台服务将无限运行。 我的解决方案使用CancellationToken,并创建一个线程在一段时间后取消它。 代码如下:
CancellationTokenSource source = new CancellationTokenSource();
CancellationToken token = source.Token;
new Thread(async () =>
{
     Thread.CurrentThread.IsBackground = true;
     await Task.Delay(500);
     hostedService.StopAsync(token);
}).Start();

await hostedService.StartAsync(token)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接