ちゃんとやったことなかった(存在は知ってた)ので覚書です。
ASP.NET Core で Controller を作ったけど、結合テストしないとなぁ…と思ってたけど、単体テストしてるしなぁめんどくさいなぁ…とも思ってたりしてたけど、便利な機能なのでやります!やりますよ。
テスト対象のプロジェクトの作成
ASP.NET Core の API のプロジェクトテンプレートを作成します。
認証は個別のユーザー アカウント(Azure AD B2C を使うやつ)を設定しました。
前はここにアプリ内でユーザー管理するやつがあった気がするけど…、変わったのかな?
今回はテスト用なので、ドメイン名やアプリケーション ID などは適当なものを入れました。
Entity Framework Core 系の以下のパッケージを追加して DB 操作のコードを追加します。
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.Design
とりあえず DbContext は以下のようにしました。
using Microsoft.EntityFrameworkCore;
using System;
namespace ApiTest.Models
{
public class WeatherContext : DbContext
{
public DbSet<WeatherForecast> WeatherForecasts { get; set; }
public WeatherContext()
{
}
public WeatherContext(DbContextOptions<WeatherContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<WeatherForecast>(b =>
{
b.Property(x => x.Id);
b.HasKey(x => x.Id);
b.Property(x => x.City).IsRequired();
b.Property(x => x.TemperatureC).IsRequired();
b.Property(x => x.Date).IsRequired();
b.Property(x => x.Summary).IsRequired();
});
}
}
public class WeatherForecast
{
public int Id { get; set; }
public string City { get; set; }
public int TemperatureC { get; set; }
public DateTime Date { get; set; }
public string Summary { get; set; }
}
}
Startup.cs に追加しましょう。
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(AzureADB2CDefaults.BearerAuthenticationScheme)
.AddAzureADB2CBearer(options => Configuration.Bind("AzureAdB2C", options));
services.AddControllers();
services.AddDbContext<WeatherContext>(optiosnBuilder =>
{
optiosnBuilder.UseSqlServer(
Configuration.GetConnectionString("DefaultDb"),
options => options.EnableRetryOnFailure());
});
}
最後に WeatherForecastController を DB を使うように書き換えます。
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApiTest.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ApiTest.Controllers
{
[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger;
private readonly WeatherContext _weatherContext;
public WeatherForecastController(ILogger<WeatherForecastController> logger, WeatherContext weatherContext)
{
_logger = logger;
_weatherContext = weatherContext;
}
[HttpGet]
public async Task<IEnumerable<WeatherForecastResponse>> Get([FromQuery]string city)
{
_logger.LogDebug($"Get weather forecasts for {city}");
IQueryable<WeatherForecast> query = _weatherContext.WeatherForecasts;
if (!string.IsNullOrWhiteSpace(city))
{
query = query.Where(x => x.City == city);
}
var forecasts = await query.ToArrayAsync();
return forecasts.Select(x => new WeatherForecastResponse
{
City = x.City,
Date = x.Date,
Summary = x.Summary,
TemperatureC = x.TemperatureC,
});
}
}
}
これで下準備完了です。
テストプロジェクトの作成
テストプロジェクトを作ります!xUnit にしましょう(なんとなく
追加するパッケージは以下のパッケージです。
- Microsoft.AspNetCore.Mvc.Testing
- Microsoft.EntityFrameworkCore.InMemory
最初のものは、ASP.NET Core MVC のテスト時に使うもので、2 つ目のものは結合テスト時に SQL Server ではなく InMemory の DB を今回使おうと思ったので追加しています。SQL Server の localdb とかでやるなら追加しなくてもいいです。
今回は本番コードが SQL Server を想定しているのに、テスト用に別 DB を使うケースを試したかったのでそうしています。
WebApplicationFactory の作成
では、結合テストで起動する Web サーバーを構成していきましょう。
WebApplicationFactory クラスを継承して ConfigureWebHost をオーバーライドします。
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
namespace ApiTest.Tests
{
public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup>
where TStartup: class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// ここで起動する Web サーバーの構成をテスト用に変える
}
}
}
では、コードを追加していきましょう。追加するコードは WeatherContext を InMemory のものに置き換える処理と、DB にテストデータを追加する処理です。ざくっと追加しました。
using ApiTest.Models;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Security.Cryptography;
namespace ApiTest.Tests
{
public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup>
where TStartup: class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// DB を SQL Server からインメモリーにする
var descriptor = services.SingleOrDefault(
x => x.ServiceType == typeof(DbContextOptions<WeatherContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
services.AddDbContext<WeatherContext>(options =>
{
options.UseInMemoryDatabase("Testing");
});
var sp = services.BuildServiceProvider();
// Scope を作っておくことで DbContext が使いまわされないようにする
using (var scope = sp.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<WeatherContext>();
// DB を作り直し
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
// テストデータの投入
db.WeatherForecasts.AddRange(new WeatherForecast
{
City = "Tokyo",
Summary = "Cold",
Date = new DateTime(2020, 1, 1),
TemperatureC = 0,
},
new WeatherForecast
{
City = "Tokyo",
Summary = "Hot",
Date = new DateTime(2020, 8, 6),
TemperatureC = 35,
},
new WeatherForecast
{
City = "Hiroshima",
Summary = "Cold",
Date = new DateTime(2020, 1, 1),
TemperatureC = -1,
},
new WeatherForecast
{
City = "Hiroshima",
Summary = "Hot",
Date = new DateTime(2020, 8, 6),
TemperatureC = 32,
});
db.SaveChanges();
}
});
}
}
}
テストコードの追加
では、テストコードを追加していきます。
認証されてないと呼べないコードなので、普通に呼んだら Unauthorized になるはずです。まずは、それを試してみます。
先ほどの CustomWebApplicationFactory クラスを使って xUnit のテストを書くと以下のようになります。IClassFixture で CustomWebApplicationFactory を作ってもらって下準備をしてもらいます。そして CustomWebApplicationFactory から HttpClient を作って、そいつに対して GetAsync などを呼ぶことで Web API が呼び出せます。以下のようなコードになります。
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Threading.Tasks;
using Xunit;
namespace ApiTest.Tests.Controllers
{
public class WeatherForecastControllerTest : IClassFixture<CustomWebApplicationFactory<Startup>>
{
private readonly CustomWebApplicationFactory<Startup> _factory;
public WeatherForecastControllerTest(CustomWebApplicationFactory<Startup> factory)
{
_factory = factory;
}
[Fact]
public async Task Unauthorized()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
var forecasts = await client.GetAsync("/WeatherForecast");
Assert.Equal(HttpStatusCode.Unauthorized, forecasts.StatusCode);
}
}
}
このテストを実行すると GREEN!!
いいですね。
認証に対応
認証通らないと機能のテストが出来ないので、そこの対応をしていきましょう。まず、テスト用の認証情報を作って返すクラスを AuthenticationHandler を継承して作成します。
中味は、基本クラスのコンストラクタに引数をそのまま渡すためのコンストラクタと、ダミーのテスト用認証情報を返す処理だけで大丈夫です。
今回は名前だけ設定していますが、追加のクレームを足したい場合は、ここに足すといいでしょう。以下のようになります。
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
namespace ApiTest.Tests
{
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) :
base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 含めたいクレームを作る
var claims = new[]
{
new Claim(ClaimTypes.Name, "Test user"),
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}
では、テストを書いていこうと思います。先ほど作った認証用のハンドラーを仕込んで HttpClient を作成してから `/WeatherForecast の URL を叩けば OK です。やってみましょう。
[Fact]
public async Task GetAllForecasts()
{
var client = _factory.WithWebHostBuilder(b =>
{
// テスト用の認証ハンドラーを設定する
b.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", options => { });
});
})
.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
var res = await client.GetAsync("/WeatherForecast");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
実行すると GREEN!!!
ついでにレスポンスの中身も思った通りの結果かどうか確認しましょう。DB には Tokyo が 2 件、Hiroshima が 2 件あるのでそういうアサーションを書かないといけないのですがメンドクサイ。
そんな時楽させてくれるライブラリとして neuecc さん作の ChainingAssertion があります。NuGet 上での最新は jsakamoto さんがメンテしているバージョンがあるので、そちらを使いたいと思います。
アサート処理が凄くシンプルになります。ついでに都市での絞り込みのテストも追加して最終的にはテストコードは以下のようになりました。
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using Xunit;
namespace ApiTest.Tests.Controllers
{
public class WeatherForecastControllerTest : IClassFixture<CustomWebApplicationFactory<Startup>>
{
private readonly CustomWebApplicationFactory<Startup> _factory;
public WeatherForecastControllerTest(CustomWebApplicationFactory<Startup> factory)
{
_factory = factory;
}
[Fact]
public async Task Unauthorized()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
var forecasts = await client.GetAsync("/WeatherForecast");
forecasts.StatusCode.Is(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task GetAllForecasts()
{
var client = _factory.WithWebHostBuilder(b =>
{
// テスト用の認証ハンドラーを設定する
b.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", options => { });
});
})
.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
var res = await client.GetAsync("/WeatherForecast");
res.StatusCode.Is(HttpStatusCode.OK);
var responseContent = await JsonSerializer.DeserializeAsync<WeatherForecastResponse[]>(
await res.Content.ReadAsStreamAsync(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// サーバー側では、順序保障してないのでローカルでソートしてアサート
responseContent = responseContent.OrderBy(x => x.TemperatureC).ToArray();
responseContent.Is(
new[]
{
new WeatherForecastResponse { City = "Hiroshima", Summary = "Cold", Date = new DateTime(2020, 1, 1), TemperatureC = -1 },
new WeatherForecastResponse { City = "Tokyo", Summary = "Cold", Date = new DateTime(2020, 1, 1), TemperatureC = 0 },
new WeatherForecastResponse { City = "Hiroshima", Summary = "Hot", Date = new DateTime(2020, 8, 6), TemperatureC = 32 },
new WeatherForecastResponse { City = "Tokyo", Summary = "Hot", Date = new DateTime(2020, 8, 6), TemperatureC = 35 },
},
(x, y) => x.City == y.City &&
x.Date == y.Date &&
x.Summary == y.Summary &&
x.TemperatureC == y.TemperatureC &&
x.TemperatureF == y.TemperatureF);
}
[Fact]
public async Task GetTokyoForecasts()
{
var client = _factory.WithWebHostBuilder(b =>
{
// テスト用の認証ハンドラーを設定する
b.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", options => { });
});
})
.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
var res = await client.GetAsync("/WeatherForecast?city=Tokyo");
res.StatusCode.Is(HttpStatusCode.OK);
var responseContent = await JsonSerializer.DeserializeAsync<WeatherForecastResponse[]>(
await res.Content.ReadAsStreamAsync(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// サーバー側では、順序保障してないのでローカルでソートしてアサート
responseContent = responseContent.OrderBy(x => x.TemperatureC).ToArray();
responseContent.Is(
new[]
{
new WeatherForecastResponse { City = "Tokyo", Summary = "Cold", Date = new DateTime(2020, 1, 1), TemperatureC = 0 },
new WeatherForecastResponse { City = "Tokyo", Summary = "Hot", Date = new DateTime(2020, 8, 6), TemperatureC = 35 },
},
(x, y) => x.City == y.City &&
x.Date == y.Date &&
x.Summary == y.Summary &&
x.TemperatureC == y.TemperatureC &&
x.TemperatureF == y.TemperatureF);
}
}
}
まとめ
ということで、結合テストをしてみました。
やってみるとテストまで、きちんと考えられて作られてるんだなぁということを感じました。
ということで、意外と低コストで出来るので是非みんなやってみてね!
完全なソースコードのリポジトリーはこちら。