[ASP.NET Core 6] WebApplicationFactoryの利用の互換性が失われることに対処する
環境
- .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番目の案が良いと考えます。
- Startup.csを以前通り定義する。
- 5以前からのアップグレード組であれば最善に思います。
- 本体側で
InternalsVisibleTo
をTestに向けて許可することで、暗黙定義されるinternal
なProgram
クラスへのアクセスを許可。 - 暗黙のProgramクラスをpublicにする。
- Startup.csを以前通り定義する。
主な解決策は以下の公式ドキュメントやStack Overflowに載っていて、ただ長々と余分な説明を付けているに過ぎません。
実験コード
コードの例
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を示します。
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();
}
}
}
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
を注入してもらう使い方が一般になされると理解しています。
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]。
必要な初期設定は以下のように 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
に与えるジェネリック引数に窮してしまいました。
// Startupは無い
public class ApiTest : IClassFixture<WebApplicationFactory<Startup>>
初期化処理はProgram.csに移ったのですが、かといって Program
クラスを指定してもエラーになります。実は、Programクラスは自分で書いていなくても暗黙に作られていて存在するのですが、既定ではinternal
になっているので、テスト側からは見えません。
// Programは見えない
public class ApiTest : IClassFixture<WebApplicationFactory<Program>>
この「暗黙にProgramクラスが作られている」という点については諸事情があり、本記事の最後で述べています。
解決策
始めに述べたように、以下3つの案を調べました。私は新規案件では 3.の方法がベストである と結論しています。
- Startup.csを以前通り定義する。
- 本体側で
InternalsVisibleTo
をTestに向けて許可することで、暗黙定義されるinternal
なProgram
クラスへのアクセスを許可。 - 暗黙のProgramクラスをpublicにする
以下述べます。再掲ですがこのページに載っている話です。
1. Startup.csを以前通り定義する
従来の書き方は変わらずサポートされています。なんだかんだ安全で、特にASP.NET Core 5以前からのアップグレード組ならば無理にStartupを無くすことはないというのが私見です[4]。
2. InternalsVisibleTo
方法
本体側の .csproj に以下を追記します。テストプロジェクトの名前を指定します [5]。
<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
の末尾におまじないを書きます。それだけです。
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が参考になります。
C# 9.0 では、暗黙に作られる Program
クラスの名前が「unspeakable」つまり開発者からは参照不能なよくわからない名前になっていました。するとまさにWebApplicationFactory
で困ったことになったようで、このissueはそれを「speakable」つまり固定のProgram
という名前にしようという話のようです。これがC# 10.0で採用され、本記事の内容である WebApplicationFactory<Program>
という箇所でちょうど役に立つという流れでした。
従って、C# 9.0 ではおとなしくStartup.csを書くしかないということになります(Programの名前以外にももちろん要因はあります)。
Discussion