ASP.NET Web APIのSelf-Hostでテスト

これはOne ASP.NET Advent Calendar 2012の18日目の記事です。
昨日は、karuakunさんでASP.NET 4 WebFormでモバイルサイトを作成する場合のスクリプト結合でした。

はじめに

ASP.NET Web APIにはSelf-Hostと呼ばれる機能があります。IISがなくても動くということですね。この機能の使いどころは何だろうか?と考えたときに、一番しっくりしたのがテストでの利用でした。というわけで、Self-Hostをテストで使う方法を紹介したいと思います。テストのツールにはMSTestを使いますが、NUnitなどasync/awaitに対応したツールならほとんど同じ方法が使えると思います。

プロジェクトの構成

まず、プロジェクトの構成はこんな感じです。WebApiがControllerを含むテスト対象プロジェクト、WebApiTestがテストコードを含むプロジェクトです。


それぞれのプロジェクトに含まれるソースコードを示します。

WebApiプロジェクト

データをあらわすPersonクラス。

namespace WebApi
{
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
}

リクエストを受けてレスポンスを返すPersonController。

using System.Collections.Generic;
using System.Web.Http;

namespace WebApi
{
    public class PersonController : ApiController
    {
        public IEnumerable<Person> Get()
        {
            return new List<Person>
                       {
                           new Person{ Name = "hoge", Age = 10 },
                           new Person{ Name = "foo", Age = 20 }
                       };
        }
    }
}
WebApiTestプロジェクト

PersonControllerをTestするクラス。

using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Dispatcher;
using System.Web.Http.SelfHost;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using WebApi;

namespace WebApiTest
{
    // このテストを動かすにはVisual Stuido を管理者権限で起動するか、Netsh.exeで設定必要。
    // http://www.asp.net/web-api/overview/hosting-aspnet-web-api/self-host-a-web-api
    [TestClass]
    public class PersonControllerTest
    {
        private static readonly Uri BaseAddress = new Uri("http://localhost:9090/");
        private readonly HttpSelfHostConfiguration _config = new HttpSelfHostConfiguration(BaseAddress);

        [TestInitialize]
        public void Initialize()
        {
            _config.Routes.MapHttpRoute(
                name: "Default",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional });

            // エラーメッセージをレスポンスで返す
            _config.IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Always;

            // SelfHostのデフォルトのIAssembliesResolverはエントリポイントになった
            // アセンブリの中からコントローラーを探すがテストでは不適切。
            // コントローラーを含むアセンブリから探す実装で置き換える
            _config.Services.Replace(typeof(IAssembliesResolver),
                new AssembliesResolver(typeof(PersonController).Assembly));

            // POCOのプロパティ名の先頭を小文字にしてJSONのキーとする
            var jsonSettings = _config.Formatters.JsonFormatter.SerializerSettings;
            jsonSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
        }

        // asyncをつける場合、戻り値をTaskにしないとMSTestがテストメソッドだと認識しないので注意
        [TestMethod]
        public async Task TestGet()
        {
            using (var server = new HttpSelfHostServer(_config))
            {
                await server.OpenAsync();
                using (var client = new HttpClient { BaseAddress = BaseAddress })
                using (var response = await client.GetAsync("api/person"))
                {
                    var content = await response.Content.ReadAsStringAsync();
                    Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, content);

                    var result = JArray.Parse(content);
                    Assert.AreEqual(2, result.Count);
                    var person = result[0];
                    Assert.AreEqual("hoge", person["name"]);
                    Assert.AreEqual(10, person["age"]);
                    person = result[1];
                    Assert.AreEqual("foo", person["name"]);
                    Assert.AreEqual(20, person["age"]);
                }
            }
        }
    }
}

Controllerを含むアセンブリを解決するクラス。

using System.Collections.Generic;
using System.Reflection;
using System.Web.Http.Dispatcher;

namespace WebApiTest
{
    public class AssembliesResolver : IAssembliesResolver
    {
        private readonly Assembly _assembly;

        public AssembliesResolver(Assembly assembly)
        {
            _assembly = assembly;
        }

        public ICollection<Assembly> GetAssemblies()
        {
            return new[] { _assembly };
        }
    }
}

解説

いくつかはコメントに書いていますが、次のようなポイントがあります。

  • 指定のportでリッスンできるように、Visual Stuidoを管理者権限で起動するか、Netsh.exeを使う必要があります
  • 他のプロジェクトからControllerを見つけられるようにIAssembliesResolverの実装を作る必要があります
  • WebApiのアセンブリはSelf-Hostなプロジェクトから独立しています。Web-Hostなプロジェクトからも利用できます、Web-Hostなプロジェクトを別途作れば。

テストメソッドではサーバを起動して、それからHttpClientでリクエスト飛ばしてレスポンス受け取って、結果が期待通りのJSONか検証しています。

参考

基本はドキュメントとソース。


ソースコードを見るとわかりますが、ASP.NET WebAPIはTaskを使いまくりです。Taskを使った処理とSynchronizationContextをうまく連携させたり、スレッドやSynchronizationContextの無駄なスイッチを避けるような仕組みも入っています。ここの解説が詳しいです。


SynchronizationContextは、ufcppさんの記事もとてもわかりやすいですね。

まとめ

Controllerを自分でnewしてテストすることもできますが、それはあくまでControllerの単体のテストです。Self-Hostを使うと、Controllerの呼び出し前後で動くフレームワーク部分を含めてテストができます。たとえば、上で示したコードではJSONシリアライズ方法をカスタマイズしていますが、そういう部分が意図通りに動いているか確かめることができます。ASP.NET Web APIは、機能や拡張ポイントが豊富だったりTaskを多用していたりして、何ができてどう動くかわかりづらい場合があります。でも、心配なし。そんなときは、Self-Hostを使ったテストで試行錯誤してみればいいんです。


明日は、masa_edwさんです。