ASP.NET Core 集成测试和使用 Moq 进行模拟。

12

我有以下使用自定义WebApplicationFactoryASP.NET Core集成测试

public class CustomWebApplicationFactory<TEntryPoint> : WebApplicationFactory<TEntryPoint>
    where TEntryPoint : class
{
    public CustomWebApplicationFactory()
    {
        this.ClientOptions.AllowAutoRedirect = false;
        this.ClientOptions.BaseAddress = new Uri("https://localhost");
    }

    public ApplicationOptions ApplicationOptions { get; private set; }

    public Mock<IClockService> ClockServiceMock { get; private set; }

    public void VerifyAllMocks() => Mock.VerifyAll(this.ClockServiceMock);

    protected override TestServer CreateServer(IWebHostBuilder builder)
    {
        this.ClockServiceMock = new Mock<IClockService>(MockBehavior.Strict);

        builder
            .UseEnvironment("Testing")
            .ConfigureTestServices(
                services =>
                {
                    services.AddSingleton(this.ClockServiceMock.Object);
                });

        var testServer = base.CreateServer(builder);

        using (var serviceScope = testServer.Host.Services.CreateScope())
        {
            var serviceProvider = serviceScope.ServiceProvider;
            this.ApplicationOptions = serviceProvider.GetRequiredService<IOptions<ApplicationOptions>>().Value;
        }

        return testServer;
    }
}

看起来应该可以工作,但问题在于ConfigureTestServices方法从未被调用,因此我的模拟对象从未注册到IoC容器中。您可以在这里找到完整的源代码。

public class FooControllerTest : IClassFixture<CustomWebApplicationFactory<Startup>>, IDisposable
{
    private readonly HttpClient client;
    private readonly CustomWebApplicationFactory<Startup> factory;
    private readonly Mock<IClockService> clockServiceMock;

    public FooControllerTest(CustomWebApplicationFactory<Startup> factory)
    {
        this.factory = factory;
        this.client = factory.CreateClient();
        this.clockServiceMock = this.factory.ClockServiceMock;
    }

    [Fact]
    public async Task Delete_FooFound_Returns204NoContent()
    {
        this.clockServiceMock.SetupGet(x => x.UtcNow).ReturnsAsync(new DateTimeOffset.UtcNow);

        var response = await this.client.DeleteAsync("/foo/1");

        Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
    }

    public void Dispose() => this.factory.VerifyAllMocks();
}
3个回答

18

我曾经写过一篇关于使用Moq进行ASP.NET Core集成测试和模拟的博客。虽然这并不简单,需要一些设置,但我希望它能帮助到某些人。以下是你在ASP.NET Core 3.1中所需的基本代码:

启动项

你应用程序的Startup类中的ConfigureServicesConfigure方法必须是虚拟的。这样我们就可以从这个类中继承,在测试中使用模拟版本替换某些服务的生产版本。

public class Startup
{
    private readonly IConfiguration configuration;
    private readonly IWebHostingEnvironment webHostingEnvironment;

    public Startup(IConfiguration configuration, IWebHostingEnvironment webHostingEnvironment)
    {
        this.configuration = configuration;
        this.webHostingEnvironment = webHostingEnvironment;
    }

    public virtual void ConfigureServices(IServiceCollection services) =>
        ...

    public virtual void Configure(IApplicationBuilder application) =>
        ...
}

测试启动

在您的测试项目中,通过创建一个覆盖原有的 Startup 类的类,将模拟对象和模拟对象注册到 IoC 容器中。

public class TestStartup : Startup
{
    private readonly Mock<IClockService> clockServiceMock;

    public TestStartup(IConfiguration configuration, IHostingEnvironment hostingEnvironment)
        : base(configuration, hostingEnvironment)
    {
        this.clockServiceMock = new Mock<IClockService>(MockBehavior.Strict);
    }
 
    public override void ConfigureServices(IServiceCollection services)
    {
        services
            .AddSingleton(this.clockServiceMock);

        base.ConfigureServices(services);

        services
            .AddSingleton(this.clockServiceMock.Object);
    }
}

CustomWebApplicationFactory

在您的测试项目中编写一个自定义的WebApplicationFactory,该工厂配置了HttpClient并从TestStartup解析模拟对象,然后将它们公开为属性,以便供我们的集成测试使用。请注意,我还将环境更改为Testing并告诉它使用TestStartup类进行启动。

请注意,我已经实现了IDisposable的`Dispose`方法来验证所有严格的模拟对象。这意味着我不需要手动验证任何模拟对象。当xUnit处置测试类时,所有模拟设置的验证都将自动发生。

public class CustomWebApplicationFactory<TEntryPoint> : WebApplicationFactory<TEntryPoint>
    where TEntryPoint : class
{
    public CustomWebApplicationFactory()
    {
        this.ClientOptions.AllowAutoRedirect = false;
        this.ClientOptions.BaseAddress = new Uri("https://localhost");
    }

    public ApplicationOptions ApplicationOptions { get; private set; }

    public Mock<IClockService> ClockServiceMock { get; private set; }

    public void VerifyAllMocks() => Mock.VerifyAll(this.ClockServiceMock);

    protected override void ConfigureClient(HttpClient client)
    {
        using (var serviceScope = this.Services.CreateScope())
        {
            var serviceProvider = serviceScope.ServiceProvider;
            this.ApplicationOptions = serviceProvider.GetRequiredService<IOptions<ApplicationOptions>>().Value;
            this.ClockServiceMock = serviceProvider.GetRequiredService<Mock<IClockService>>();
        }

        base.ConfigureClient(client);
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder
            .UseEnvironment("Testing")
            .UseStartup<TestStartup>();

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            this.VerifyAllMocks();
        }

        base.Dispose(disposing);
    }
}

集成测试

我使用xUnit编写我的测试。请注意,传递给CustomWebApplicationFactory的泛型类型是Startup而不是TestStartup。此泛型类型用于查找应用程序项目在磁盘上的位置,而不是启动应用程序。

我在测试中设置了一个模拟对象,并实现了IDisposable以验证所有测试中的所有模拟对象, 但如果您喜欢,也可以在测试方法本身中执行此步骤。

请注意,我没有使用xUnit的IClassFixture仅启动一次应用程序,因为ASP.NET Core文档告诉您要这样做。如果我这样做,我将不得不在每个测试之间重置模拟对象,并且您只能逐个串行运行集成测试。使用下面的方法,每个测试都是完全隔离的,它们可以并行运行。这会占用更多的CPU,每个测试需要更长时间才能执行,但我认为这是值得的。

public class FooControllerTest : CustomWebApplicationFactory<Startup>
{
    private readonly HttpClient client;
    private readonly Mock<IClockService> clockServiceMock;

    public FooControllerTest()
    {
        this.client = this.CreateClient();
        this.clockServiceMock = this.ClockServiceMock;
    }

    [Fact]
    public async Task GetFoo_Default_Returns200OK()
    {
        this.clockServiceMock.Setup(x => x.UtcNow).ReturnsAsync(new DateTimeOffset(2000, 1, 1));

        var response = await this.client.GetAsync("/foo");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

xunit.runner.json

我正在使用xUnit。我们需要关闭阴影复制,以便任何单独的文件(如appsettings.json)都可以放置在应用程序DLL文件旁边的正确位置。这可以确保我们在集成测试中运行的应用程序仍然可以读取appsettings.json文件。

{
  "shadowCopy": false
}

appsettings.Testing.json

如果您需要针对集成测试更改配置,则可以将appsettings.Testing.json文件添加到应用程序中。由于我们将环境名称设置为“Testing”,因此仅在集成测试中才会读取此配置文件。


如果我想使用内存数据库,该怎么做?在3.1中,我的内存数据库从未被使用,只有在应用程序启动时注册的数据库被使用。我在这里找到了一个链接到Github问题的链接(https://github.com/dotnet/aspnetcore/issues/13918#issuecomment-532162945),但是无法弄清楚如何覆盖此行为。 - kuldeep
3
你说:“我没有使用xUnit的IClassFixture仅启动一次应用程序,正如ASP.NET Core文档告诉你那样。如果这样做,我将不得不在每个测试之间重置模拟,并且您只能一次运行一个集成测试。”我理解IClassFixture是用来共享东西的,但被共享的东西是一个工厂,不应该有状态,因此可以安全地并行运行。然而,似乎不是这样,因为我似乎遇到了竞态条件错误... 可以解释一下共享的工厂如何具有可能破坏测试的状态吗? - ahong

3

处理这个问题的最佳方式是将需要在测试期间替换的Startup部分因子化。例如,不要直接在ConfigureServices中调用services.AddDbContext<MyContext>(...);,而是创建一个虚拟的私有方法,如下所示:

protected virtual void ConfigureDatabase(IServiceCollection services)
{
    services.AddDbContext<MyContext>(...);
}

然后,在您的测试项目中,创建一个类,如TestStartup,它派生自您的SUT的Startup类。然后,您可以重写这些虚拟方法以替换您的测试服务、模拟等。

最后,只需执行以下操作:

builder
    .UseEnvironment("Testing")
    .UseStartup<TestStartup>();

在 C# 中是否可以使用“私有虚拟(private virtual)”? - Sateesh Pagolu
好发现。疯狂的是除我之外没有人注意到那个问题,直到现在。我的意思是protected。 - Chris Pratt

1

你应该创建一个虚假的创业公司:

public class FakeStartup : Startup
{
    public FakeStartup(IConfiguration configuration)
        : base(configuration)
    {
    }

    public override void ConfigureServices(IServiceCollection services)
    {
        base.ConfigureServices(services);

        // Your fake go here
        //services.AddScoped<IService, FakeService>();
    }
}

然后使用 IClassFixture<CustomWebApplicationFactory<FakeStartup>>

确保将您的原始 ConfigureServices 方法设置为虚拟方法。


1
WebApplicationFactory 的通用类型参数是 TEntryPoint。它用于确定 SUT 的程序集,而不是要使用的 Startup 类。因此,在这里应该保持为 Startup - Chris Pratt

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