外部システムとの連携で、テストしにくいこといろいろあると思うんだけど、その辺について先日会社でこうするといいんじゃないのー?って話した内容のおさらい。
外部システムって言っても、同一システムの一部であることも含めます。とにかく、テストをしたいと思ってる部分から接続する必要のあるものすべて。社外か社内かはあんまり関係なく。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サービスがありました。として。
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アクションでは、サービス参照の追加で作成したサービスクライアントクラスを実行してページタイトル取得した後に、反転して大文字にするロジックが含まれてるつもりです。
はいはい。雰囲気出てますね。
この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" />
そうすると、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)が結果を返します。
準備するの面倒かもしれないけど、この辺もちゃんとテストしましょー。なるべく環境依存しなくて済むようにね。