📚

.NETの単体テストでMoqを使ってみる

2020/09/22に公開

Moqとは?

.NET環境の単体テストで使用する、外部モジュールのMock化(Stub化)パッケージです。
例えば、テスト対象のクラスがHTTPやシリアルポートで外部と通信していると、そのままでは単体テストを組むのは容易ではありません。(テスト用のサーバーを用意するなど)
そういった、HTTP通信やシリアルポート通信をする部分をダミーのテスト用モジュールに置き換えるのがMoqです。

環境

Windows 10 Pro 2014
Visual Studio 2019 Version 16.6.3
言語:C# (.NET Framework 4.7.2)
単体テスト プロジェクト (.NET Framework)
Moq 4.14.5 (NuGetからインストール)

テスト対象クラス

テスト対象クラスを、内部でシリアルポートで通信するクラスとします。
名前は、Communication としておきます。

よく有る問題

ありがちな例として、

class Communication
{
	SerialPort port = new SerialPort();
	...
}

テスト対象クラスが直接内部でSerialPortの実態を生成している場合です。
このままではMoq対応出来ません。

単体テスト対応

SerialPortの実態については外部からinterfaceで受け渡すようにしておき、単体テスト時は代わりにダミーの実体を渡します。

  • 外部から渡すinterface定義
public interface ISerialPortEx
{
	void Open();
	string PortName { get; set; }
	bool IsOpen { get; }
	void WriteLine(string text);
	string ReadLine();
	string ReadTo(string text);
	...
}
  • SerialPortからの置き換えクラス
class SerialPortEx : ISerialPortEx  // ISerialPortExを継承
{
	SerialPort port = new SerialPort();
	public void Open() 
	{
		this.port.Open();
	}
	...
}

このクラスもテストが必要ですが、本記事では扱いません。

  • テスト対象クラス
    テスト対象クラスは以下のようになり、間接的にISerialPortExを通じてSerialPortExを使用する仕組みになります。
public class Communication
{
	ISerialPortEx port;

	public Communication(ISerialPortEx port)
	{
		this.port = port;
	}

	public string PortName
	{
		get
		{
			return this.port.PortName;
		}
		set
		{
			this.port.PortName = value;
		}
	}

	public void Connect()
	{
		this.port.Open();
	}
	
	public bool IsConnected
	{
		get
		{
			return this.port.IsOpen;
		}
	}

	public void SendMessage(string text)
	{
		this.port.WriteLine("Msg:"+text);
	}
	public string RecvMessage()
	{
		return this.port.ReadLine();
	}
	public string ReadTo(string text)
	{
		return this.port.ReadTo(text);
	}
}

実装例:

SerialPortEx port = new SerialPortEx();
Communication com = new Communication(port);
com.PortName = "COM1";
com.Connect();
if(com.IsConnected)
{
	com.SendMessage("test message");
	string response = com.RecvMessage();
}
...

基本的な単体テスト実装

引数無し返り値voidのメソッド

Communicationクラスのvoid Connect()メソッドを呼び出すテストです。
Connect()からは、ISerialPortExの Open() が呼ばれています。

引数無しで返り値無しの為、Assert.AreEqual()などの通常の単体テスト構文では
検査出来ないケースです。

[TestMethod]
public void TestConnect()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mock生成
	ISerialPortEx port = mock.Object;  // ISerialPortExを継承したダミークラスを取得

	// テスト対象コード
	Communication com = new Communication(port);
	com.Connect();

	// Mockの検査
	mock.Verify(o => o.Open(), Times.Once);
}

最後、Mockの呼び出し結果として ISerialPortExのOpen() メソッドが1回呼ばれていることを
チェックすることでメソッドが正しく動作していることを検査しています。

Setプロパティのテスト

CommunicationクラスのPortNameプロパティに値をセットするテストです。

[TestMethod]
public void TestPortName()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mock生成
	ISerialPortEx port = mock.Object;  // ISerialPortExを継承したダミークラスを取得

	// テスト対象コード
	Communication com = new Communication(port);
	port.PortName = "COM1";

	// Mockの検査
	mock.VerifySet(o => o.PortName = "COM1", Times.Once);
}

Mockの検査で、ISerialPortExのPortNameプロパティに"COM1"を代入する処理が1回呼ばれていることをチェックしています。

Getプロパティのテスト

CommunicationクラスのIsConnectedプロパティから値を読み出すテストです。
IsConnectedからは、ISerialPortExの IsOpen が呼ばれています。

[TestMethod]
public void TestIsConnected()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mock生成
	mock.SetupGet(o => o.IsOpen).Returns(true);  // IsOpenの値としてtrueを返す設定

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);
	com.Connect();
	if (com.IsConnected) {
	  // ...
	}

	// テスト対象コード
	Assert.IsTrue(com.IsConnected);

	// Mockの検査
	mock.VerifyGet(o => o.IsOpen, Times.Once);
}

テスト対象コード実行前に、IsOpenが返すべき値を設定しています。
このテストメソッドでは、ターゲットコードが成功するはずの値を与えることで、その通りに動作するかを検査しています。
Mockの検査で、IsOpenが1回呼ばれているかをチェックしています。

引数有り返り値voidのメソッド

Communication クラスの void SendMessage(string text) メソッドを呼び出すテストです。
SendMessage() からは、ISerialPortExの WriteLine() が呼ばれています。

[TestMethod]
public void TestSendMessage()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mockクラス生成
	mock.SetupGet(o => o.IsOpen).Returns(true);  // IsOpenの値としてtrueを返す設定

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);
	com.PortName = "COM1";
	com.Connect();

	if(com.IsConnected)
	{
		// テスト対象コード
		com.SendMessage("test message");
	}

	// Mockの検査
	mock.Verify(o => o.WriteLine("Msg:test message"), Times.Once);
}

Mockの結果として、ISerialPortExのWriteLine() メソッドが "Msg:test message"を引数として1回呼び出されていることをチェックしています。

引数無し返り値有りのメソッド

Communication クラスの string RecvMessage() メソッドを呼び出すテストです。
RecvMessage() からは、ISerialPortExの ReadLine() が呼ばれています。

[TestMethod]
public void TestRecvMessage()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mockクラス生成
	mock.SetupGet(o => o.IsOpen).Returns(true);  // IsOpenの値としてtrueを返す設定
	mock.Setup(o => o.ReadLine()).Returns("response message");  // ReadLine()の返り値

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);
	com.PortName = "COM1";
	com.Connect();

	if (com.IsConnected)
	{
		// テスト対象コード
		Assert.AreEqual("response message", com.RecvMessage());
	}

	// テスト結果の検査
	mock.Verify(o => o.ReadLine(), Times.Once);
}

事前準備として、ReadLine()が返す文字列を設定しています。
Mockの結果としては、ReadLine()が1回呼び出されていることをチェックしています。

応用的な単体テスト実装

例外が発生するケース

例えば、RecvMessage()の応答が無くTimeoutExceptionの例外が発生するケースです。

[TestMethod]
public void TestRecvMessageTimeout()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mockクラス生成
	mock.Setup(o => o.ReadLine()).Throws<TimeoutException>();  // 呼ばれたら例外を投げる

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);

	// テスト対象コード
	Assert.ThrowsException<TimeoutException>(() => com.RecvMessage());

	// Mockの検査
	mock.Verify(o => o.ReadLine(), Times.Once);
}

事前準備として、ISerialPortExのReadLine() が呼ばれたらTimeoutExceptionを投げる設定をしています。
RecvMessage()呼び出しに対して、通常の単体テスト評価として、例外が投げられることをチェックしています。
最後のMockの検査で、ReadLine()が1回呼び出されていることをチェックしています。

呼び出し毎にプロパティの値を変化させる場合

Communication クラスのIsConnectedプロパティを読み出し毎に値が変化する場合です。

[TestMethod]
public void TestIsConnected2()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mockクラス生成

	mock.SetupSequence(o => o.IsOpen)
		.Returns(true)
		.Returns(false);

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);
	com.PortName = "COM1";
	com.Connect();

	// テスト対象コード
	Assert.IsTrue(com.IsConnected);
	Assert.IsFalse(com.IsConnected);

	// Mockの検査
	mock.VerifyGet(o => o.IsOpen, Times.Exactly(2));
}

事前準備として、IsOpen が、1回目true、2回目falseを返す設定をしています。

テストコードもIsConnectedが同様に1回目true、2回目falseをチェックしています。
Mockの検査では、IsOpenが2回呼ばれていることをチェックしています。

呼び出し毎にメソッドの返り値を変化させる場合

こちらもプロパティのケースと同様です。

[TestMethod]
public void TestRecvMessage2()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mockクラス生成
	mock.SetupGet(o => o.IsOpen).Returns(true);  // IsOpenの値としてtrueを返す設定
	mock.SetupSequence(o => o.ReadLine())
		.Returns("response message")
		.Returns("response message2");

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);
	com.PortName = "COM1";
	com.Connect();

	if (com.IsConnected)
	{
		// テスト対象コード
		Assert.AreEqual("response message", com.RecvMessage());
		Assert.AreEqual("response message2", com.RecvMessage());
	}

	// テスト結果の検査
	mock.Verify(o => o.ReadLine(), Times.Exactly(2));
}

メソッドでも同様に、SetupSequence()にてReadLine()の2回分の返り値を設定しています。

呼び出し毎にメソッドの引数が変化する場合

Communication クラスのSendMessage()メソッドが異なる値で2回呼び出される場合です。

[TestMethod]
public void TestSendMessage2()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mockクラス生成
	mock.SetupGet(o => o.IsOpen).Returns(true);  // IsOpenの値としてtrueを返す設定

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);
	com.PortName = "COM1";
	com.Connect();

	if (com.IsConnected)
	{
		// テスト対象コード
		com.SendMessage("test");
		com.SendMessage("test2");
	}

	// Mockの検査
	mock.Verify(o => o.WriteLine("Msg:test"), Times.Once);
	mock.Verify(o => o.WriteLine("Msg:test2"), Times.Once);
}

最後のMockの検査にて、それぞれの引数値で1回ずつ呼ばれることをチェックしています。

なお、最後のMockの検査を以下のように書くと、
2つの文字列のどちらかを引数としてWriteLine()が2回呼び出されるチェックになります。

	mock.Verify(o => o.WriteLine(It.IsIn<string>("Msg:test", "Msg:test2")),
		Times.Exactly(2));

メソッドの2回目の呼び出しで例外となるケース

Communication クラスのConnect()メソッドが、
1回目は問題無し、2回目は例外となるケースです。

[TestMethod]
public void TestConnect2()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mockクラス生成
	mock.SetupSequence(o => o.Open())
		.Pass()  // 1回目何もしない
		.Throws<InvalidOperationException>();  // 2回目例外

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);

	// テスト対象コード
	com.Connect();
	Assert.ThrowsException<InvalidOperationException>(() => com.Connect());

	// Mockの検査
	mock.Verify(o => o.Open(), Times.Exactly(2));
}

事前準備のSetupSequence()で、Open()の1回目は何もしない(引数無し、返り値無し)で、
2回目に例外を投げる設定をしています。

テストコードでもConnect()の2回目呼び出しで例外が発生することをチェックしています。
Mockの検査ではISerialPortExのOpen()が2回呼ばれることをチェックしています。

メソッドの返り値が引数の値で変化する場合

CommunicationクラスのReadBlock()メソッドの返り値が引数の値によって変化する場合です。
ReadBlock()からISerialPortExの string ReadTo(string text) が呼び出されています。

[TestMethod]
public void TestReadTo()
{
	// 事前準備
	var mock = new Mock<ISerialPortEx>();  // Mockクラス生成
	mock.SetupGet(o => o.IsOpen).Returns(true);  // IsOpenの値としてtrueを返す設定
	mock.Setup(o => o.ReadTo(" ")).Returns("response");
	mock.Setup(o => o.ReadTo("\n")).Returns("response message");

	ISerialPortEx port = mock.Object;
	Communication com = new Communication(port);
	com.PortName = "COM1";
	com.Connect();

	if (com.IsConnected)
	{
		// テスト対象コード
		Assert.AreEqual("response", com.ReadBlock(" "));
		Assert.AreEqual("response message", com.ReadBlock("\n"));
	}

	// Mockの検査
	mock.Verify(o => o.ReadTo(" "), Times.Once);
	mock.Verify(o => o.ReadTo("\n"), Times.Once);
}

事前準備として、ReadTo()の引数それぞれでの返り値を設定しています。
テスト対象コードでその動作をチェックしています。
Mockの検査では、それぞれの引数で1回ずつ呼び出されていることをチェックしています。

まとめ

  • テスト対象クラスが呼び出す外部モジュールはinterface化して受け渡す。
  • 事前準備としてMockを作成しメソッドやプロパティが返すべき値を設定します。
  • テスト対象コードは通常のAssert.AreEqual()などのテスト構文を使います。
  • 最後にMockの呼び出し結果として引数等で渡される値とその呼び出し回数を検査します。

Discussion