コミュニティ

ASP.NET Core で Web API の結合テストをしよう

ちゃんとやったことなかった(存在は知ってた)ので覚書です。
ASP.NET Core で Controller を作ったけど、結合テストしないとなぁ…と思ってたけど、単体テストしてるしなぁめんどくさいなぁ…とも思ってたりしてたけど、便利な機能なのでやります!やりますよ。

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

ASP.NET Core の API のプロジェクトテンプレートを作成します。
認証は個別のユーザー アカウント(Azure AD B2C を使うやつ)を設定しました。
image.png

前はここにアプリ内でユーザー管理するやつがあった気がするけど…、変わったのかな?
今回はテスト用なので、ドメイン名やアプリケーション 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!!

image.png

いいですね。

認証に対応

認証通らないと機能のテストが出来ないので、そこの対応をしていきましょう。まず、テスト用の認証情報を作って返すクラスを 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!!!

image.png

ついでにレスポンスの中身も思った通りの結果かどうか確認しましょう。DB には Tokyo が 2 件、Hiroshima が 2 件あるのでそういうアサーションを書かないといけないのですがメンドクサイ。
そんな時楽させてくれるライブラリとして neuecc さん作の ChainingAssertion があります。NuGet 上での最新は jsakamoto さんがメンテしているバージョンがあるので、そちらを使いたいと思います。

https://www.nuget.org/packages/ChainingAssertion-xUnit.Bin/

アサート処理が凄くシンプルになります。ついでに都市での絞り込みのテストも追加して最終的にはテストコードは以下のようになりました。

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);
        }
    }
}

まとめ

ということで、結合テストをしてみました。
やってみるとテストまで、きちんと考えられて作られてるんだなぁということを感じました。

ということで、意外と低コストで出来るので是非みんなやってみてね!

完全なソースコードのリポジトリーはこちら。

https://github.com/runceel/AspNetCoreWebAPIIntegrationTest

okazuki
日本マイクロソフトでサポート系のエンジニアとして働いています。 好きな言語は C# と TypeScript。メインの興味領域は Windows クライアントアプリ開発と Xamarin によるモバイルアプリ開発。その延長として API を作るための Azure の PaaS 系サービスが好きです。 SPA はたしなむ程度に。 お約束ですが、ここでの発言は個人の見解になります。
https://blog.okazuki.jp
microsoft
マイクロソフトのメンバーが最新の技術情報をお届けします。Twitterアカウント(@msdevjp)やYouTubeチャンネル「クラウドデベロッパーちゃんねる」も運用中です。
https://aka.ms/MSFT-Docs-JPN
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。