taediumの日記

2012-12-18

[][] 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がテストコードを含むプロジェクトです。

f:id:taedium:20121217212115p:image


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

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さんです。

2011-02-10

[][] ObjectDataSourceを使ったページング

ステートレスなビジネスロジックが一覧データと件数を同時に返すとき

件数を一時的に保持してObjectDataSourceに渡せるようにビジネスロジックを一時的にラップしてあげるといいかもしれない。

GridViewPage.cs

using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.Web.UI.WebControls;

namespace Shumisen.Web.Form.IntegrationTest.Data
{
    public partial class GridViewPage : System.Web.UI.Page
    {
        [Import]
        public GridViewLogic GridViewLogic { private get; set; }

        protected void ObjectDataSource1_OnObjectCreating(object sender, ObjectDataSourceEventArgs e)
        {
            e.ObjectInstance = new LogicAdapter{ GridViewLogic = GridViewLogic};
        }

        public class LogicAdapter
        {
            private int _count;

            public GridViewLogic GridViewLogic { private get; set; }

            public IEnumerable<Book> GetBooks(int startRowIndex, int maximumRows)
            {
                return GridViewLogic.GetBooks(startRowIndex, maximumRows, out _count);
            }

            public int GetBookCount()
            {
                return _count;
            }
        }
    }
}

GridViewPage.cs.aspx

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="GridViewPage.aspx.cs" Inherits="Shumisen.Web.Form.IntegrationTest.Data.GridViewPage" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
    <div>
        <asp:GridView ID="GridView1" runat="server" AllowPaging="True" AllowSorting="true" AutoGenerateColumns="False"
            DataSourceID="ObjectDataSource1">
            <Columns>
                <asp:BoundField DataField="ASIN" HeaderText="ASIN" SortExpression="ASIN" />
                <asp:BoundField DataField="Title" HeaderText="Title" SortExpression="Title" />
            </Columns>
        </asp:GridView>
        
        <asp:ObjectDataSource ID="ObjectDataSource1" runat="server" SelectMethod="GetBooks"
            SelectCountMethod="GetBookCount" TypeName="Shumisen.Web.Form.IntegrationTest.Data.GridViewPage+LogicAdapter"
            EnablePaging="true" OnObjectCreating="ObjectDataSource1_OnObjectCreating" />
    </div>
    </form>
</body>
</html>

実行結果

f:id:taedium:20110211010741p:image

2010-11-18

[][] HttpUtilityのエンコードいろいろ

Update: How and When to Encode for the Web

次のメソッドのちがいを解説。

  • HtmlEncode
  • HtmlAttributeEncode
  • UrlEncode
  • JavaScriptStringEncode

MSDNのAPIドキュメントよりもわかりやすいです。

2010-11-14

[][] ApplicationPoolIdentityについてメモ

リモートのSQL ServerへのWindows認証を稼動確認したい。

[][] App_Dataディレクトリ以下に配置されたファイルへのNLogを使ったログ出力

IIS7.5のアプリケーションプールのデフォルトのユーザーIDであるApplicationPoolIdentityでそのまま出力できました。最初、FileのtargetにfileName="~/App_Data/app.log"と指定していてうまくいかないなぁと思っていたのですが、fileName="${basedir}/App_Data/app.log"でいいみたい。

App_Data以外のところへ出力しようとするとアクセス権限与えないといけないから、そういうことなしで出力できるのは楽ですね。


    <nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
        <targets>
            <target name="ASPNetTrace" xsi:type="ASPNetTrace" />
            <target name="File" xsi:type="File" fileName="${basedir}/App_Data/app.log" />
        </targets>
        <rules>
            <logger name="*" writeTo="ASPNetTrace, File" />
        </rules>
    </nlog>


開発環境は、targetにASPNetTraceとFileを使えばあんまり困ることないかな。

2010-11-13

[][] いくつかのスコープ(状態)を検索対象にして値を得る

一番小さいスコープから一番大きいスコープまでを自動でたどっていくのって、誤って間違ったデータを取得してしまう可能性が高いと思うのです。だから、探すスコープ(状態)とその順番を指定できるといいのかもしれない。

たとえば次のようなクラスを用意して

    public static class StateFinder
    {
        public static T Get<T>(string key, StateKind kind, params StateKind[] kinds)
        {
            /// 指定されたkindやkindsの順番で値を探してTに変換して返す
            return ...;
        }
    }

    public enum StateKind
    {
        HttpContext,
        Redirection,
        View,        
        Session,
        Application
    }

こう使う

var value = StateFinder.Get<string>("hoge", StateKind.View, StateKind.Redirection);

これは、まずViewStateをhogeというkeyで探してあればそれを返し、なければRedirectionStateをhogeというkeyで探しあればそれを返すという意味です。こういうユーティリティを使わないとhogeというキーを複数指定したりそれぞれでキャストを指定したりしないといけなくなってそれが面倒くさいんですよね。

[][] ASP.NETでTeedaのリダイレクトスコープのようなもの その2

下のエントリですが、よく考えると属性を指定したPageのプロパティに自動で値をセットしたりプロパティから値を取得したりというのは、ASP.NETのプログラミングモデルにそぐわないですね。自分で明示的にGET/SETしたほうが一貫性があってわかりやすいかも。

Default.aspx

    public partial class _Default : Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
        }

        protected void Button1_OnClick(object sender, EventArgs e)
        {
            var redirection = HttpRedirectionState.Current;
            redirection["hoge"] = TextBox1.Text;
            redirection["foo"] = TextBox2.Text;
            Response.Redirect("About.aspx, true);
        }
    }

About.aspx

    public partial class About : Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            var redirection = HttpRedirectionState.Current;
            Label1.Text = (string)redirection["hoge"];
            Label2.Text = (string)redirection["foo"];
        }

        protected void Button1_OnClick(object sender, EventArgs e)
        {
        }
    }

[][] ASP.NETでTeedaのリダイレクトスコープのようなものを実装する

ASP.NETでTeedaのリダイレクトスコープのようなものがあると便利かと思って考えてみました。要するに、リダイレクトする直前でセッションに情報を置いて、リダイレクト後のGETでその情報をセッションから取得するという機能です。そんなかんじの機能をつくって公開している人がいそうな気はするのですがみつからないですね。

この機能を利用するPageクラスはこんな感じになります。

Default.aspx.cs

    public partial class _Default : Page
    {
        [RedirectionState(Direction.Out)]
        public string Hoge { get; set; }

        [RedirectionState(Direction.Out)]
        public string Foo { get; set; }

        protected void Page_Load(object sender, EventArgs e)
        {
        }

        protected void Button1_OnClick(object sender, EventArgs e)
        {
            Hoge = TextBox1.Text;
            Foo = TextBox2.Text;
            Response.Redirect("About.aspx", true);
        }
    }

About.aspx.cs

    public partial class About : Page
    {
        [RedirectionState(Direction.In)]
        public string Hoge { get; set; }

        [RedirectionState(Direction.In)]
        public string Foo { get; set; }

        protected void Page_Load(object sender, EventArgs e)
        {
            Label1.Text = Hoge;
            Label2.Text = Foo;
        }

        protected void Button1_OnClick(object sender, EventArgs e)
        {
        }
    }

RedirectionStateといった属性を作成して、この属性で値の保存とリストアを自動化できるようにします。Direction.InとかOutとかは、値を保存するかリストアするかどうかを示します。この例では、Default.aspxからAbout.aspxへリダイレクトすると、HogeとFooプロパティが引き継がれます。


この機能を実現するポイントは、IHttpHandlerFactoryの実装です。このクラスでPageのPreInitイベントとUnloadイベントに適当なイベントハンドラーを登録しておきます。

IHttpHandlerFactoryの実装

    public class MyPageHandlerFactory : PageHandlerFactory
    {
        public override IHttpHandler GetHandler(HttpContext context, string requestType, string virtualPath, string path)
        {
            var handler = base.GetHandler(context, requestType, virtualPath, path);
            var page = handler as Page;
            if (page == null)
            {
                return handler;
            }
            page.PreInit += OnPreInit;
            page.Unload += OnUnload;
            return page;
        }

        private void OnPreInit(object sender, EventArgs e)
        {
            var page = (Page) sender;
            if (page.IsPostBack)
            {
                return;
            }
            var guid = page.Request.QueryString["rid"];
            if (guid == null)
            {
                return;
            }
            var redirectionStateCollection = GetRedirectionStateCollection();
            var redirectionState = redirectionStateCollection[guid];
            if (redirectionState == null)
            {
                return;
            }
            if (!string.Equals(page.Request.RawUrl, redirectionState.RedirectLocation, StringComparison.OrdinalIgnoreCase))
            {
                throw new InvalidOperationException();
            }
            var type = page.GetType();
            foreach (var property in type.GetProperties(BindingFlags.SetProperty | BindingFlags.Instance | BindingFlags.Public))
            {
                var attribute = (RedirectionStateAttribute) Attribute.GetCustomAttribute(property, typeof(RedirectionStateAttribute));
                if (attribute != null && attribute.Direction == Direction.In)
                {
                    if (property.CanWrite)
                    {
                        var value = redirectionState[property.Name];
                        if (value != null)
                        {
                            property.SetValue(page, value, null);
                        }
                    }
                }
            }
        }

        private void OnUnload(object sender, EventArgs e)
        {
            var page = (Page) sender;
            var context = HttpContext.Current;
            var response = context.Response;
            if (!response.IsRequestBeingRedirected)
            {
                return;
            }
            var type = page.GetType();
            var redirectionState = new HttpRedirectionState();
            foreach (var property in type.GetProperties(BindingFlags.GetProperty | BindingFlags.Instance | BindingFlags.Public))
            {
                var redirectable = (RedirectionStateAttribute) Attribute.GetCustomAttribute(property, typeof(RedirectionStateAttribute));
                if (redirectable != null && redirectable.Direction == Direction.Out)
                {
                    if (property.CanRead)
                    {
                        redirectionState[property.Name] = property.GetValue(page, null);
                    }
                }
            }
            var guid = Guid.NewGuid().ToString();
            var location = response.RedirectLocation;
            response.RedirectLocation = location.IndexOf('?') > 0 ? location + "&rid=" + guid : location + "?rid=" + guid;
            redirectionState.RedirectLocation = response.RedirectLocation;
            var redirectionStateCollection = GetRedirectionStateCollection();
            redirectionStateCollection[guid] = redirectionState;
            context.Session["redirectionStates"] = redirectionStateCollection;
        }

        private HttpRedirectionStateCollection GetRedirectionStateCollection()
        {
            var context = HttpContext.Current;
            return (HttpRedirectionStateCollection)context.Session["redirectionStates"]
                   ?? new HttpRedirectionStateCollection();
        }
    }

ちょっと考えたのは2点。

  • リダイレクトのときにクエリストリングにGUIDを振っておいて、同一セッションの別リクエストとまざらないようにしています。また、リダイレクト後のGETのときにリクエストのURLがリダイレクトに指定されたURLと同じかチェックするようにしてみました。
  • セッションに置いたリダイレクトスコープの値ですが、リストアしたあとはすぐに破棄せずにセッションに残しています。リロードしたときにもまだ使えたほうがうれしいような気がして。実装していないですが、MRU(Most Recently Used)で指定した数ぶんだけ残しておくのはどうだろうかと思っています。

MyPageHandlerFactoryはもちろんWeb.configに登録しておきます。

    <system.webServer>
        <handlers>
            <add name="MyPageHandlerFactory" verb="*" path="*.aspx" type="RedirectionStateSamle.MyPageHandlerFactory, RedirectionStateSamle" />
        </handlers>
        <modules runAllManagedModulesForAllRequests="true" />
    </system.webServer>


メインとなるのは上のMyPageHandlerFactoryですが、他に自作したクラスはこんなです。

    [Flags]
    public enum Direction
    {
        In, Out
    }


    [Serializable]
    public class HttpRedirectionState : DictionaryBase
    {
        public string RedirectLocation { get; set; }

        public object this[string name]
        {
            get
            {
                return Dictionary[name];
            }
            set
            {
                this.Dictionary[name] = value;
            }
        }
    

    [Serializable]
    public class HttpRedirectionStateCollection : DictionaryBase
    {
        public HttpRedirectionState this[string name]
        {
            get
            {
                return (HttpRedirectionState)Dictionary[name];
            }
            set
            {
                this.Dictionary[name] = value;
            }
        }
    }

    [AttributeUsage(AttributeTargets.Property, Inherited = true)]
    public class RedirectionStateAttribute : Attribute
    {
        public Direction Direction { get; private set; }

        public RedirectionStateAttribute(Direction direction)
        {
            Direction = direction;
        }
    }