テストしづらいクラスをユニットテスト可能にする方法
概要
Adapter パターンと Dependency Injection (DI) を適用して、テストしづらいクラスをユニットテストできるようにするまでの流れを、HTTP通信を使うクラスを例に説明する。
- 変更できないクラスとの依存を切り離す方法 (Adapterパターンの適用)
- 依存するクラスを指し替えられるようにする方法 (DIの適用)
- 依存するクラスをモックに差し替えてユニットテストする例
対象読者
下記のような外部モジュールと連携するプログラムを作ったけどテストに苦労している人
- 応答が遅いポンコツAPI(テストに時間がかかる)
- 限られた時間しか使えない高価な装置(テストできる時間が少ない)
- まれにエラーが起こるアプライアンス(再現テストが難しい)
オブジェクト指向をそこそこ理解している人。クラス、インタフェース、オブジェクト、継承、委譲 あたりを知ってればたぶん大丈夫。
注意事項
この記事では「テスト」と「ユニットテスト」を下記のように区別する。
- テスト:手動・自動を問わない動的テストのこと
- ユニットテスト:コード化された自動テスト
サンプルプログラムは C# だが、他のオブジェクト指向型プログラミング言語でも同じように適用可能。
あと Adapter パターンとか DI そのものの説明はあまり書かないので別途調べてね。
テストしづらいクラスの例
架空のサービス HogeService にメッセージを投稿する機能を持つクラス HogeServiceClient がある。このクラスをテストするにはどうすればよいか?どんなテストが必要?どうやってテストする?
public class HogeServiceClient
{
WebClient _webClient = new WebClient();
public string PostMessage(string message)
{
string url = $"http://example.jp/api/post";
try
{
// メッセージを HTTP POST する
string response = _webClient.UploadString(url, message);
// メッセージ投稿に成功(HTTP 200 OK)したら応答コンテンツを返す
return response;
}
catch (Exception e)
{
// メッセージ投稿に失敗したら例外を投げる
throw;
}
}
}
void Main()
{
HogeServiceClient h = new HogeServiceClient();
h.PostMessage("こんにちは");
}
この HogeServiceClient をテストするに少なくとも2つのテストケースが必要。
- 200 OK で HTTP 本文が返ってくるパターン
- Webサーバに接続できないなどその他エラーパターン
しかしこのクラスをテストするには下記のような問題があり、自動化どころか手動テストも困難。
- テストの実行に Web サーバとネットワークが必須
- テストの時間や結果がネットワーク環境やWebサーバの状態などの影響を受けてしまう
- 意図したテスト(HTTP応答を操作したり、接続エラーを発生させたり)がやりづらい
このテストしづらいクラス HogeServiceClient をユニットテストできるようにしよう!
Adapter パターンの適用:依存を切り離す
HogeServiceClient のテストを困難にしているのは WebClient クラスへの依存。(WebClient クラスは変更できない!)
Adapter パターンは、既存のクラスを変更することなくインタフェースを変更したいときに使うデザインパターン。(ラッパーとかプロキシとかの一種)
Adapter パターンを適用して WebClient クラスへの依存を切り離す。Adapterパターンの適用は下記のように実施する
- HogeServiceClient に必要なインタフェースを WebClient から抽出し定義する(このインタフェースを IWebUploader とする)
- IWebUploader を実装するクラス WebUploader を定義する
- HogeServiceClient を、WebClient のかわりに IWebUploader 使うように変更する
なお HogeServiceClient, IWebUploader, WebUploader, WebClient を、Adapterパターンではそれぞれ Client, Target, Adapter, Adaptee と呼ぶ。
Adapterパターン適用後のコード
// Target
// * HogeServiceClient に必要なメソッドを抽出したインタフェース
public interface IWebUploader
{
string PostString(string url, string data);
}
// Adapter
// * IWebUploader の機能を WebClient に移譲するクラス
public class WebUploader : IWebUploader
{
public WebClient _webClient = new WebClient();
public string PostString(string url, string data)
{
return _webClient.UploadString(url, data);
}
}
// Client
// * WebClient の代わりに WebUploader を使うようにした。
public class HogeServiceClient
{
IWebUploader _webUploader = new WebUploader();
public string PostMessage(string message)
{
string url = $"http://example.jp/api/post";
try
{
return _webUploader.PostString(url, message);
}
catch (Exception e)
{
// 接続エラー
throw;
}
}
}
これで HogeServiceClient は WebClient との依存を断ち切ることができた。
Adapter パターン適用によるユニットテスト
HogeServiceClient は IWebUploader を実装するクラスなら何でも使えるので、IWebUploader を実装するモックに置き換えればユニットテストできるようになる。
// IWebUploader を実装する Mock
public class MockWebUploader : IWebUploader
{
public string PostString(string url, string data)
{
return "投稿成功";
}
}
// Client
public class HogeServiceClient
{
// WebUploader のかわりに MockWebUploader を使う
IWebUploader _webUploader = new MockWebUploader();
public string PostMessage(string message)
{
string url = $"http://example.jp/api/post";
try
{
return _webUploader.PostString(url, message);
}
catch (Exception e)
{
// 接続エラー
throw;
}
}
}
とはいえテストのために new WebUploader();
を new MockWebUploader();
に置き換えるのはどうかと思う。それをなんとかするために、次に Dependency Injection を適用する。
Dependency Injection (DI):依存オブジェクトを差し替える
HogeServiceClient の問題は、WebUploader オブジェクトを自ら生成しているため、MockWebUploader への指し替えが難しいことにある。
Dependency Injection (DI:依存オブジェクトの注入)では、自らオブジェクトを生成する代わりに、予め生成されたオブジェクトを受け取って使う。
DI を適用した HogeServiceClient
public class HogeServiceClient
{
IWebUploader _webUploader;
// コンストラクタで IWebUploader を実装したクラスのオブジェクトを受け取って使う。
// * 依存オブジェクト(Dependency)をコンストラクタに注入(Injection)してるから
// * Dependency Injection
public HogeServiceClient(IWebUploader webUploader)
{
_webUploader = webUploader;
}
public string PostMessage(string message)
{
string url = $"http://example.jp/api/post";
try
{
return _webUploader.PostString(url, message);
}
catch (Exception e)
{
// 接続エラー
throw;
}
}
}
本番用コードでは WebUploader オブジェクト、テストコードでは MockWebUploader オブジェクトをコンストラクタに渡せばよい。これで HogeServiceClient は変更することなく、本番・テスト、両方で使えるコードになった。
Adapter と DI を適用したコード
Adapter と DI を適用した場合のコード全体。元のコードからだいぶ膨れた(約2倍!)代わりに、HogeServiceClient のユニットテストができるようになった。
本番用コード
// Target
public interface IWebUploader
{
string PostString(string url, string data);
}
// Adapter
public class WebUploader : IWebUploader
{
public WebClient _webClient = new WebClient();
public string PostString(string url, string data)
{
return _webClient.UploadString(url, data);
}
}
// Client
public class HogeServiceClient3
{
IWebUploader _webUploader;
public HogeServiceClient3(IWebUploader webUploader)
{
_webUploader = webUploader;
}
public string PostMessage(string message)
{
string url = $"http://example.jp/api/post";
try
{
return _webUploader.PostString(url, message);
}
catch (Exception e)
{
// 接続エラー
throw;
}
}
}
void Main()
{
// WebUploader オブジェクトを渡す
HogeServiceClient3 h = new HogeServiceClient3(new WebUploader());
h.PostMessage("こんにちは");
}
ユニットテストコード
// モック
// IWebUploader.PostString() のふるまいを自由に変えられるテスト用クラス
public class MockWebUploader : IWebUploader
{
public Func<string> UploadStringFunc { get; set; }
public string PostString(string url, string data)
{
return UploadStringFunc();
}
}
// ユニットテストクラス
[TestClass]
public class HogeServiceClientTest
{
[TestMethod]
public void PostMessageはメッセージ投降に成功したらHTTP応答本文を返す()
{
// モックのセットアップ
MockWebUploader m = new MockWebUploader();
m.UploadStringFunc = (() => "OK"); // Webサーバが "OK" を返すパターンをシミュレート
// モックオブジェクトを HogeServiceClient のコンストラクタに渡す
HogeServiceClient3 h = new HogeServiceClient3(m);
string ret = h.PostMessage("test");
Assert.AreEqual("OK", ret);
}
[TestMethod]
public void PostMessageはHTTP接続に失敗したらExceptionを投げる()
{
// モックのセットアップ
MockWebUploader m = new MockWebUploader();
m.UploadStringFunc = () => throw new Exception("なんかエラー"); // 接続エラーをシミュレート
// モックオブジェクトを HogeServiceClient のコンストラクタに渡す
HogeServiceClient3 h = new HogeServiceClient3(m);
Assert.ThrowsException<Exception>(() =>
{
h.PostMessage("test");
});
}
}
まとめ
HTTP 通信を使うテストしづらいクラスに Adapter パターンと Dependency Injection を適用し、ユニットテストできるようにした。
AdapterパターンとDependency Injection を適用してユニットテストできるようにするには、
- Client と Adaptee の依存を切り離す (Adapter)
- Client に必要なインタフェースを Adaptee から抽出し Target を定義する。
- Target を実装するクラス Adapter を定義する。Adapter の実装は Adaptee に委譲する。
- Client を Adaptee のかわりに Target 使うように変更する。
- Client の依存オブジェクトを指し替えられるようにする (DI)
- Client がオブジェクトを生成する代わりに、生成されたオブジェクトを受け取って使うように変更する。
- Adapter オブジェクトを Client のコンストラクタに渡す
- ユニットテストを作成する
- Target を実装するモックを作成する
- モックオブジェクトを Client のコンストラクタに渡す
- Client をテストする
Adapter パターンと Dependency Injection を適用すると、下記のようなメリットとデメリットが生まれる。
- ○ 依存が少ないクラスになる
- ○ テストの自動化が捗る
- × コードの量が増える
- × F12(定義へ移動)がめんどくさくなる
プロジェクトの特性に合わせてメリット・デメリットを比較して、やるやらないを検討すればいいと思う。