2012年7月30日月曜日

外部のWebサービスを呼び出す側の簡単なユニットテスト

外部システムとの連携で、テストしにくいこといろいろあると思うんだけど、その辺について先日会社でこうするといいんじゃないのー?って話した内容のおさらい。

外部システムって言っても、同一システムの一部であることも含めます。とにかく、テストをしたいと思ってる部分から接続する必要のあるものすべて。社外か社内かはあんまり関係なく。WCFかASMXかRESTかすら関係なく。ホントはDBもだけど、今回はHTTPベースのWebサービスということで。

まずは、わかりやすくWCFでWebサービスを作ってみます。別にWebAPIでもいいしー。なんでもいいんだけどー。用意するのはVS2010とFiddlerとブラウザとテキストエディタ。

[ServiceContract]
public class WebScraper
{
  [OperationContract]
  public string ExtractPageTitle(string url)
  {
    var title = "-unknown-";
    var client = new HttpClient();
    var html = string.Empty;
    client.GetStringAsync(url)
          .ContinueWith(response =>
          {
            html = response.Result;
          })
          .Wait();

    var match = Regex.Match(html, "<title>([^<]+)</title>");
    if(match.Success)
    {
      title = match.Groups[1].Value;
      if (!string.IsNullOrWhiteSpace(title))
          title = HttpUtility.HtmlDecode(title);
    }

    return title;
  }
}

なんでもいいんだけど。こんなの。外部Webサービスのつもりで。URLを指定して呼び出すと、ページタイトル取得する、っていう意味不明なWebサービスがありました。として。

wcftest1

WcfTestClientで実行してみて動くのを確認。大丈夫だね。

これを呼び出すMvcアプリケーションも用意。HomeControllerしかいないよ。

private string Logic(string value)
{
  return new string(value.Reverse().ToArray()).ToUpper();
}

public ActionResult Index(string url)
{
  ViewBag.Message = "URLを入れるとページのタイトルを取得するよ";
  if(!string.IsNullOrWhiteSpace(url))
  {
      var service = new ApiService.WebScraperClient();
      var title = service.ExtractPageTitle(url);
      ViewBag.Message = Logic(title);
  }

  return View();
}

Indexアクションでは、サービス参照の追加で作成したサービスクライアントクラスを実行してページタイトル取得した後に、反転して大文字にするロジックが含まれてるつもりです。

wcftest2 wcftest3

はいはい。雰囲気出てますね。

このIndexアクションのテストを書いてみました。

[TestMethod]
public void Index_マイクロソフト()
{
  // Arrange
  HomeController controller = new HomeController();

  // Act
  ViewResult result = controller.Index("http://www.microsoft.com") as ViewResult;

  // Assert
  Assert.AreEqual("NOITAROPROC TFOSORCIM", result.ViewBag.Message);
}

[TestMethod]
public void Index_いろいろ()
{
  // Arrange
  var list = new[] { "http://www.google.com", "http://www.twitter.com", "http://takepara.blogspot.jp" };

  // Act
  var result = list.Select(url => new HomeController().Index(url) as ViewResult).ToArray();

  // Assert
  Assert.AreEqual("ELGOOG", result[0].ViewBag.Message);
  Assert.AreEqual("RETTIWT", result[1].ViewBag.Message);
  Assert.AreEqual("!だらかれこはみし楽お", result[2].ViewBag.Message);
}

これといって変なこともなく。HomeControllerのインスタンス作ってUrlを引数にIndexを呼び出したら、ページタイトルが反転して大文字になってるかどうかを確認してるだけです。

外部サービスがですね、ホントに社外のもので、そこのロジックがどういうものかは知らないっていうことになると、テストしたい部分ってどのになりますかね。通信経路ですかね?それとも、外部サービスのロジックですかね?ユニットテストでまわしたいとすると、そこってテストしたい部分じゃないと思います。自分書いたコードじゃないし、環境だし。

なので、ユニットテストしたいのは反転して大文字にするロジックですよね。たぶん。とはいえ、すでに存在するコードのテストをしたい、っていう場合もあるでしょう。みんなTDDなわけでもなく。そんな時はMVCを使って簡単なFakeサーバー。HTTP(S)だしー。なんでもいいでしょー。

実際の通信内容を保持しといて、リクエストのたびにその値を返せば開発環境で完結したテストが実行できて便利。何パターンか用意しておけばいいしね。その辺はテストしたい対象に合わせて良しなに用意しましょう。

今回は、上記テストがパスするように用意します。エラーのパターンがないのはご愛嬌。

public class FakeController : Controller
{
  private static string RequestBody(HttpContextBase context)
  {
      context.Request.InputStream.Position = 0;
      var reader = new StreamReader(context.Request.InputStream);
      return HttpUtility.UrlDecode(reader.ReadToEnd(), Encoding.UTF8);
  }

  private Dictionary<Func<HttpContextBase, bool>, Func<ActionResult>> _responseHandlers =
      new Dictionary<Func<HttpContextBase, bool>, Func<ActionResult>>
        {
          {
            (context) =>
                context.Request.CurrentExecutionFilePath == "/HttpApiServer.Fake/WebScraper.svc" && 
                RequestBody(context).Contains("www.microsoft.com"),
            ()=>new FilePathResult("~/App_Data/www.microsoft.com.txt", "text/xml; charset=utf-8")
          },
          {
            (context) =>
                context.Request.CurrentExecutionFilePath == "/HttpApiServer.Fake/WebScraper.svc" && 
                RequestBody(context).Contains("www.google.com"),
            ()=>new FilePathResult("~/App_Data/www.google.com.txt", "text/xml; charset=utf-8")
          },
          {
            (context) =>
                context.Request.CurrentExecutionFilePath == "/HttpApiServer.Fake/WebScraper.svc" && 
                RequestBody(context).Contains("www.twitter.com"),
            ()=>new FilePathResult("~/App_Data/www.twitter.com.txt", "text/xml; charset=utf-8")
          },
          {
            (context) =>
                context.Request.CurrentExecutionFilePath == "/HttpApiServer.Fake/WebScraper.svc" && 
                RequestBody(context).Contains("takepara.blogspot.jp"),
            ()=>new FilePathResult("~/App_Data/takepara.blogspot.jp.txt", "text/xml; charset=utf-8")
          },
        };

    
  public ActionResult Api()
  {
    var handler = _responseHandlers.Where(_ => _.Key(HttpContext)).Select(_ => _.Value).FirstOrDefault();
    return handler !=null ? 
            handler() : 
            new HttpStatusCodeResult(500);
  }
}

リクエストに合わせて結果を返すだけなんですけどね。ローカルにファイルを作成するために、一度Fiddlerで一通り動かして、データを取得しておきましょう!今回の場合だとWebサービスをIISで動かすと都合がよかったです。Fiddlerの都合で。

<endpoint address="http://localhost/HttpApiServer.Real/WebScraper.svc" binding="basicHttpBinding"
  bindingConfiguration="BasicHttpBinding_WebScraper" contract="ApiService.WebScraper"
  name="BasicHttpBinding_WebScraper" />

こんな感じのendpointだったものを以下のようにFiddler通すように変更。

<endpoint address="http://ipv4.fiddler/HttpApiServer.Real/WebScraper.svc" binding="basicHttpBinding"
  bindingConfiguration="BasicHttpBinding_WebScraper" contract="ApiService.WebScraper"
  name="BasicHttpBinding_WebScraper" />

wcftest4

そうすると、www.microsoft.comへを指定した、API呼出しのRequestとResponseは画像のような結果になります。ここから、ResponseのBody部だけ抜き出して、ファイルに保存しておき、それをリクエストに合わせてレスポンスするのがFakeサーバーの役目です。

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><ExtractPageTitleResponse xmlns="http://tempuri.org/"><ExtractPageTitleResult>Microsoft Corporation</ExtractPageTitleResult></ExtractPageTitleResponse></s:Body></s:Envelope>

↑たとえばこれをwww.microsoft.com.txtっていうファイルにしておきます。どんなパスを指定してもFakeControllerのApiアクションにリクエストが届くようにルーティング登録とWeb。configで.svcの拡張子を外しておきましょう。.svc外しとかないとSystem.ServiceModel.Activationが横取りするのでApiアクションにリクエストが届かず、404エラーになっちゃいます。

public class MvcApplication : System.Web.HttpApplication
{
  public static void RegisterGlobalFilters(GlobalFilterCollection filters)
  {
      filters.Add(new HandleErrorAttribute());
  }

  public static void RegisterRoutes(RouteCollection routes)
  {
      routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

      routes.MapRoute(
          "FakeServer", // Route name
          "{*path}", // URL with parameters
          new { controller = "Fake", action = "Api" } // Parameter defaults
      );

  }

  protected void Application_Start()
  {
      AreaRegistration.RegisterAllAreas();

      RegisterGlobalFilters(GlobalFilters.Filters);
      RegisterRoutes(RouteTable.Routes);
  }
}

↑Global.asaxね。MapRouteだけいじってます。

<system.web>
  <compilation debug="true" targetFramework="4.0">
    <buildProviders>
      <remove extension=".svc"/>
    </buildProviders>
    <assemblies>
    :
    </assemblies>
  </compilation>

  <pages>
    <namespaces>
    :
    </namespaces>
  </pages>
</system.web>

↑web.configね。buildProvidersで.svcを削除してます。一通りファイルを用意して、テストのendpointをこのFakeサーバーに向けて実行します。

<endpoint address="http://localhost/HttpApiServer.Fake/WebScraper.svc" binding="basicHttpBinding"
  bindingConfiguration="BasicHttpBinding_WebScraper" contract="ApiService.WebScraper"
  name="BasicHttpBinding_WebScraper" />

ここまでが既に作成したものに対して、テスト実行したいときにとる手法で、WebサービスのFakeパターンですが、ユニットテストっていう意味ならテスト対象のIndexアクションが本当に通信するコードを呼び出さないほうが都合がいいと思います。

なので、これから作成するなら、Webサービスへのアクセスを抽象化し、ユニットテストで差し替えるようにすると、本来テストしたい部分に特化したテストができていいと思います。例えば以下のようにControllerのコードを変えてみます。

public interface IApiAdapter
{
  string Execute(string url);
}

public class HttpApiAdapter: IApiAdapter
{
  public string Execute(string url)
  {
    var service = new ApiService.WebScraperClient();
    return service.ExtractPageTitle(url);
  }
}

public class HomeController : Controller
{
  public static Func<IApiAdapter> ApiAdapterFactory = () => new HttpApiAdapter();

  private string Logic(string value)
  {
      return new string(value.Reverse().ToArray()).ToUpper();
  }

  public ActionResult Index(string url)
  {
    ViewBag.Message = "URLを入れるとページのタイトルを取得するよ";
    if (!string.IsNullOrWhiteSpace(url))
    {
      var adapter = ApiAdapterFactory();
      var title = adapter.Execute(url);
      ViewBag.Message = Logic(title);
    }

    return View("Index");
  }
}

IApiAdapterと、その実装であるHttpApiAdapterを用意して、インスタンスはファクトリメソッドから実行するように書き換えました。

テストのほうも同じく書き換えます。まずはFakeApiAdapterの追加。

 

public class FakeApiAdapter : IApiAdapter
{
  public string Execute(string url)
  {
    var list = new Dictionary<string,string> 
    { {"http://www.google.com","Google"}, 
      {"http://www.twitter.com","Twitter"}, 
      {"http://takepara.blogspot.jp","お楽しみはこれからだ!"},  };

    return list[url];
  }
}

あとはTestInitializerでファクトリメソッドのFuncデリゲート書き換え。

[TestInitialize]
public void Initialize()
{
  HomeController.ApiAdapterFactory = () => new FakeApiAdapter();
}

以上!

これで実際のプログラムにアクセスするときは、ClientBaseのWCFクライアントクラスから実Webサービス呼出しをするし、ユニットテストからは呼んだつもりで偽アダプター(Stub)が結果を返します。

wcftest5

準備するの面倒かもしれないけど、この辺もちゃんとテストしましょー。なるべく環境依存しなくて済むようにね。

2012年7月16日月曜日

c#でインスタンスをFreeze

クラスをジェネレートする際にメタデータでFreezableかどうか保持して作るのが楽ちんなのかなー。

Castle.DynamicProxyとPostSharpで実装してみました。

DynamicProxy | Castle Project

どうやってインストールするんですか!と、思ったけど、NuGetですね。Castle.Core で。

The Creators of PostSharp – SharpCrafters

こっちもNuGetで。ただ、無料のBASICライセンスを別途取得する必要がありました。むかーし、登録したアカウントが邪魔してちょっと手間取った...。

Castle.DynamicProxy

まずはCastle。ベケットは出てこないよ。

Dynamic Proxy Tutorial « Krzysztof Koźmic on software

ここに書いてるとおりです!

ようするに動的に派生クラスを生成するFactoryを使ってインスタンスを作るのと、virtualなプロパティ/メソッドに対してインターセプトできるようになる、っていうのがCastle.DynamicProxyです。overrideね。

    public interface IFreezable
    {
        bool IsFrozen { get; }
        void Freeze();
    }

Freezeできるようにするためのインターフェース。CastleでもPostSharpでも同じのを使うよ。

    public class Animal
    {
        public virtual string Name { get; set; }
        public virtual int Age { get; set; }

        public override string ToString()
        {
            return string.Format("Name: {0}, Age: {1}", Name, Age);
        }
    }

こんなモデルクラスを用意します。もちろんvirtualで。だけど、これ自体がIFreezableを実装する必要はないので注意。

ここからは、ちょっと長めのコードになるけど、簡単に説明するとIFreezableを実装したInterceptorを実装し、それを注入したProxyインスタンスを生成。Proxyインスタンスではvirtualなものにアクセスした場合、Interceptorが実行されるのでその時都度Freezeされたかチェックする、っていう流れです。

proxy_pipeline

こんなイメージ。公式サイトより

    class FreezableInterceptor : IInterceptor, IFreezable
    {
        private bool _isFrozen;

        public void Freeze()
        {
            _isFrozen = true;
        }

        public bool IsFrozen
        {
            get { return _isFrozen; }
        }

        public void Intercept(IInvocation invocation)
        {
            if (_isFrozen && invocation.Method.Name.StartsWith("set_", StringComparison.OrdinalIgnoreCase))
            {
                throw new FreezableException("object frozen!");
            }

            invocation.Proceed();
        }
    }

Interceptorが↑これ。続いてヘルパーのコード。

    static class Freezable
    {
        private static readonly ProxyGenerator _generator = new ProxyGenerator();

        public static bool IsFreezable(object obj)
        {
            return AsFreezable(obj) != null;
        }

        private static IFreezable AsFreezable(object target)
        {
            if (target == null)
                return null;
            var accessor = target as IProxyTargetAccessor;
            if (accessor == null)
                return null;
            return accessor.GetInterceptors().OfType<FreezableInterceptor>().FirstOrDefault();
        }

        public static bool IsFrozen(object obj)
        {
            var freezable = AsFreezable(obj);
            return freezable != null && freezable.IsFrozen;
        }

        public static void Freeze(object freezable)
        {
            var interceptor = AsFreezable(freezable);
            if (interceptor == null)
                throw new FreezableException("Not freezalbe!");
            interceptor.Freeze();
        }

        public static TFreezable CreateInstance<TFreezable>() where TFreezable : class, new()
        {
            var freezableInterceptor = new FreezableInterceptor();
            var proxy = _generator.CreateClassProxy<TFreezable>(freezableInterceptor);
            return proxy;
        }
    }

こっちがちょっと長い。ProxyインスタンスはIProxyTargetAccessorを実装してるので、それを利用して、Interceptorを取得してFreezeしたりIsFrozeしたりです。

    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void Freezeさせる()
        {
            var rex = Freezable.CreateInstance<Animal>();
            rex.Name = "Rex";
            Console.WriteLine(Freezable.IsFreezable(rex)
                                  ? "Rex is freezable!"
                                  : "Rex is not freezable. Something is not working");
            Console.WriteLine(rex.ToString());
            Console.WriteLine("Add 50 years");
            rex.Age += 50;
            Console.WriteLine("Age: {0}", rex.Age);

            Freezable.Freeze(rex);
            Console.WriteLine("Frozen? : " + Freezable.IsFrozen(rex));
            try
            {
                rex.Age++;
                Console.WriteLine("あれー?変更できるー。");
            }
            catch
            {
                Console.WriteLine("Frozen!!");
            }
        }

        [TestInitialize]
        public void Initialize()
        {
            var instance1 = new Animal();
            var instance2 = Freezable.CreateInstance<Animal>();            
        }

        [TestMethod]
        public void 普通のインスタンス作る()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000;i++ )
            {
                var instance = new Animal();
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms",sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void Proxyのインスタンス作る()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = Freezable.CreateInstance<Animal>();
                //Freezable.Freeze(instance);
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void Proxyのインスタンス作ってFreezeする()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = Freezable.CreateInstance<Animal>();
                Freezable.Freeze(instance);
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void 普通のインスタンスでプロパティアクセス()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = new Animal();
                instance.Age = 200;
                instance.Name = "Foo";
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void Proxyのインスタンスでプロパティアクセス()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = Freezable.CreateInstance<Animal>();
                instance.Age = 200;
                instance.Name = "Foo";
                //Freezable.Freeze(instance);                
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }
    }

動かしましょう。

  • Freezeさせる[0:00.218] Success
  • Proxyのインスタンスでプロパティアクセス[0:00.106] Success
  • Proxyのインスタンス作ってFreezeする[0:00.117] Success
  • Proxyのインスタンス作る[0:00.109] Success
  • 普通のインスタンスでプロパティアクセス[0:00.003] Success
  • 普通のインスタンス作る[0:00.002] Success

こんな感じです。Proxy経由は若干遅くなりますね。TestInitializeでわざわざインスタンス作ってるのは、Proxyの生成をここでやらせるためです。こうしとけば、各テストの計測では実行分のみの時間になるから。

DynamicProxyで、うーんと思ったのはvirtualの強制。まぁねー。クラス生成するんだし、そーだよなー、とは思いつつも。

PostSharp

続いてPostSharp。こっちはアセンブリを書き換えてインターセプト。クラス生成よりも強力。ちょっと使い方が分かりにくいところもあるけど、大体なんでもできちゃう系。

今回はInstanceLevelAspectでモデルクラスにインターフェース(IFreezable)を実装し、LocationInterceptionAspectでプロパティアクセスをインターセプト。OnSetValueでIsFrozenを確認してるよ。

    [Serializable]
    [IntroduceInterface(typeof(IFreezable))]
    class FreezableAttribute : InstanceLevelAspect, IFreezable
    {
        [NonSerialized]
        private bool _isFrozen;
        public bool IsFrozen
        {
            get { return _isFrozen; }
        }

        public void Freeze()
        {
            _isFrozen = true;
        }
    }

まずは、既存クラスにインターフェースを後付Mixin

    [Serializable]
    class FrozenAttribute : LocationInterceptionAspect
    {
        public override void OnSetValue(LocationInterceptionArgs args)
        {
            var freezable = args.Instance as IFreezable;
            if (freezable != null && freezable.IsFrozen)
            {
                throw new FreezableException(string.Format("frozen {0}", args.LocationFullName));
            }

            args.ProceedSetValue();
        }
    }

インターフェース実装されてるなら、Freezableだからプロパティアクセスにインターセプト。

これだけ。これだけだけど、これらを1個のAspectにしようとしてスゴイ遠回りした。結果、インターフェースのMixinとインターセプトは同一Aspectにできない、みたい。できる方法があるなら教えて!

    [Freezable, Frozen]
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }

        public override string ToString()
        {
            return string.Format("Name: {0}, Age: {1}", Name, Age);
        }
    }

モデルね。属性に2つ書かないといけないのが残念ポイントだけど、virtualでもないし、これならCollection/List/DictionaryいろいろReadOnlyにしてしまえます。ね。

    [TestClass]
    public class UnitTest2
    {
        [TestMethod]
        public void Freezeさせる()
        {
            var rex = new Person();
            rex.Name = "Rex";
            Console.WriteLine(rex.ToString());
            Console.WriteLine("Add 50 years");
            rex.Age += 50;
            Console.WriteLine("Age: {0}", rex.Age);

            rex.Freeze();
            Console.WriteLine("Frozen? : " + rex.IsFrozen());

            try
            {
                rex.Age++;
                Console.WriteLine("あれー?変更できるー。");
            }
            catch
            {
                Console.WriteLine("Frozen!!");
            }
        }

        [TestMethod]
        public void Newする()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = new Person();
                instance.Freeze();
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void NewしてFreezeする()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = new Person();
                instance.Freeze();
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }

        [TestMethod]
        public void Newしてプロパティアクセス()
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var i = 0; i < 10000; i++)
            {
                var instance = new Person();
                instance.Age = 200;
                instance.Name = "Foo";
            }
            sw.Stop();

            Console.WriteLine("create 10000 instances at {0} ms", sw.ElapsedMilliseconds);
        }
    }

こんなテストで実行。

  • Freezeさせる[0:00.084] Success
  • NewしてFreezeする[0:00.015] Success
  • Newしてプロパティアクセス[0:00.012] Success
  • Newする[0:00.004] Success

素のクラスに比べればそれでも遅くはなるけど、DynamicProxyよりは高速。そりゃそーだ。アセンブリ書き換えてんだから。

1個の属性でできるとうれしいなー。

いずれにせよ、モデルクラス1個ずつFreezableをプロパティごとに実装なんてコード生成以外にありえないと思うじゃない。でも、インターセプトするなら、それも何とか乗り越えられる。その際にProxyクラスにするか、アセンブリ書き換えるかは好みでいいとは思うけど、既存コードほとんどいじらず(Factoryでインスタンス作らなくていい)に済むし早いしでPostSharpが使いやすいかなーと思う海の日。

dotnetConf2015 Japan

https://github.com/takepara/MvcVpl ↑こちらにいろいろ置いときました。 参加してくださった方々の温かい対応に感謝感謝です。