拡張メソッドをモック化できない!
C# の単体テストでよく使われるモックライブラリといえば Moq や NMock でしょうか(私は Moq を使っています)。
これらのモックライブラリは static メンバをモック化することができないので、拡張メソッドを呼び出すようなテスト対象があると困ります。拡張メソッドの対象は自分で手を入れられないものも多いので(なので拡張メソッドで拡張しているわけで)。。。
そんなときの解決方法を幾つか紹介します。
課題
例えば以下のような状況です。
ILibraryClassインターフェースとその実装クラスLibraryClassが、参照している外部ライブラリで公開されている、という想定です。
// 拡張メソッド
public static class LibraryClassExtensions
{
// このメソッドをモック化できない
public static IEnumerable<object> GetElements(this ILibraryClass obj)
{
// ...
}
}
// テスト対象クラスの利用例
public class SampleClass
{
public void GetElementCount(ILibraryClass obj)
{
// 拡張メソッドを使っている
var specifiedElements = obj.GetElements(...);
return specifiedElements.Count;
}
}
// テストコード例
[TestClass]
public class SampleClassTests
{
[TestMethod]
public void FuncTest()
{
var obj = new LibraryClass();
var sample = new SampleClass();
// ★拡張メソッドGetElements()をモック化できないので、GetElementCount()をテストできない
var count = sample.GetElementCount(obj);
Assert.AreEqual(3, count);
}
}
ライブラリを使う方法
Fakesを使う
MicrosoftのFakes(古くはMoles)を使用すれば、Shimを使ってstaticクラスをモック化することができます。
ただし Visual Studio の Enterprise エディションが必要です。
ライセンスがあるなら一番よい方法と思いますが、Professionalエディションでは使用できないというのはかなり高めのハードルです。
Professionalエディションでも使えるようにとコミュニティでも要望が登録されていますが、少なくともVS2019の間は対応しないそうです。
// テストコード例
[TestClass]
public class SampleClassTests
{
[TestMethod]
public void FuncTest()
{
var obj = new LibraryClass();
var sample = new SampleClass();
// FakesのShimを使えばstaticメソッドもモック化できる
using (ShimsContext.Create())
{
var expectModels = new Model[]{ new Model(), new Model(), new Model() };
ShimLibraryClassExtensions.GetElementsILibraryClass = obj => expectModels;
var count = sample.GetElementCount(obj);
Assert.AreEqual(3, count);
}
}
}
Poseを使う
Pose は .NET Standard をターゲットとした OSS(MITライセンス)ライブラリです。
staticや非virtualでもデリデートに置き換えることができます。すごい!
テスト対象コードには一切手を加えない(実行時のバイナリ書き換えもしない)ためテストの信頼性も問題ありません。
後述のような独自に工夫するよりよほどコスト対効果高いと思います。 ダウンロード数は結構ありますが、ライブラリの開発は止まっているようです。使用される場合はご注意ください。
using Pose;
// テストコード例
[TestClass]
public class SampleClassTests
{
[TestMethod]
public void FuncTest()
{
var obj = new LibraryClass();
var sample = new SampleClass();
// Poseでモック化
var expectModels = new Model[]{ new Model(), new Model(), new Model() };
Shim s = Shim.Replace(() => ILibraryClassExtensions.GetElements(Is.A<LibraryClass>()))
.With(obj => expectModels);
// コンテキスト内で実行するとモックが使える
PoseContext.Isolate(() =>
{
var count = sample.GetElementCount(obj);
Assert.AreEqual(3, count);
}, s);
}
}
ライブラリを使わない方法
OSSの利用が制限されている場合は独自に工夫する必要があります。
これまでに私が使ったこと(作ったこと)がある解決手法を3つ紹介します。
手法 | 概要 | 拡張メソッド | テスト対象 | テストコード |
---|---|---|---|---|
手法1 テスト対象クラスのモック化 | テスト対象クラスから直接拡張メソッドを呼び出さず、拡張メソッドを呼び出す仮想メソッドを用意して、これをモック化する。 | ⭕ | 🔺 | 🔺 |
手法2 モック化可能な拡張メソッド呼び出しクラス定義 | 拡張メソッドを呼び出すモック化可能なクラスを定義し、これをモック化する。 | ⭕ | ❌ | ⭕ |
手法3 モック用インターフェース定義 | 単体テスト用に拡張メソッドの対象を派生したインターフェースまたは抽象クラスを定義し、拡張メソッド内で判断、分岐する。 | 🔺 | ⭕ | ⭕ |
どの手法もどこかに追加、変更が必要になり、違和感を感じるものもあります。
どれが優れているというわけではなく、各自のスタイルやポリシーに基づいて判断することになるでしょう。
個人的にはテストの都合で テスト対象を変更しなくてもよい手法3 が好きです(拡張メソッドは変更必要ですが1行だけですし)。
手法1 対象クラスのモック化
テスト対象クラスから直接拡張メソッドを呼び出さず、拡張メソッドを呼び出す仮想メソッドを用意して、これをモック化します。
評価項目 | 評価 | 理由 |
---|---|---|
拡張メソッド | ⭕ | 変更不要。 |
テスト対象 | 🔺 | 拡張メソッドを呼び出す仮想メソッドが必要だが、テスト専用というわけではない。 |
テストコード | 🔺 | テスト対象クラスをモック化する点が違和感。 |
3つの手法の中で最も変更規模が小さく、コードも直感的に把握しやすいと思います。
// テスト対象クラスの利用例
public class SampleClass
{
public void GetElementCount(ILibraryClass obj)
{
var specifiedElements = CallGetElements();
return specifiedElements.Count;
}
// モック化したいメソッドを呼び出す仮想メソッドを定義
protected virtual IEnumerable<object> CallGetElements()
{
// 拡張メソッドを呼び出し
return this.GetElements();
}
}
// テストコード例
using Moq;
[TestClass]
public class SampleClassTests
{
[TestMethod]
public void FuncTest()
{
var sampleMock = new Mock<SampleClass>();
// ★CallGetElements()をモック化
var expectModels = new Model[]{ new Model(), new Model(), new Model() };
sampleMock.Setup(m => m.CallGetElements).Returns(expectModels);
var obj = new LibraryClass();
var count = sampleMock.Object.GetElementCount(obj);
Assert.AreEqual(3, count);
}
}
手法2 モック化可能な拡張メソッド呼び出しクラス定義
拡張メソッドを呼び出すモック化可能なクラスを定義して、これをモック化します。
テスト対象クラスそのものはあまり汚しませんが、専用クラスの導入が必要になります。
評価項目 | 評価 | 理由 |
---|---|---|
拡張メソッド | ⭕ | 変更不要。 |
テスト対象 | ❌ | 拡張メソッドを呼び出すためのクラスを渡す必要がある。テスト専用である。 |
テストコード | ⭕ | 違和感なし。 |
手法1 と違うのは、このクラスをDI登録してテスト対象クラスにコンストラクタ注入することで、拡張メソッド群(が拡張する外部ライブラリ)とテスト対象クラスの疎結合化を進める第一歩と考えることもできます。
// モック化するための呼び出しクラス
public class MockableInvoker
{
// モック化したいメソッドを呼び出す仮想メソッドを定義
public virtual IEnumerable<object> GetElements(ILibraryClass obj)
{
// 拡張メソッドを呼び出し
return obj.GetElements();
}
}
// テスト対象クラスの利用例
public class SampleClass
{
private MockableInvoker Invoker;
public SampleClass(MockableInvoker invoker = null)
{
Invoker = invoker ?? new MockableInvokder();
}
public void GetElementCount(ILibraryClass obj)
{
var specifiedElements = Invoker.GetElements(obj);
return specifiedElements.Count;
}
}
// テストコード例
using Moq;
[TestClass]
public class SampleClassTests
{
[TestMethod]
public void FuncTest()
{
// モック化
var invokerMock = new Mock<MockableInvoker>();
var expectModels = new Model[]{ new Model(), new Model(), new Model() };
invokerMock.Setup(m => m.GetElements).Returns(expectModels);
var obj = new LibraryClass();
var sample = new SampleClass(invokerMock);
var count = sample.GetElementCount(obj);
Assert.AreEqual(3, count);
}
}
手法3 モック用インターフェース定義
単体テスト用に拡張メソッドの対象を派生したインターフェースまたは抽象クラスを定義して、拡張メソッド内で判断、分岐します。
- 拡張メソッド内でこのインターフェースにキャストできた場合
単体テスト実行中であり、インターフェースを使ってモックに期待値がセットアップされているはずなのでそれを返す。 - 拡張メソッド内でこのインターフェースにキャストできない場合
本体コードが実行中であり、拡張メソッド内に実装した処理を実行する。
評価項目 | 評価 | 理由 |
---|---|---|
拡張メソッド | 🔺 | モックを意識した実装が必要。 |
テスト対象 | ⭕ | 変更不要。 |
テストコード | ⭕ | 違和感なし。 |
テスト対象クラスを変更する必要が無く、拡張メソッドも1行追加するだけです。
// モック化するためのインターフェース定義
// 拡張メソッドの対象が実クラスの場合は抽象クラス定義
public interface ILibraryClassMock : ILibraryClass
{
// モック化したいメソッドを定義
IEnumerable<object> GetElements();
}
// 拡張メソッド
public static class LibraryClassExtensions
{
public static IEnumerable<object> GetElements(this ILibraryClass obj)
{
// モックの場合はセットアップされた期待値を返す
if (obj is ILibraryClassMock mock) return mock.GetElements();
// 本来の拡張メソッドの処理
...
}
}
// テスト対象クラスの利用例
public class SampleClass
{
public void GetElementCount(ILibraryClass obj)
{
var specifiedElements = obj.GetElements();
return specifiedElements.Count;
}
}
// テストコード例
using Moq;
[TestClass]
public class SampleClassTests
{
[TestMethod]
public void FuncTest()
{
var classMock = new Mock<ILibraryClassMock>();
var expectModels = new Model[]{ new Model(), new Model(), new Model() };
classMock.Setup(m => m.GetElements).Returns(expectModels);
var obj = new LibraryClass();
var sample = new SampleClass();
var count = sample.GetElementCount(obj);
Assert.AreEqual(3, count);
}
}