概要
Webアプリケーションの開発で、HttpContextから情報を取得するメソッドがあったとする。このメソッドのテストはIISが必要となるため、通常であれば結合テストフェーズにならないとテストができない。しかしどうしても単体テストを行いたい場合には、Visual Studio Enterpriseで利用可能なMS Fakesを利用することで、何とか単体でのテストが可能であるということを、この記事で述べていきたいと思う。
Webアプリケーションのテストに関して
概要では、HttpContextから値を取得するメソッドのテストについて記述すると書いたが、本来であればテスト対象となるようなメソッドがHttpContextからじかに値を取得するような設計があまりよくないと思われる。
つまり、ブラウザから入力した値をコードビハインドやハンドラーで取得せずに後続処理に投げていたり、もしくはセッション状態に常にアクセス可能なオブジェクトを保持し続けるといった実装はクラス間の結合が密になってしまうため、改修やテストの際に扱いにくくなってしまうということである。またレガシーナコードなどでは、ビジネスロジックとしてオブジェクト(モデル)に移譲すべき処理をコードビハインドに直接記述しいるケースも考えられる。
正しく対応するのであれば、サーバー処理に必要な引数はコードビハインドで変数として抜き出して業務ロジックに引き渡すようにする。また、セッションにオブジェクトを保持しなくていいような設計にする。コードビハインドに業務ロジックがあった場合はリファクタリングして別のクラスに処理を委譲するなどプロジェクトのクラス構成から設計しなおした場合が良いことが多いだろう。
だが、レガシーなアプリケーションや、複雑な状態を保持するオブジェクトを何らかの理由(工期やプロジェクトのチーム構成の問題など)で保持する理由ケースが存在することは確かなので、そのようなメソッドをテストしたい場合もあるのが実情である。
では、どうすれば単体テストを実施することが可能なのだろうか。
※ここでは本当にHttpContextを必要としている場合を想定している。HttpContextから抜き出した値を保持している別のクラスからその値を取得する場合などは、その別クラスをインターフェース化しテスト用のスタブを作成するだけで十分テストを実施できる場合が多い。
Microsoft Fakes
Microsoft Fakesは2020年10月現在Visual StudioのEnterprise版でしか利用できないが、アプリケーションの一部の機能(プロパティ、メソッド)をテスト実施時に自分の好きなふるまいをするように置き換えることができるツールである。
(参考:https://docs.microsoft.com/ja-jp/visualstudio/test/isolating-code-under-test-with-microsoft-fakes?view=vs-2019 )
このツールを用いて、HttpContextの返す様々なオブジェクトや値をテスト実施の際に偽装してしまえば、今問題になっているメソッドのテストが行える。
サンプルプロジェクト
今、適当なWebアプリを作成してみる。
Visual Studio 2019だと、空のソリューションに対してC#>Windows>Webでプロジェクトの種類を絞り込んで、ASP.NET Webアプリケーション(.NET Framework)を選択し、空のプロジェクトを選択作成したうえで、default.aspxとエラーのリダイレクト先のerropage.htmlを追加して作っている。
それぞれのファイルの中身は以下のような感じ
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="default.aspx.cs" Inherits="WebAppTest._default" %>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title></title>
</head>
<body>
<form id="form1" runat="server">
<div>
<p>表示用の文字を入力してください</p>
<p><input type="text" name="presentation" /></p>
<p>
<button type="submit" id="button1">"送信"</button>
</p>
</div>
</form>
</body>
</html>
using System;
using System.Web;
namespace WebAppTest
{
public partial class _default : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
string sentence = HttpContext.Current.Request["presentation"];
if (!string.IsNullOrEmpty(sentence))
{
if (sentence == "例外表示")
{
// Exceptionを投げたい
throw new ApplicationException("エラー表示をします");
}
else
{
this.Context.Response.Write($"あなたは「{sentence}」と入力しました。");
}
}
}
}
}
<system.web>
<compilation debug="true" targetFramework="4.8"/>
<httpRuntime targetFramework="4.8"/>
<!-- リダイレクト先を指定 -->
<customErrors defaultRedirect="errorpage.html" mode="On" />
</system.web>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
例外が発生しました。
</body>
</html>
フォームに入力した文字を加工して表示するだけの単純なアプリで、「例外発生」という文字列が送られてきた時だけエラーページに飛ばすような作りである。
では、このdefault.aspx.csにあるPage_Loadで、本当に「例外発生」という文字列が送られてきた時にApplicationExceptionをスローしているかをテストするにはどうすればいいだろうか。
テストプロジェクトの作成
ソリューションに空のテストプロジェクトを追加して、以下の手順でテストクラスを作成します。
- 追加するのはC#>Windows>テスト の中の 単体テストプロジェクト(.NET Framework)
- WebAppTestに対してプロジェクト参照を追加
- テストする対象がPageオブジェクトの継承クラスなので、System.Web.dllに対するアセンブリ参照を追加
- テスト対象メソッドがprotectedメソッドなので、外部から呼び出せるように_default.aspx.csの継承クラスdefaultPageInheritor.csを追加
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WebAppTest;
namespace WebAppTest_Test
{
/// <summary>
/// _default.aspx.csをテストするための継承クラス
/// </summary>
public class defaultPageInheritor : _default
{
/// <summary>
/// protectedメソッドは直接実行できないので、このメソッドから呼び出す
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
public void Page_Load(object sender, EventArgs e)
{
base.Page_Load(sender, e);
}
}
}
テストの実装
Page_Loadのタイミングで例外を投げることをテストしたいので、以下のようなテストコードを記述してみるが、このままだとフォームからの入力がないため文字列を渡すことができないうえに、HttpContext.Currentが存在していないためプロパティにアクセス時に例外発生するという問題が発生する。
ここで本題であるMicrosoft Fakesを利用することで、本来存在しないはずのHttpContext.Current及びその先のプロパティを偽装することができるのである。
#Fakesを用いたテストの実装
Fakesで偽装をしたい対象がHttpCotextクラスのCurrentプロパティなので、このクラスを含むSystem.Web.dllをFakesアセンブリに追加する
すると、Fakesというフォルダがテストプロジェクトに追加され、このdllに含まれるクラスのクラスを偽装できることがわかる。
次に、HttpContextを偽装していくわけだが、偽装の定義はShimContextのスコープ内でしか利用できないので、テストの最中だけShimContextを用いるようusing節の内部にテストコードを書くことになる。もちろんusing節を必ずしも用いる必要はないのだが、その場合自分でShimContextを破棄しなければならないので注意が必要である。
偽装の実装法は、ラムダ式で定義するのが一般的なため以下のようなコードになる。
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Web;
using Microsoft.QualityTools.Testing.Fakes;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace WebAppTest_Test
{
[TestClass]
public class defaultTests
{
[TestMethod]
public void Page_Load_Test()
{
using (ShimsContext.Create())
{
#region 偽装の実装
// Request.Formが返すダミーを準備
NameValueCollection dummyForm = new NameValueCollection();
// Request.QueryStringが返すダミーを宣言
NameValueCollection dummyQS = new NameValueCollection();
// Request.Itemsが返すダミーを準備
NameValueCollection dummyItems = new NameValueCollection();
// HttpContext.Requestが返すダミーを準備
var fakeRequest = new System.Web.Fakes.ShimHttpRequest()
{
// Formプロパティの偽装
FormGet = () => dummyForm,
// QueryStringの偽装
QueryStringGet = () => dummyQS,
ItemGetString = (string key) => dummyItems[key]
};
// HttpContext.Sessionが返すダミーを準備
DummySessionState dummySessionItem = new DummySessionState();
var fakeSessionState = new System.Web.SessionState.Fakes.ShimHttpSessionState()
{
ItemGetString = (x) => dummySessionItem[x],
ItemSetStringObject = (x, value) => dummySessionItem[x] = value
};
// HttpContextのItemsが返すダミーを準備
var fakeItems = new Hashtable();
// HttpContext.Curretnの偽装
System.Web.Fakes.ShimHttpContext.CurrentGet = ()
=> new System.Web.Fakes.ShimHttpContext()
{
// HttpContext.Current.Requestの偽装
RequestGet = () => fakeRequest,
// HttpContext.Current.Sessionの偽装
SessionGet = () => fakeSessionState,
// // HttpContext.Current.Itemsの偽装
ItemsGet = () => fakeItems,
};
#endregion
// 例外を発生させる文言をセット
dummyItems["presentation"] = "例外表示";
var page = new defaultPageInheritor();
Assert.ThrowsException<ApplicationException>(
() => page.Page_Load(null, null)
);
}
}
}
}
ここで、DummySessionStateクラスはSessionの偽装用クラスでどのように実装してもいいのだが、一例としては以下のような実装が考えられる。
using System.Collections;
using System.Collections.Generic;
namespace WebAppTest_Test
{
class DummySessionState : IDictionary<string, object>
{
Dictionary<string, object> innerDic = new Dictionary<string, object>();
public object this[string key]
{
get
{
if(innerDic.ContainsKey(key))
{
return innerDic[key];
}
else
{
return null;
}
}
set => ((IDictionary<string, object>)innerDic)[key] = value;
}
public ICollection<string> Keys => ((IDictionary<string, object>)innerDic).Keys;
public ICollection<object> Values => ((IDictionary<string, object>)innerDic).Values;
public int Count => ((ICollection<KeyValuePair<string, object>>)innerDic).Count;
public bool IsReadOnly => ((ICollection<KeyValuePair<string, object>>)innerDic).IsReadOnly;
public void Add(string key, object value)
{
((IDictionary<string, object>)innerDic).Add(key, value);
}
public void Add(KeyValuePair<string, object> item)
{
((ICollection<KeyValuePair<string, object>>)innerDic).Add(item);
}
public void Clear()
{
((ICollection<KeyValuePair<string, object>>)innerDic).Clear();
}
public bool Contains(KeyValuePair<string, object> item)
{
return ((ICollection<KeyValuePair<string, object>>)innerDic).Contains(item);
}
public bool ContainsKey(string key)
{
return ((IDictionary<string, object>)innerDic).ContainsKey(key);
}
public void CopyTo(KeyValuePair<string, object>[] array, int arrayIndex)
{
((ICollection<KeyValuePair<string, object>>)innerDic).CopyTo(array, arrayIndex);
}
public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
{
return ((IEnumerable<KeyValuePair<string, object>>)innerDic).GetEnumerator();
}
public bool Remove(string key)
{
return ((IDictionary<string, object>)innerDic).Remove(key);
}
public bool Remove(KeyValuePair<string, object> item)
{
return ((ICollection<KeyValuePair<string, object>>)innerDic).Remove(item);
}
public bool TryGetValue(string key, out object value)
{
return ((IDictionary<string, object>)innerDic).TryGetValue(key, out value);
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)innerDic).GetEnumerator();
}
}
}
Visual Studioでは、インターフェース(今の場合IDictionary)の継承クラス内に、そのインターフェースを実装したクラスをフィールド(今の場合innerDic)として保持していると、そのフィールドを通じてクラス実装のコードを自動生成してくれる機能があるので、このような実装も簡単に行うことができる。
多少コードが長くなったが、内容はシンプルなので少し読んでみれば簡単にわかる内容となっているはずである。
この程度のコーディングでテストを可能にするFakesはテストの適用範囲を広げてくれる可能性を感じさせてくれるのではないだろうか。
まとめ
今回の例では、本来RequestのItemsのみを偽装すればテストには十分だったが、よくHttpContextからアクセスする可能性のあるFormやQueryStringなどのプロパティに関しても偽装を実装してみた。
Fakesを用いれば、今回のような静的プロパティを偽装してテストを実施できることを示したが、冒頭にも記したようにグローバルオブジェクトのように振舞えるHttpContextへのアクセスは限定的にし、テスト可能なクラス設計を行うことが正攻法なので、何でもかんでも偽装をすればよいとクラス構成をおろそかにしてはならないことに注意しよう。