🐡

[ASP.NET Core 6] WebApplicationFactoryの利用の互換性が失われることに対処する

2022/02/26に公開約10,100字

環境

  • .NET 6 / C# 10.0
  • ASP.NET Core 6

先にまとめ

  • ASP.NET Core 5以前では Startup というクラスがStartup.cs にありました。
  • ASP.NET Core 6では最小ホスティングモデル (minimal hosting model) が登場し、StartupクラスもなければProgramクラスすら一見すると無くなりました。
  • WebApplicationFactory を使うと、コントローラにHTTPリクエストを投げるようなテストを簡単に記述できますが、StartupやProgramが「無くなって」しまうと、ジェネリック型引数に指定するものに窮します。
  • 3つの解決策を調べました。新規案件については、個人的には3番目の案が良いと考えます。
    1. Startup.csを以前通り定義する。
      • 5以前からのアップグレード組であれば最善に思います。
    2. 本体側で InternalsVisibleTo をTestに向けて許可することで、暗黙定義されるinternalProgramクラスへのアクセスを許可。
    3. 暗黙のProgramクラスをpublicにする。

主な解決策は以下の公式ドキュメントやStack Overflowに載っていて、ただ長々と余分な説明を付けているに過ぎません。

https://docs.microsoft.com/ja-jp/aspnet/core/test/integration-tests?view=aspnetcore-6.0#basic-tests-with-the-default-webapplicationfactory
https://stackoverflow.com/questions/69058176/how-to-use-webapplicationfactory-in-net6-without-speakable-entry-point

実験コード

https://github.com/shimat/net6_webapi_test_sample

コードの例

ASP.NET CoreのWeb APIプロジェクトを前提として以下進めます。Visual Studioでは ASP.NET Core Web API というテンプレートで [1] 雛型のWebAPIプロジェクトを作れます。

ASP.NET Core 5 (.NET 5 / C# 9.0) 以前

コントローラとStartup

以下、雛型そのままのコントローラとStartup.csを示します。

WeatherForecast.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;

namespace WebApplication2.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };

        private readonly ILogger<WeatherForecastController> _logger;

        public WeatherForecastController(ILogger<WeatherForecastController> logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
}
Startup.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace WebApplication2
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {

            services.AddControllers();
            services.AddSwaggerGen(c =>
            {
                c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebApplication2", Version = "v1" });
            });
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseSwagger();
                app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebApplication2 v1"));
            }

            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

テスト

コントローラのアクションメソッドをHTTP経由で呼び出すテストは、WebApplicationFactoryを使って例として以下のように実装できます。ここではxUnit.netを使用しています。

xUnit.netのIClassFixtureについては下記ページが本家の情報です。IClassFixtureを使ってコンストラクタにWebApplicationFactoryを注入してもらう使い方が一般になされると理解しています。

https://xunit.net/docs/shared-context
ApiTest.cs
using Microsoft.AspNetCore.Mvc.Testing;
using System.Collections.Generic;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Xunit;

namespace WebApplication1.Tests
{
    public class ApiTest : IClassFixture<WebApplicationFactory<Startup>>
    {
        private readonly WebApplicationFactory<Startup> factory;

        public ApiTest(WebApplicationFactory<Startup> factory)
        {
            this.factory = factory;
        }

        [Fact]
        public async Task GetTest()
        {
            var httpClient = factory.CreateClient();
            
            var contentModels = await httpClient.GetFromJsonAsync<IReadOnlyList<WeatherForecast>>("/WeatherForecast")
                .ConfigureAwait(false);
            Assert.NotNull(contentModels);
            Assert.Equal(5, contentModels!.Count);
            Assert.All(contentModels, model =>
            {
                Assert.True(model.TemperatureC >= -20);
                Assert.True(model.TemperatureC < 55);
            });
        }
    }
}

ASP.NET Core 6 (.NET 6 / C# 10.0)

5との差分

ASP.NET Core 6では、コントローラは大差ないですが[2]、Startupが無くなりました [3]

https://docs.microsoft.com/ja-jp/aspnet/core/migration/50-to-60?view=aspnetcore-6.0&tabs=visual-studio#new-hosting-model

必要な初期設定は以下のように Program.cs で行うようになりました。

Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseAuthorization();

app.MapControllers();

app.Run();

テストで困る

このようにStartupが無くなってしまったので、以下の WebApplicationFactory に与えるジェネリック引数に窮してしまいました。

WeatherForecast.cs
// Startupは無い
public class ApiTest : IClassFixture<WebApplicationFactory<Startup>>

初期化処理はProgram.csに移ったのですが、かといって Program クラスを指定してもエラーになります。実は、Programクラスは自分で書いていなくても暗黙に作られていて存在するのですが、既定ではinternalになっているので、テスト側からは見えません。

WeatherForecast.cs
// Programは見えない
public class ApiTest : IClassFixture<WebApplicationFactory<Program>>

この「暗黙にProgramクラスが作られている」という点については諸事情があり、本記事の最後で述べています。

解決策

始めに述べたように、以下3つの案を調べました。私は新規案件では 3.の方法がベストである と結論しています。

  1. Startup.csを以前通り定義する。
  2. 本体側で InternalsVisibleTo をTestに向けて許可することで、暗黙定義されるinternalProgramクラスへのアクセスを許可。
  3. 暗黙のProgramクラスをpublicにする

以下述べます。再掲ですがこのページに載っている話です。

https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-6.0#basic-tests-with-the-default-webapplicationfactory

1. Startup.csを以前通り定義する

従来の書き方は変わらずサポートされています。なんだかんだ安全で、特にASP.NET Core 5以前からのアップグレード組ならば無理にStartupを無くすことはないというのが私見です[4]

2. InternalsVisibleTo

方法

本体側の .csproj に以下を追記します。テストプロジェクトの名前を指定します [5]

WebApplication1.csproj
  <ItemGroup>
    <InternalsVisibleTo Include="WebApplication1.Tests" />
  </ItemGroup>

これだけで、テスト側から暗黙に定義されているProgramクラスにアクセスできます。以下のようにWebApplicationFactoryをインスタンス化することは実現可能になりました。

public class ApiTest
{
    [Fact]
    public async Task Get()
    {
        var factory = new WebApplicationFactory<Program>();
	var httpClient = factory.CreateClient();
	
	var response = await httpClient.GetAsync("/WeatherForecast");
	...
    }
}

問題点

IClassFixture でDIする書き方はこれではできません。理由は、そもそもInternalsVisibleToを書いた当然の前提としてProgramクラスがinternalだからです。

public class ApiTest : IClassFixture<WebApplicationFactory<Program>>
{
    // Programがinternalなので、コンストラクタをpublicにはできないと怒られる
    public ApiTest(WebApplicationFactory<Program> factory)
    {

それではとコンストラクタをinternalにしてみると、xUnit.netから怒られます。テストクラスにはpublicの唯一のコンストラクタが必要です。

A test class may only define a single public constructor.

コンストラクタはpublicでクラスをinternalにしてもダメです xUnit1000 Test classes must be public。ここで手詰まりになりました。

3. 暗黙のProgramクラスをpublicにする

こちらの方法は、上記のInternalsVisibleToの問題は起きません。

方法

本体側のProgram.csの末尾におまじないを書きます。それだけです。

Program.cs
var builder = WebApplication.CreateBuilder(args);

// (中略)

app.Run();

// !!! ここ !!!
public partial class Program
{
}

Programクラスがpublicになるので、internalに起因する問題は解決です。IClassFixtureを使ったDI方式にも対応します。

思うところ

  • 前のInternalsVisibleTo方式でも同様ですが、おまじない感満載は否めません。
    • 現状致し方ないところですが、見た目は消えたProgramクラスがどう扱われているかという言語仕様の裏に意識を払う必要が出てきてしまいます。
  • 変化の速い(ASP).NET界において、近い将来また仕様変更があって変更を迫られる可能性を若干感じます。

参考: トップレベルステートメントとProgramクラスについて

Mainメソッド無しで以下のようにいきなりコードを書いてしまえるというのは、C# 10.0 より少し早く C# 9.0 からありました。

従来

using System;

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
}

C# 9.0

using System;

Console.WriteLine("Hello World!");

ですから、ASP.NET Core 6の最小ホスティングモデルというのは .NET 5 (C# 9.0) 時代でもImplicit Usingsを除けば大体実現可能だったように見えます。ですが本記事の内容はC# 10.0限定と言えます

理由は以下のissueが参考になります。

https://github.com/dotnet/roslyn/issues/54877

C# 9.0 では、暗黙に作られる Program クラスの名前が「unspeakable」つまり開発者からは参照不能なよくわからない名前になっていました。するとまさにWebApplicationFactoryで困ったことになったようで、このissueはそれを「speakable」つまり固定のProgramという名前にしようという話のようです。これがC# 10.0で採用され、本記事の内容である WebApplicationFactory<Program> という箇所でちょうど役に立つという流れでした。

従って、C# 9.0 ではおとなしくStartup.csを書くしかないということになります(Programの名前以外にももちろん要因はあります)。

脚注
  1. コマンドラインであれば dotnet new webapi です。 ↩︎

  2. コントローラを使って実装した場合 ↩︎

  3. 従来通り書いても可です。 ↩︎

  4. ConfigureConfigureServiceが分かれていて実装しづらいシーンをたまに感じるなど、新方式にする意義も十分感じます。 ↩︎

  5. InternalsVisibleToが .csproj で書いて済むようになるとは、便利な時代になりました。 ↩︎

Discussion

ログインするとコメントできます