ASP.NET

Navigation for ASP.NET Web Forms フレームワークにおける単体テスト

Graham Mendick

コード サンプルのダウンロード

ライブラリのダウンロード

Navigation for ASP.NET Web Forms フレームワークは、navigation.codeplex.com (英語) でホストされているオープン ソース プロジェクトで、ナビゲーションとデータの受け渡しに新たな手法を採用することで、Web フォーム アプリケーションの作成に新しい可能性を開きます。従来から Web フォーム コードでデータを渡す方法は、実行するナビゲーションに依存しています。たとえば、リダイレクト中はデータがクエリ文字列かルーティング データに保持されますが、ポスト バック中はコントロールの値かビューステートに保持されます。ところが、Navigation for ASP.NET Web Forms フレームワーク (以下「Navigation フレームワーク」) では、どのようなシナリオでも同じデータ ソースが使用されます。

最初の記事 (msdn.microsoft.com/magazine/hh975349) で、Navigation フレームワークを紹介し、その中心をなす考え方とメリットをデモするサンプル オンライン アンケートを作成しました。特に、動的で状況に依存する一連の階層型ハイパーリンクを生成し、ユーザーが前の質問に戻るとその回答が復元されるようにして、静的 ASP.NET サイト マップ機能の限界を克服する方法を示しました。

このときも、Navigation フレームワークを使用すれば、ASP.NET MVC アプリケーションがうらやむような Web フォーム コードを記述できると主張しましたが、サンプル アンケート アプリケーションは、コードを分離コードに集中したため、単体テストができず、これを実証できませんでした。

今回は、このアンケート アプリケーションを編集し、典型的な ASP.NET MVC アプリケーションと同じくらい構造化し、単体テストにもより高い水準で使用できるようにすることで、この問題を解決します。標準の ASP.NET データ バインドを Navigation フレームワークと連携して使用し、分離コードを取り除き、ビジネス ロジックを別のクラスに取り出して、単体テストを可能にします。この単体テストはモックを一切必要とせず、コード カバレッジにナビゲーション ロジックを含みます。この機能は、他の ASP.NET の単体テスト手法ではあまり実現されません。

データ バインド

過去の Web フォーム コードは、分離コード ファイルがあふれかえっていますが、こうである必要はありません。Web フォームは公開時からデータ バインドに取り組んできましたが、Visual Studio がデータ ソース コントロールと双方向で更新できるバインド用の Bind 構文を導入したのは 2005 年のことで、これにより典型的な MVC アプリケーションと同様の構造で Web フォーム アプリケーションを開発できるようになりました。とりわけ単体テストに関しては、次期バージョンの Visual Studio 向け Web フォームの開発作業のほとんどが単体テストの分野に注がれてきたことを反映して、こうしたコードのメリットが広く認識されています。

このデモを行うため、最初の記事で開発したアンケート アプリケーションを MVC に似たアーキテクチャに変換します。コントローラー クラスはビジネス ロジックを、ビューモデル クラスはコントローラーとビューの間で通信するデータを保持します。現在分離コードにあるコードをほとんどそのまま切り取ってコントローラーに貼り付けられるので、開発作業はあまり必要ありません。

Question1.aspx から始め、まず選択済みの回答をコントローラーと受け渡しできるように、文字列プロパティを含む Question ビューモデル クラスを作成します。

public class Question
{
  public string Answer
  {
    get;
    set;
  }
}

次はコントローラー クラス SurveyController を作成します。これは、MVC コントローラーとは異なり、Plain Old CLR Object (POCO) です。Question1.aspx にはメソッドが 2 つ必要で、1 つは Question ビューモデル クラスを返すデータ取得用、もう 1 つは Question ビューモデル クラスを受け取るデータ更新用です。

public class SurveyController
{
  public Question GetQuestion1()
  {
    return null;
  }
  public void UpdateQuestion1(Question question)
  {
  }
}

これらのメソッドを作成するには、Question1.aspx 分離コードのコードから、ページ読み込みロジックを GetQuestion1 に移動し、ボタン クリック ハンドラーのロジックを UpdateQuestion1 に移動します。コントローラーはページのコントロールにアクセスできないので、ラジオ ボタン リストではなく Question ビューモデル クラスを使用して回答の取得と設定を行います。GetQuestion1 メソッドは、返される既定の回答が「Web フォーム」になるように、次のように追加の調整が必要です。

public Question GetQuestion1()
{
  string answer = "Web Forms";
  if (StateContext.Data["answer"] != null)
  {
    answer = (string)StateContext.Data["answer"];
  }
  return new Question() { Answer = answer };
}

MVC のデータ バインドは要求レベルで行われ、ルートの登録時に要求がコントローラー メソッドにマップされますが、Web フォームのデータ バインドはコントロール レベルで行われ、ObjectDataSource を使用してマッピングが実行されます。そこで、Question1.aspx を SurveyController メソッドにフックするため、適切に構成されたデータ ソースに接続される FormView を追加します。

<asp:FormView ID="Question" runat="server"
  DataSourceID="QuestionDataSource" DefaultMode="Edit">
  <EditItemTemplate>
  </EditItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="QuestionDataSource" 
  runat="server" SelectMethod="GetQuestion1" 
  UpdateMethod="UpdateQuestion1" TypeName="Survey.SurveyController"  
  DataObjectTypeName="Survey.Question" />

最後の手順では、ラジオ ボタン リストとボタンで構成される質問を、FormView の EditItemTemplate 内部に移動します。同時に、データ バインドのしくみが機能するには変更が 2 点必要です。1 つは Bind 構文を使用し、GetQuestion1 から返された回答を表示して、新しく選択された回答を UpdateQuestion1 に返すことです。もう 1 つは、ボタンの CommandName を Update に設定し、ボタンが押されたときに UpdateQuestion1 が自動的に呼び出されるようにすることです (「Web フォーム」を既定の回答に設定して GetQuestion1 で管理するようにしたため、最初のリスト項目の Selected 属性を削除しているのがわかります)。

<asp:RadioButtonList ID="Answer" runat="server"
  SelectedValue='<%# Bind("Answer") %>'>
  <asp:ListItem Text="Web Forms" />
  <asp:ListItem Text="MVC" />
</asp:RadioButtonList>
<asp:Button ID="Next" runat="server" 
  Text="Next" CommandName="Update" />

Question1.aspx のプロセスが完成し、分離コードはすっきりと空になりました。同じ手順で Question2.aspx にもデータ バインドを追加できますが、戻るナビゲーションを行うハイパーリンクにページ読み込みのコードが関連するため、これをしばらく残す必要があり、分離コードは完全に空にはなりません。次のセクションで、Navigation フレームワークとデータ バインドの統合について説明しながら、このコードをマークアップに移動し、分離コードを空にします。

Thanks.aspx へのデータ バインドの追加も似ていますが、名前が不適切に感じられる Question ビューモデル クラスを再利用するのではなく、選択された回答を保持する文字列プロパティを備えた Summary クラスを新しく作成します。

public class Summary
{
  public string Text
  {
    get;
    set;
  }
}

Thanks.aspx は読み取り専用の画面なので、Question2.aspx と同様、コントローラーに必要なのはデータ取得メソッドのみで、戻るナビゲーション ロジック以外のページ読み込みコードをすべてこのメソッドに移動できます。

public Summary GetSummary()
{
  Summary summary = new Summary();
  summary.Text = (string)StateContext.Data["technology"];
  if (StateContext.Data["navigation"] != null)
  {
    summary.Text += ", " + (bool)StateContext.Data["navigation"];
  }
  return summary;
}

更新機能は必要ないため、EditItemTemplate の代わりに FormView ItemTemplate を使用し、Bind の代わりに Eval という一方向バインド用の構文を使用します。

<asp:FormView ID="Summary" runat="server" 
  DataSourceID="SummaryDataSource">
  <ItemTemplate>
    <asp:Label ID="Details" runat="server" 
      Text='<%# Eval("Text") %>' />
  </ItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="SummaryDataSource" 
  runat="server"
  SelectMethod="GetSummary" 
  TypeName="Survey.SurveyController" />

アンケート アプリケーションのビジネス ロジックを別のクラスに抽出したところで、単体テストに向けての準備の半分は完了です。ただし、分離コードと事実上同じコードをコントローラーに移しているため、データ バインド機能を最大限に活かしきれていません。

ナビゲーション データ バインド

アンケート アプリケーションのコードには、まだいくつか問題があります。ナビゲーション ロジックを含むのは SurveyController の更新メソッドだけにし、分離コードを空にする必要があります。1 つ目の問題は Get メソッドの単体テストを必要以上に複雑にし、2 つ目の問題はすべてのロジックを単体テストの対象にできなくします。単体テストは、これらの問題が解決するまで開始できません。

データ ソース コントロールの選択パラメーターが、冗長なデータバインド メソッドにある HttpRequest オブジェクトにアクセスします。たとえば、QueryStringParameter クラスはクエリ文字列データをパラメーターとしてデータバインド メソッドに渡します。Navigation フレームワークには NavigationDataParameter クラスがあり、StateContext オブジェクトの状態データに相当する役割を果たします。

この NavigationDataParameter を利用し、GetQuestion1 に戻って状態データにアクセスするコードをすべて削除して、回答をメソッド パラメーターにします。これにより、コードは大幅に簡素化されます。

public Question GetQuestion1(string answer)
{
  return new Question() { Answer = answer ?? "Web Forms" };
}

これに併せて Question1.aspx を変更し、データ ソースに NavigationDataParameter を追加します。そのため、まず、ページの先頭で次のように Navigation 名前空間を登録します。

<%@ Register assembly="Navigation" 
                       namespace="Navigation" 
                        tagprefix="nav" %>

次に、NavigationDataParameter をデータ ソースの選択パラメーターに追加します。

<asp:ObjectDataSource ID="QuestionDataSource" runat="server"
  SelectMethod="GetQuestion1" UpdateMethod="UpdateQuestion1" 
  TypeName="Survey.SurveyController" 
  DataObjectTypeName="Survey.Question" >
  <SelectParameters>
    <nav:NavigationDataParameter Name="answer" />
  </SelectParameters>
</asp:ObjectDataSource>

Web 固有のコードをすべて取り除いたので、GetQuestion1 メソッドの単体テストを容易に実行できるようになります。GetQuestion2 についても、同じことを行います。

GetSummary メソッドには、回答ごとに 1 つずつ、2 つのパラメーターが必要です。2 つ目のパラメーターは UpdateQuestion2 からデータが渡される方法に対応するブール値で、2 つ目の質問が必ず問われるわけではないため、Null を指定できなければなりません。

public Summary GetSummary(string technology, bool? navigation)
{
  Summary summary = new Summary();
  summary.Text = technology;
  if (navigation.HasValue)
  {
    summary.Text += ", " + navigation.Value;
  }
  return summary;
}

これと対応して Thanks.aspx のデータ ソースを変更し、2 つの NavigationDataParameter を追加します。

<asp:ObjectDataSource ID="SummaryDataSource" runat="server"
  SelectMethod="GetSummary" TypeName="Survey.SurveyController" >
  <SelectParameters>
    <nav:NavigationDataParameter Name="technology" />
    <nav:NavigationDataParameter Name="navigation" />
  </SelectParameters>
</asp:ObjectDataSource>

これで、ナビゲーション ロジックを含むのはコントローラーの更新メソッドのみになったので、アンケート アプリケーションのコードの 1 つ目の問題は解決です。

Navigation フレームワークは Web フォームのサイト マップが提供する静的階層リンクのナビゲーション機能を改良し、アクセスした状態と状態データを追跡し、ユーザーが実際にたどる状況依存の階層リンクの追跡を構築します。分離コードを必要としないで、マークアップに戻るナビゲーションのハイパーリンクを構築するため、Navigation フレームワークは SiteMapPath コントロールに似た CrumbTrailDataSource を提供します。CrumbTrailDataSource を ListView のバッキング データ ソースに使用すると、項目の一覧を返します。1 つ 1 つの項目が、これまでにアクセスした状態に対応し、状況依存のナビゲーションがその状態に戻るための NavigationLink URL を含んでいます。

この新しいデータ ソースを使用し、Question2.aspx の戻るナビゲーションをマークアップに移動します。まず、CrumbTrailDataSource に接続する ListView を追加します。

<asp:ListView ID="Crumbs" runat="server" 
  DataSourceID="CrumbTrailDataSource">
  <LayoutTemplate>
    <asp:PlaceHolder ID="itemPlaceholder" runat="server" />
  </LayoutTemplate>
  <ItemTemplate>
  </ItemTemplate>
</asp:ListView>
<nav:CrumbTrailDataSource ID="CrumbTrailDataSource" runat="server" />

次に、Question2.aspx 分離コードからページ読み込みのコードを削除し、戻るナビゲーション ハイパーリンクを ListView ItemTemplate 内に移動し、Eval バインドを使用して NavigateUrl プロパティを生成します。

<asp:HyperLink ID="Question1" runat="server"
  NavigateUrl='<%# Eval("NavigationLink") %>' Text="Question 1"/>

HyperLink Text プロパティを "Question 1" にハードコーディングしています。Question2.aspx では最初の質問に戻るナビゲーションしか起こり得ないので、この方法でも完全に機能します。ただし、Thanks.aspx は 1 問目にも 2 問目にも戻る可能性があるため、これは当てはまりません。さいわい、StateInfo.config ファイルに入力されたナビゲーション構成は、たとえば次のように、タイトル属性をそれぞれの状態に関連付けることができます。

<state key="Question1" page="~/Question1.aspx" title="Question 1">

次に、CrumbTrailDataSource はこのタイトルをデータ バインドに使用できるようにします。

<asp:HyperLink ID="Question1" runat="server"
  NavigateUrl='<%# Eval("NavigationLink") %>' 
  Text='<%# Eval("Title") %>'/>

Thanks.aspx にこれと同じ変更を行えば分離コードがすべて空になるため、アンケート アプリケーション コードの 2 つ目の問題が解決します。ただし、SurveyController の単体テストを実行できなければ、これまでの努力がすべて無駄になります。

単体テスト

分離コードを空にし、UI ロジックをすべてページ マークアップに移動して、アンケート アプリケーションの構造を適切にしたので、いよいよ SurveyController クラスの単体テストを実行します。GetQuestion1、GetQuestion2、および GetSummary の各データ取得メソッドにはWeb 固有のコードが含まれていないため、明らかに単体テストが可能です。単体テストで問題になるのは、UpdateQuestion1 メソッドと UpdateQuestion2 メソッドだけです。Navigation フレームワークがないと、これら 2 つのメソッドには、ASPX ページ間でデータを移動して渡すために従来から行われていたようにルーティングとリダイレクトの呼び出しが含まれ、どちらも Web 環境外で使用された場合には例外を返します。単体テストは最初のハードルでつまずいてしまいます。ところが、Navigation フレームワークを利用すれば、2 つのメソッドはコードの変更やモック オブジェクトを一切必要としないで、完全な単体テストが可能になります。

出発点として、アンケート用の Unit Test プロジェクトを作成します。SurveyController クラス内の任意のメソッド内部を右クリックし、[単体テストの作成...] をクリックすると、必要な参照を含むプロジェクトと SurveyControllerTest クラスが作成されます。

Navigation フレームワークは、StateInfo.config ファイルで構成するために、状態と遷移の一覧を必要とします。単体テスト プロジェクトで同じナビゲーション構成を使用するために、単体テストが実行されるときに Web プロジェクトから StateInfo.config ファイルが配置される必要があります。これを念頭に置き、Local.testsettings ソリューション項目をダブルクリックし、[配置] タブの [配置を有効にする] チェックボックスをオンにします。次に、SurveyControllerTest クラスをこの StateInfo.config ファイルを参照する DeploymentItem 属性で装飾します。

[TestClass]
[DeploymentItem(@"Survey\StateInfo.config")]
public class SurveyControllerTest
{
}

次に、app.config ファイルをこの配置済み StateInfo.config ファイルを参照するテスト プロジェクトに追加する必要があります (この構成は Web プロジェクトでも必要になりますが、NuGet をインストールすると自動的に追加されます)。

<configuration>
  <configSections>
    <sectionGroup name="Navigation">
      <section name="StateInfo" type=
        "Navigation.StateInfoSectionHandler, Navigation" />
    </sectionGroup>
  </configSections>
  <Navigation>
    <StateInfo configSource="StateInfo.config" />
  </Navigation>
</configuration>

この構成を配置すると、単体テストが開始できます。単体テストの構造化には、AAA パターンを使用します。

  1. 準備 (Arrange): 前提条件とテスト データを設定します。
  2. 実行 (Act): 単体テストの対象を実行します。
  3. アサート (Assert): 結果を検証します。

まず UpdateQuestion1 メソッドから始めます。ここでは、Navigation フレームワークでのナビゲーションとデータの受け渡しをテストする際に、これら 3 つの手順のそれぞれで必要になることを示します。

準備 (Arrange) 手順では、単体テストを設定し、テスト対象のオブジェクトとメソッドに渡すパラメーターを作成します。UpdateQuestion1 の場合は、SurveyController と、関連する回答が設定された Question を作成します。ただし、追加のナビゲーションの設定条件が必要で、Web アプリケーションの開始時に行われるナビゲーションをミラー化します。アンケート Web アプリケーションが開始されたら、Navigation フレームワークはスタートアップ ページ Question1.aspx に対する要求をインターセプトし、StateInfo.config ファイルでこの要求と一致するパス属性が付けられたダイアログに移動します。

<dialog key="Survey" initial="Question1" path="~/Question1.aspx">

ダイアログのキーを使用したナビゲーションは、初期属性で示された状態に進み、Question1 の状態に到達します。単体テストでスタートアップ ページを設定することはできないため、このダイアログ ナビゲーションは手動で実行する必要があり、これは準備 (Arrange) の手順に必要な追加条件です。

StateController.Navigate("Survey");

実行 (Act) の手順は、テストするメソッドを呼び出します。これには、回答が設定された Question を UpdateQuestion1 に渡すだけで、ナビゲーション固有の詳細は一切必要ありません。

アサート (Assert) の手順は、結果を期待値と比較します。ナビゲーション結果の検証とデータの受け渡しは、Navigation フレームワークのクラスを使用して実行できます。StateContext は、ナビゲーションの間に渡された NavigationData で初期化される Data プロパティを介して、状態データへのアクセスを提供します。これを使用して、UpdateQuestion1 が次の状態用に選択された回答を渡すことを検証できます。そのため、「Web フォーム」がメソッドに渡されることを想定すると、アサート (Assert) は次にようになります。

Assert.AreEqual("Web Forms", (string) StateContext.Data["technology"]);

StateContext には、現在状態を追跡する State プロパティもあります。これは、ナビゲーションが期待どおりに行われたかどうかを確認するのに使用できます。たとえば、「Web フォーム」を UpdateQuestion1 に渡すことにより Question2 に移動するかを確かめます。

Assert.AreEqual("Question2", StateContext.State.Key);

StateContext には現在状態の詳細と関連データが保持されますが、Crumb は同様にこれまでにアクセスした状態とそのデータを保持するクラスです。Crumb (階層リンク) と呼ぶのは、ユーザーが移動するたびに新しい状態とデータが階層リンクの追跡に追加されるためです。この階層リンクの追跡、または階層リンクの一覧は StateController の Crumbs プロパティからアクセスできます (これは、前のセクションの CrumbTrailDataSource のバッキング データです)。この一覧は、UpdateQuestion1 がナビゲーションの前に渡された回答を状態データに格納することを確認するのに必要です。これは、ナビゲーションが行われたら、この状態データを保持する階層リンクが作成されるためです。渡された回答は「Web フォーム」だと想定し、最初で唯一の階層リンクのデータを検証します。

Assert.AreEqual("Web Forms", (string) StateController.Crumbs[0].Data["answer"]);

Navigation フレームワークに関する、構造化された単体テストを作成する AAA パターンは完成です。これらの手順を完了すると、次は 「Web フォーム」の回答を (わかりやすくするためにそれぞれの手順の間に空白の行を挿入して) UpdateQuestion1 に渡した後に、Question2 の状態に到達したかを確認する単体テストになります。

[TestMethod]
public void UpdateQuestion1NavigatesToQuestion2IfAnswerIsWebForms()
{
  StateController.Navigate("Survey");
  SurveyController controller = new SurveyController();
  Question question = new Question() { Answer = "Web Forms" };
  controller.UpdateQuestion1(question);
  Assert.AreEqual("Question2", StateContext.State.Key);
}

さまざまな Navigation フレームワークの概念を単体テストできるようになるために必要なのはこれですべてですが、UpdateQuestion2 では準備 (Arrange) と実行 (Act) の手順にいくつか違いがあるため、続きを見てみましょう。準備 (Arrange) の手順で必要なナビゲーション条件が異なるのは、UpdateQuestion2 を呼び出すには、現在状態が Question2 で、現在の状態データに「Web フォーム」技術の回答が格納されている必要があるためです。Web アプリケーションでは、このナビゲーションとデータの受け渡しは UI で管理します。これは、ユーザーは最初の質問に「Web フォーム」と回答しないと 2 つ目の質問に進めないためです。ただし、単体テストの環境では、これを手動で行う必要があります。これには Question1 の状態に到達するのに UpdateQuestion1 が必要としたのと同じダイアログ ナビゲーションを用意し、Next 遷移キーと「Web フォーム」という回答を NavigationData に渡すナビゲーションを行います。

StateController.Navigate("Survey");
StateController.Navigate(
  "Next", new NavigationData() { { "technology", "Web Forms" } });

UpdateQuestion2 のアサート (Assert) の手順の唯一の違いは、回答がナビゲーションの前に状態データに格納されるのを検証するときに生じます。UpdateQuestion1 でこのチェックを実行したときには、アクセスされたことのある状態は Question1 だけなので、一覧の最初の階層リンクを使用しました。ところが、UpdateQuestion2 では Question1 と Question2 の 2 つに到達しているため、一覧に階層リンクが 2 つあります。一覧の階層リンクはアクセスした順に出現します。そのため、Question2 は 2 つ目のエントリになり、次のようなチェックが必要になります。

Assert.AreEqual("Yes", (string)StateController.Crumbs[1].Data["answer"]);

これで、単体テストが可能な Web フォーム コードを作成するという大きな目標が達成されました。これは、標準のデータ バインドを Navigation フレームワークを利用して使うことで可能になります。コントローラーが他のフレームワーク クラスやインターフェイスを継承または実装する必要がなく、メソッドは他の特定のフレームワークの型を返す必要がないため、他の ASP.NET の単体テストの手法ほど制約がありません。

MVC との比較

このアンケート アプリケーションは典型的な MVC アプリケーションと遜色なく構造化されており、単体テストに関しては MVC アプリケーションより優れています。アンケート アプリケーションのナビゲーション コードはコントローラー メソッドの内部にあり、残りのビジネス ロジックと共にテストされます。MVC アプリケーションでは、ナビゲーション コードはテストされません。これは、ナビゲーション コードが、RedirectResult などのコントローラー メソッドの戻り値の型に格納されるためです。Navigation フレームワークに関する次回の記事では、検索エンジンが最適化しやすくなるように、単一ページのアプリケーションを、MVC の同様のアプリケーションでは達成が困難な、「同じことを繰り返さない (DRY: don’t repeat yourself)」という方針を遵守して構築し、さらに MVC と差別化します。

Web フォームのデータ バインドには、MVC では生じない問題があると言われます。たとえば、コントローラー クラスで依存関係の挿入を使用するのが難しく、ビューモデル クラスで入れ子になった型がサポートされないことが指摘されています。しかし、Web フォームは MVC から多くを学んできており、次期バージョンの Visual Studio では Web フォームのデータ バインドのエクスペリエンスが大幅に改良されるでしょう。

Navigation フレームワークのデータ バインドの統合については、ここで紹介した以外にもまだまだ説明すべきことあります。たとえば、ASP.NET の DataPager とは異なり、コントロールに関連付ける必要がないか、カウント メソッドを別途用意する必要のないデータ ページャー コントロールがあります。詳細に関心がある方は、navigation.codeplex.com (英語) から包括的なドキュメントとサンプル コードが利用できます。

Graham Mendick は Web フォームの熱心な愛好者で、Web フォームが ASP.NET MVC と同様に優れたアーキテクチャであることを示してきています。彼は Navigation for ASP.NET Web Forms フレームワークを作成しました。これをデータ バインドと連携して使用することにより、Web フォームに新たな息吹をもたらすと信じています。

この記事のレビューに協力してくれた技術スタッフの Scott Hanselman に心より感謝いたします。