🌊

ASP.NET Core のミドルウェアをテストする

2023/01/26に公開

設定

以下のようにリクエストごとの運勢を占い、結果をレスポンスヘッダーに追加するミドルウェアを考えます。このミドルウェアのテストを行うにはどのようにすればよいでしょうか。

OmikujiMiddleware.cs
public class OmikujiMiddleware
{
    private readonly RequestDelegate _next;

    public OmikujiMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext httpContext, IOmikujiService omikuji)
    {
        var result = omikuji.GetFortune();
        httpContext.Response.Headers.Add("FORTUNE-OF-THE-REQUEST", result);
        await _next(httpContext);
    }
}
OmikujiService.cs
public interface IOmikujiService
{
    string GetFortune();
}

public class OmikujiService : IOmikujiService
{
    private readonly Random _random;

    public OmikujiService(Random random)
    {
        _random = random;
    }

    public string GetFortune() => _random.NextDouble() switch
    {
        var x when x < 0.1 => "Excellent Luck",
        var x when x < 0.3 => "Very Good Luck",
        var x when x < 0.6 => "Good Luck",
        var x when x < 0.95 => "Bad Luck",
        _ => "Very Bad Luck",
    };
}
Program.cs
builder.Services
    .AddSingleton<Random>()
    .AddSingleton<IOmikujiService, OmikujiService>();

app.UseMiddleware<OmikujiMiddleware>();

テスト構成

ASP.NET Core でミドルウェアの単体テストを行う場合は、 WebHostBuilder で個別にミドルウェアに関連するインターフェイスやクラスを依存性注入(DI)してテストすることができます[1]。また、 WebApplicationFactory を利用してしてテスト対象プロジェクトの Program.cs と同等の DI を行って統合テストすることもできます[2]

ミドルウェアの単体テスト

MiddlewareUnitTest.cs
public class MiddlewareUnitTest
{
    [Fact]
    public async Task Test()
    {
        using var host = await new HostBuilder()
            .ConfigureWebHost(webBuilder =>
            {
                webBuilder
                    .UseTestServer()
                    .ConfigureServices(services =>
                    {
                        services
                            .AddSingleton<Random>()
                            .AddSingleton<IOmikujiService, OmikujiService>();
                    })
                    .Configure(app =>
                    {
                        app.UseMiddleware<OmikujiMiddleware>();
                    });
            })
            .StartAsync();

        var client = host.GetTestClient();
        var response = await client.GetAsync("/");
        Assert.True(response.Headers.TryGetValues("FORTUNE-OF-THE-REQUEST", out var result));
        Assert.Equal("Excellent Luck", result.SingleOrDefault());
    }
}

ミドルウェアの統合テスト

MiddlewareIntegrationTest.cs
public class MiddlewareIntegrationTest : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public UnitTest(WebApplicationFactory<Program> factory)
    {
        _factory= factory;
    }

    [Fact]
    public async Task Test()
    {
        var client = _factory.CreateClient();
        var response = await client.GetAsync("/");
        Assert.True(response.Headers.TryGetValues("FORTUNE-OF-THE-REQUEST", out var result));
        Assert.Equal("Excellent Luck", result.SingleOrDefault());
    }
}

DI 構成

上記は乱数に依存するため、テストは乱数依存で成功する時もあれば失敗する時もあります。そこでテスト用に乱数が固定化されるようにテストを設定する必要があります。

ミドルウェアの単体テスト

ミドルウェアの単体テストはテストケースごとに DI を行うため、ケースで注入する Random のインスタンスを、固定乱数を返すサブクラスにしてやれば OK です。

FixedRandom
public class FixedRandom : Random
{
    private readonly double _value;

    public FixedRandom(double value)
    {
        _value = value;
    }

    public override double NextDouble()
    {
        return _value;
    }
}

以下のようなテストデータを作成して Theory テストします。

OmikujiTestData.cs
public static class OmikujiTestData
{
    public static IEnumerable<object[]> GenerateTestData()
    {
        yield return new object[] { 0.0, "Excellent Luck" };
        yield return new object[] { 0.05, "Excellent Luck" };
        yield return new object[] { Math.BitDecrement(0.1), "Excellent Luck" };
        yield return new object[] { 0.1, "Very Good Luck" };
        yield return new object[] { 0.2, "Very Good Luck" };
        yield return new object[] { Math.BitDecrement(0.3), "Very Good Luck" };
        yield return new object[] { 0.3, "Good Luck" };
        yield return new object[] { 0.45, "Good Luck" };
        yield return new object[] { Math.BitDecrement(0.6), "Good Luck" };
        yield return new object[] { 0.6, "Bad Luck" };
        yield return new object[] { 0.8, "Bad Luck" };
        yield return new object[] { Math.BitDecrement(0.95), "Bad Luck" };
        yield return new object[] { 0.95, "Very Bad Luck" };
        yield return new object[] { 1.0, "Very Bad Luck" };
    }
}
MiddlewareUnitTest.cs
public class MiddlewareUnitTest
{
    [Theory]
    [MemberData(nameof(OmikujiTestData.GenerateTestData), MemberType = typeof(OmikujiTestData))]
    public async Task Test(double value, string expected)
    {
        using var host = await new HostBuilder()
            .ConfigureWebHost(webBuilder =>
            {
                webBuilder
                    .UseTestServer()
                    .ConfigureServices(services =>
                    {
                        services
                            .AddSingleton<Random>(serviceProvider =>
                            {
                                return new FixedRandom(value);
                            })
                            .AddSingleton<IOmikujiService, OmikujiService>();
                    })
                    .Configure(app =>
                    {
                        app.UseMiddleware<OmikujiMiddleware>();
                    });
            })
            .StartAsync();

        var client = host.GetTestClient();
        var response = await client.GetAsync("/");
        Assert.True(response.Headers.TryGetValues("FORTUNE-OF-THE-REQUEST", out var result));
        Assert.Equal(expected, result.SingleOrDefault());
    }
}

AddSingleton で注入している Random クラスを固定値を返す FixedRandom クラスに変更し、そのコンストラクターに与える引数をテストごとに差し替えています。

ミドルウェアの統合テスト

統合テストでは WebApplicationFactory.ConfigureWebHost をオーバーライドすることで DI の設定を変更することができます。

TestWebApplicationBuilder.cs
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
	    // テスト設定
        });
    }
}

しかしこの方法だとクラスのセットアップ時に一度実行されるだけになるので(IClassFixture の場合)、 1 ケースごとに WebApplicationFactory のサブクラスを実装しなければならなくなります。そこで、 WebApplicationFactory のサブクラスは 1 つだけ定義し、ケースごとに値を差し替えることを考えます。

ここではモックオブジェクトを利用し、ケースごとにモックを差し替えて実行することを考えます。元の Program.cs で DI していた Random および IOmikujiService を取り除き、モックを DI し直します。

TestWebApplicationBuilder.cs
public class TestWebApplicationFactory : WebApplicationFactory<Program>
{
    public IMock<Random> RandomMock { get; set; } = default!;

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.Remove(services.Single(descriptor => descriptor.ServiceType == typeof(Random)));
            services.Remove(services.Single(descriptor => descriptor.ServiceType == typeof(IOmikujiService)));
            services
                .AddScoped(serviceProvider =>
                {
                    return RandomMock.Object;
                })
                .AddScoped<IOmikujiService>(serviceProvider =>
                {
                    var random = serviceProvider.GetRequiredService<Random>();
                    return new OmikujiService(random);
                });
        });
    }
}

元の Program.cs では AddSingleton で DI していましたが、 AddSingleton で DI すると最初のケースで DI されたモックインスタンスが使いまわされてしまうので、ここでは AddScoped で DI しています。

この構成を利用してテストクラスを作成します。ケースごとにモックを作成して TestWebApplicationFactory で DI 対象となるモックを差し替えます。

MiddlewareIntegrationTest.cs
public class MiddlewareIntegrationTest : IClassFixture<TestWebApplicationFactory>
{
    private readonly TestWebApplicationFactory _factory;

    public MiddlewareIntegrationTest(TestWebApplicationFactory factory)
    {
        _factory = factory;
    }

    [Theory]
    [MemberData(nameof(OmikujiTestData.GenerateTestData), MemberType = typeof(OmikujiTestData))]
    public async Task Test(double value, string expected)
    {
        var randomMock = new Mock<Random>();
        randomMock.Setup(random => random.NextDouble()).Returns(value);
        _factory.RandomMock = randomMock;
            
        var client = _factory.CreateClient();
        var response = await client.GetAsync("/");
        Assert.True(response.Headers.TryGetValues("FORTUNE-OF-THE-REQUEST", out var result));
        Assert.Equal(expected, result.SingleOrDefault());
    }
}

統合テストでモックを DI したい場合としては、例のような乱数を利用する場合の他、時刻に依存する場合や webAPI を呼び出す場合等がありますが、この方法を利用すれば容易にテストできます。時刻については DateTime や DateTimeOffset が構造体で DI 対象とならないため、 DateTime や DateTimeOffset を返すメソッドを提供するインターフェイスを作るのが簡単です。外部の webAPI を呼び出すような場合は IHttpClientFactory を利用しますが、その場合は HttpMessageHandler をモック化することでリクエスト・レスポンスに対してテストを行うことができます。

脚注
  1. ASP.NET Core のミドルウェアのテスト ↩︎

  2. ASP.NET Core での統合テスト ↩︎

Discussion