07
2019

ASP.NET CoreでAPIテスト (統合テスト) を行う

CATEGORY.NET
またASP.NET Coreネタ。今度はASP.NET Coreの自動テストで、APIレベルのテスト (統合テスト, Integration tests) を行う方法について。バージョンは引き続き2.1。

統合テストの方法については、公式ドキュメントにも以下のように説明がある。
が、いろいろ説明が冗長だったり、かと思えば足りなかったりと、最初にこれだけ見ても正直分かり辛い。
なので、実際に統合テストを行うのに必要な要点とかをまとめてみる。
(最終的なテストの実装例はこちら参照。)

テストプロジェクトの作成

まずテストプロジェクトの作成。Visual Studioで、公式のサンプルに合わせてxUnit.netのテストプロジェクトを作る。
(ただし、統合テストの仕組み自体はテストライブラリによらず共通と思われる。)

作成したプロジェクトでは、csprojファイルの冒頭を <Project Sdk="Microsoft.NET.Sdk.Web"> に差し替え、かつNuGetなりで以下のライブラリを参照している状態にする。
あと当然テスト対象のプロジェクトも参照する。
ここまでは普通のテストプロジェクトを作る場合とほぼ同じだと思う。

WebApplicationFactoryによるAPIテスト例

ASP.NET Coreの統合テストには、2.1から WebApplicationFactory というクラスが用意されている。
これにジェネリックでテスト対象プロジェクトの Startup クラスを指定すると、そのStartupを使ってテスト用のサーバーが立ち上がり、そこにリクエストを投げられるようになる。
単にAPIを呼ぶだけで良いのであれば、これを直接そのまま使うこともできる。
namespace Honememo.AspNetCoreApiExample.Tests.Controllers
{
using System.Net.Http;
using Xunit;
using Microsoft.AspNetCore.Mvc.Testing;

public class BlogsControllerTest : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly HttpClient client;

public BlogsControllerTest(WebApplicationFactory<Startup> factory)
{
this.client = factory.CreateClient();
}

[Fact]
public async void TestGetBlogs()
{
var response = await this.client.GetAsync("/api/blogs");
Assert.True(response.IsSuccessStatusCode);
}
}
}
上記が WebApplicationFactory を直接使ったごくごく単純なテストの例。
この例では、xunitの IClassFixture を使って、クラス生成時にファクトリが自動生成されるようにしている。
ファクトリの CreateClient() から取れる HttpClient はテスト用サーバーに繋がるようになっているので、これにテストしたいパスを指定することで、簡単にAPIを呼ぶことができる。

CustomWebApplicationFactoryの作成

上記のようにAPIを呼ぶだけなら WebApplicationFactory だけで足りるのだが、通常はその他に、Webアプリの設定をテスト用に変更したり、DBをモックに差し替えたり、といった前作業が必要になる。
そこで、今度は WebApplicationFactory を継承したクラスを作成し、ConfigureWebHost() メソッドをオーバーライドして、Webホストにテスト用の設定を行う。
namespace Honememo.AspNetCoreApiExample.Tests
{
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;

public class CustomWebApplicationFactory : WebApplicationFactory<Startup>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// ここにStartup.ConfigureServices()のように設定を行う
});
}
}
}
これが空の継承クラス。次はここに設定を追加していく。

インメモリDBへの切り替え

統合テストでは、モックではなく何かしらテスト用のDBに接続することが多いと思う。
本物のDBの別スキーマや、sqlite等を用いることもあるが、Entity Framework CoreではインメモリDBを使うこともできるので、今回はこれを使った例を紹介する。
namespace Honememo.AspNetCoreApiExample.Tests
{
using System;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Honememo.AspNetCoreApiExample.Repositories;

public class CustomWebApplicationFactory : WebApplicationFactory<Startup>
{
public static readonly InMemoryDatabaseRoot InMemoryDatabaseRoot = new InMemoryDatabaseRoot();

private readonly string appDbName = "TestAppDB";

public CustomWebApplicationFactory()
{
this.appDbName += Guid.NewGuid();
}

public AppDbContext CreateDbContext()
{
var builder = new DbContextOptionsBuilder<AppDbContext>();
builder.UseInMemoryDatabase(this.appDbName, InMemoryDatabaseRoot);
return new AppDbContext(builder.Options);
}

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase(this.appDbName, InMemoryDatabaseRoot);
});

var sp = services.BuildServiceProvider();
using (var scope = sp.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
TestData.InitializeDbForTests(db);
}
});
}
}
}
クラスが一気に大きくなったが以下解説。
まず一番メインが AddDbContext() の部分で、ここでテスト用DBの定義を行うことで、Startup にある本物のDB定義が置き換えられる。
定義の仕方自体は通常時と同じだが、インメモリDBを識別するためにいくつか引数を指定している(後述)。

その下はテストデータの登録処理で、ここでは先ほど定義したDBコンテキストを取得して、テストデータを登録している。
データの登録自体は、普通にDBコンテキストで Add() して SaveChanges() したりするだけなので割愛。

次いで先頭に戻って先ほどの InMemoryDatabaseRoot だが、これは設定しないとインメモリDBがDBコンテキスト毎に別々になってしまうらしい。
その下の appDbName も同じような用途で、参照するインメモリDBを特定するために名前を指定している。
コンストラクタでGUIDを付けているのはxunitの並列実行対策で、xunitではテストをテストクラス単位で並列に実行するため、各テストクラスごと(ファクトリごと)に別々のDBとなるように指定している。

その下の CreateDbContext() はテストケースでDBを参照するためのもの。テストケース側ではDIされたコンテキストは取れない(?)っぽかったので、同じ名前のインメモリDBに繋ぐことで対応している。

要認証APIへのアクセス

テストDBを設定したことで、テスト用サーバーで多くのAPIを動かせるようになったと思う。
だが、統合テストを書くには、まだもう一つ、認証をどうするかという課題が残っている。次はその解決に取り掛かる。

…と、調べてみたのだが、最初にぶっちゃけてしまうと、フレームワークとしてのやり方は見つからなかった(汗
他のフレームワークだと、例えば直接セッションを弄って認証中にするとかあるものだが、そういう事は出来ないらしい。

じゃあどうやって解決するのかという話であるが…力技である。普通にログインAPIを呼んでセッションを確立した HttpClient を返す共通関数を作った(--;
public (HttpClient, int) CreateAuthedClient(string name = "Taro", string password = "PASSWORD")
{
var client = this.CreateClient();
var body = new LoginDto() { UserName = name, Password = password };
var response = client.PostAsJsonAsync("/api/users/login", body).Result;
var responseString = response.Content.ReadAsStringAsync().Result;
if (!response.IsSuccessStatusCode)
{
throw new Exception(responseString);
}

return (client, (int)JObject.Parse(responseString)["id"]);
}
さっきのインメモリDBのとこで登録してるテストデータにテストユーザーも入れておいて、それでログインするという。
まあ確かにログイン済みには出来るが、あまり融通が利かないのが難点。とはいえ他に手もないので致し方ない。。。

なお、上のコードは普通のセッションを使うログインAPIの例なのでシンプルだが、これがJWTAuthorizationヘッダーを送らなければいけないとかだと、独自の DelegatingHandler 作ってヘッダーを詰め込む必要があったりと割と面倒くさい。

CustomWebApplicationFactoryによるAPIテスト例

ということで、ここまでのインメモリDBやら認証やらを使った統合テストの例が以下となる。
namespace Honememo.AspNetCoreApiExample.Tests.Controllers
{
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using Newtonsoft.Json;
using Xunit;
using Honememo.AspNetCoreApiExample.Dto;

public class BlogsControllerTest : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory factory;
private readonly HttpClient client;
private readonly HttpClient authedClient;
private readonly int userId;

public BlogsControllerTest(CustomWebApplicationFactory factory)
{
this.factory = factory;
this.client = factory.CreateClient();
(this.authedClient, this.userId) = factory.CreateAuthedClient();
}

[Fact]
public async void TestGetBlogs()
{
var response = await this.client.GetAsync("/api/blogs");
var responseString = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, responseString);

var array = JsonConvert.DeserializeObject<IEnumerable<BlogDto>>(responseString);

Assert.NotEmpty(array);

var blog = array.First();
Assert.True(blog.Id > 0);
Assert.True(!string.IsNullOrEmpty(blog.Name));
}

[Fact]
public async void TestPostBlog()
{
var body = new BlogEditDto() { Name = "New Blog" };
var response = await this.authedClient.PostAsJsonAsync("/api/blogs", body);
var responseString = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, responseString);

var json = JsonConvert.DeserializeObject<BlogDto>(responseString);
Assert.True(json.Id > 0);
Assert.Equal(body.Name, json.Name);

var dbblog = this.factory.CreateDbContext().Blogs.Find(json.Id);
Assert.NotNull(dbblog);
Assert.Equal(body.Name, dbblog.Name);
}
}
}
この例では、APIの引数/戻り値ともにDTOクラスがあるので、引数はDTOで設定して、戻り値もJSONをDTOにデシリアライズして、値を確認している。
(クラスがない場合も、引数は匿名型で、戻り値はJson.NETの JObject とかで処理できる。)

認証が必要なAPIでは、先ほど作った認証済の HttpClient を用い、またDBに正しく登録できているかの確認用にファクトリの共通関数からDBコンテキストを取得している。

上記以外については、書き方は普通のユニットテストと同じだと思う。

補足1) NETSDK1071のWarning

以上で統合テストの書き方については一通り完了!あとはテストを書く上で必須じゃないけど、これどうすんの?みたいなとこが何カ所かあったので、ちょっと補足する。
warning NETSDK1071: 'Microsoft.AspNetCore.App' への PackageReference は '2.1.11' のバージョンを指定しました。このパッケージのバージョンを指定することは推奨されません。詳細については、https://aka.ms/sdkimplicitrefs を参照してください

1つ目はテスト実行時にこんなワーニングが出る場合の対処について。まあ書いてあるURL開いてその通りにやるだけなんだけど、これはテストプロジェクト側に Microsoft.AspNetCore.App ライブラリのバージョンを指定するなって事らしい。テスト対象のバージョン(?)を勝手に見てくれる模様。なのでcsprojからバージョンを消せばOK。

補足2) Transactions are not supportedの例外

次はインメモリDBでトランザクションを使った処理で以下のような例外が出る場合の対処について。
Error generated for warning 'Microsoft.EntityFrameworkCore.Database.Transaction.TransactionIgnoredWarning: Transactions are not supported by the in-memory store. See http://go.microsoft.com/fwlink/?LinkId=800142'. This exception can be suppressed or logged by passing event ID 'InMemoryEventId.TransactionIgnoredWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.

これもエラーメッセージ書いてある通りだけど、インメモリDBではトランザクションがサポートされてないので、使おうとするとデフォルトでは例外が飛ぶという話。指示に従い、DB定義にこの警告を無視するよう設定すれば解消する。
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase(this.appDbName, InMemoryDatabaseRoot);
options.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning));
});
なお、当然であるが警告を無視しているだけなので、トランザクションが使えるようになるわけではない。
途中で例外が起きたときにトランザクションがロールバックされる事、みたいなテストはインメモリDBでは行えないのでご注意を。

補足3) Environmentの切り替え

3つ目はDevelopmentとかProductionとかの環境を、ユニットテスト用に変えたい場合の対処について。

プロパティ辺りで設定できるだろ、と思って調べてみたが、これもなんか設定するところ無いっぽい…。
なのでまた力業だが、CustomWebApplicationFactory のコンストラクタで無理やり設定した。
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")))
{
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
}

補足4) Program.csは読み込まれない

最後にProgram.csの扱いについて補足。WebApplicationFactory の型から一目瞭然ではあるが、テスト用サーバーの構築で呼ばれるのは Startup だけである。つまり、Program クラスの方に書いてある処理は実行されない。
設定プロバイダー周りや、Serilogなどの一部のライブラリは、Program の方で初期設定したりするので、そういうのをテストでも使おうとする場合は、ConfigureWebHost() で改めて解決する必要がある。


以上で補足も完了!結構長くなったけど、これでASP.NET CoreのAPIテストで詰まるのを回避できると思う。
ASP.NET Coreは知名度の割に日本語情報が不足気味な気がするので、手助けになれば喜ばしい(^^
スポンサーサイト



Tag: ASP.NET .NET

0 Comments

Leave a comment