単体テストには DI ではなくリフレクションを使いましょう

テスト手法テスト自動化品質保証QA

依存性の注入【Dependency Injection】は、SOLID 原則にも含まれる大変重要な設計指針です。
適切な箇所で適切に使用される分には何も問題はありませんが、便利だからといって単体テストのためだけに DI を利用するというのはいかがなものでしょうか。

本来、もっとシンプルに実装できるものを、品質保証のために複雑化させ、コード量を増やし、可読性を落とす。
本末転倒のような気がしてなりません。

「DI コンテナがあるから大丈夫!」

一見すると DI コンテナを使用することでソースコードは幾分すっきりはしますが、過剰な DI による弊害を棚上げしているだけにすぎないように思うのは私だけでしょうか。

この辺り賛否あるかとは思いますので(否の方が多い?)前置きはこれくらいにして、内部インスタンスをリフレクションで置き換える方法について解説したいと思います。

なお、DI なら可能なテストがリフレクションですべて代替できるわけではありません。
単体テスト用の DI をなるべく減らしましょうという提案ですのであらかじめご了承ください。


単体テストとは
もう1つだけ前置きを。
「単体テスト」という言葉の定義にもよりますが、単体テストが「結合テストやシステムテストの前までに行われるべき品質保証のための検証工程」であるとするならば、モジュール単体でテストすることにこだわる必要はありません
昔の開発に比べ、今日ではプログラムの多くの部分が外部ライブラリやフレームワーク、データベースなどすでに存在する既製品で成り立っています。
開発の時点で結合可能なものはあらかじめ結合した状態でテストを行う方が、より検証の精度は上がるでしょう。

単体でテストをするから単体テストなのではありません。
テストの観点が単体に寄っているから単体テストなのです。

要は単体テストの観点で検証ができればそれで良いのです。
テストパターンを作り出すことが困難な部分のみモックやスタブを利用して小回りの利くテスト環境を構築する方法が良いでしょう。
テストダブルの保守は思いのほか苦労します。

参考: 単体テスト(ユニットテスト)とは
参考: 単体テストのテスト項目の観点


C# におけるフィールドの置き換え
以下はテストの対象となるMyContentsクラスのコードです。
ここではコンテンツデータを取得するメソッドGetContents()をテストしてみます。
なお、テストには xUnit、モックの作成には Moq を使用します。

using System;

namespace MyProduct;
public class MyContents{

	private MyFileReader freader_;
	private bool enable_ = true;

	public MyContents(){
		this.freader_ = new MyFileReader("/path/to");
	}

	public string? GetContents(string title){
		string? contents = null;
		if(this.enable_){
			contents = this.freader_.ReadFile(title + ".cache");
			if(String.IsNullOrWhiteSpace(contents)) contents = null;
		}
		return contents;
	}
}

MyContentsクラスは内部にコンテンツファイルの読み込み(freader_)、機能の有無(enable_)を持ち、これらを参照しながら処理を行います。
GetContents()はコンテンツタイトルを入力とし、タイトルに紐づけられたコンテンツデータを返します。

以下はファイルの読み込みクラスMyFileReaderのコード例です。

using System;
using System.IO;

namespace MyProduct;
public class MyFileReader{

	private string path_;

	public MyFileReader(string path){
		this.path_ = path;
	}

	public virtual string ReadFile(string fileName){
		string data = "";
		string file = Path.Combine(this.path_, fileName);
		try{
			data = File.ReadAllText(file);

		}catch(Exception e){
			Console.WriteLine($"Error occured: {e.Message}");
		}
		return data;
	}
}

MyContentsMyFileReaderインスタンスをモックに置き換え、ReadFile()の復帰値を固定化するには以下のように記述します。

var myContents = new MyContents();

// モックの作成
var mock = new Mock<MyFileReader>("/path/to");

// フィールドの置き換え
var field = typeof(MyContents).GetField("freader_", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
if(field == null){
	throw new ArgumentException("Field 'freader_' not found in type MyContents.");
}
field.SetValue(myContents, mock.Object);

// ReadFile()が"mocked data"を返すように
mock.Setup(x=>x.ReadFile(It.IsAny<string>())).Returns("mocked data");

これでmyContentsインスタンスの内部メンバfreader_が置き換わり、freader_ReadFile()は、必ず “mocked data” を返すようになります。
なお、置き換えが可能なのは、インターフェース継承可能なメソッドvirtual)のみとなりますので注意してください。

フィールドの置き換え処理は毎回だと冗長になりそうなので、汎用的に利用できるヘルパークラスを作成してみます。

using System;
using System.Reflection;
using Moq;

namespace Test;
public static class MockHelper{

	// フィールドの置き換え
	public static void Set<TTarget, TValue>(TTarget target, string fieldName, TValue value) where TTarget:class{
		// 対象のクラスのフィールド情報を取得
		FieldInfo? finfo = typeof(TTarget).GetField(fieldName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
		if(finfo == null){
			throw new ArgumentException($"Field '{fieldName}' not found in type '{typeof(TTarget)}'.");
		}

		// valueがMock<T>型の場合、Objectプロパティを取得して使用
		object? valueToSet = value;
		if(value is Mock mockValue) valueToSet = mockValue.Object;
		finfo.SetValue(target, valueToSet);
	}
}

このヘルパークラスを利用してテストコードを書いてみます。
以下はテストコードのコード例です。

using System;
using Xunit;
using Moq;

namespace Test;
public class UnitTest{

	[Fact]
	public void Test1(){
		var myContents = new MyContents();

		// freader_のモック作成とフィールドの置き換え
		var mock = new Mock<MyFileReader>("/path/to");
		MockHelper.Set(myContents, "freader_", mock);

		// テスト1:ReadFile()が"mocked data"を返すように
		mock.Setup(x=>x.ReadFile(It.IsAny<string>())).Returns("mocked data");
		var result = myContents.GetContents("title");
		Assert.Equal("mocked data", result);

		// テスト2:ReadFile()が例外を投げるように
		mock.Setup(x=>x.ReadFile(It.IsAny<string>())).Throws(new FileNotFoundException("exception!"));
		Assert.Throws<FileNotFoundException>(() => myContents.GetContents("title"));

		// テスト3:enable_をfalseに
		MockHelper.Set(myContents, "enable_", false);
		result = myContents.GetContents("title");
		Assert.Null(result);
	}
}


Java におけるフィールドの置き換え
以下はテストの対象となるMyContentsクラスのコードです。
同様にgetContents()をテストします。
なお、テストには JUnit5、モックの作成には Mockito を使用します。

package myproduct;

public class MyContents{

	private MyFileReader freader_ = null;
	private boolean enable_ = true;

	public MyContents(){
		this.freader_ = new MyFileReader("/path/to");
	}

	public String getContents(String title){
		String contents = null;
		if(this.enable_){
			contents = this.freader_.readFile(title + ".cache");
			if (contents == null || contents.trim().isEmpty()) contents = null;
		}
		return contents;
	}
}

以下はファイルの読み込みクラスMyFileReaderのコード例です。

package myproduct;

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.io.IOException;

public class MyFileReader{

	private String path_;

	public MyFileReader(String path){
		this.path_ = path;
	}

	public String readFile(String fileName){
		String data = "";
		java.nio.file.Path file = Paths.get(this.path_, fileName);
		try{
			data = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);

		}catch(IOException e){
			System.out.println("Error occurred: " + e.getMessage());
		}
		return data;
	}
}

Mockito にはリフレクションによるモックの置き換え機能が備わっています。
@InjectMocksアノテーションを利用してMyContentsMyFileReaderインスタンスをモックに置き換え、readFile()の復帰値を固定化するには以下のように記述します。

@Mock
private MyFileReader mock;

@InjectMocks
private MyContents myContents;

・・・

when(mock.readFile("title.cache")).thenReturn("mocked data");

Mockito だとフィールドの値を直接変更するのが難しそうだったので、その用途のためのヘルパークラスを作成します。

package test;

import java.lang.reflect.Field;

public class MockHelper{

	public static <TTarget> void set(TTarget target, String fieldName, Object value) throws NoSuchFieldException, IllegalAccessException{
		// 対象のクラスのフィールド情報を取得
		Field field = target.getClass().getDeclaredField(fieldName);
		field.setAccessible(true); // プライベートフィールドへのアクセスを可能にする
		field.set(target, value);
	}
}

このヘルパークラスを利用してテストコードを書いてみます。
以下は JUnit5 によるテストコードのコード例です。
JUnit4 と書き方が異なるので注意が必要です。

package test;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

import myproduct.*;

public class UnitTest{

	@Mock
	private MyFileReader mock;

	@InjectMocks
	private MyContents myContents;

	@BeforeEach
	void setUp(){
		MockitoAnnotations.openMocks(this);
	}

	@Test
	void test1(){
		try{
			// テスト1:readFile()が"mocked data"を返すように
			when(mock.readFile("title.cache")).thenReturn("mocked data");
			String result = myContents.getContents("title");
			assertEquals("mocked data", result);
			verify(mock).readFile("title.cache"); // mockが呼ばれたか

			// テスト2:readFile()が例外を投げるように
			Mockito.doThrow(new IllegalArgumentException("This is a mock exception.")).when(mock).readFile("title.cache");
			assertThrows(IllegalArgumentException.class, () -> {
				var result2 = myContents.getContents("title");
				assertEquals("mocked data2", result2);
			});

			// テスト3:enable_をfalseに
			MockHelper.set(myContents, "enable_", false);
			result = myContents.getContents("title");
			assertNull(result);

		}catch(Exception e){
			System.err.println("Error occured: " + e.getMessage());
		}
	}
}


PHP におけるプロパティの置き換え
以下はテストの対象となるMyContentsクラスのコードです。
同様にgetContents()をテストします。
なお、テストには PHPUnit、モックの作成には Phake を使用します。

namespace MyProduct;

class MyContents{

	private MyFileReader $freader_;
	private bool $enable_ = true;

	public function __construct(){
		$this->freader_ = new MyFileReader('/path/to');
	}

	public function getContents(string $title):?string{
		$contents = null;
		if($this->enable_){
			$contents = $this->freader_->readFile($title.'.cache');
			if(!$contents) $contents = null;
		}
		return $contents;
	}
}

以下はファイルの読み込みクラスMyFileReaderのコード例です。

namespace MyProduct;

class MyFileReader{

	private string $path_;

	public function __construct(string $path){
		$this->path_ = $path;
	}

	public function readFile(string $fileName):string{
		$data = '';
		$file = realpath($this->path_.DIRECTORY_SEPARATOR.$fileName);
		$data = file_get_contents($file);
		if($data === false){
			$data = '';
			print('Error occured: '.e.Message);
		}
		return $data;
	}
}

MyContentsMyFileReaderインスタンスをモックに置き換え、readFile()の復帰値を固定化するには以下のように記述します。

$myContents = new MyContents();

// freader_のモック作成
$mock = Phake::mock('MyProduct\MyFileReader');

// プロパティの置き換え
$reflection = new ReflectionClass($myContents);
if(!$reflection->hasProperty('freader_')){
	throw new \Exception("Property 'freader_' not found in MyContents.");
}
$freader = $reflection->getProperty('freader_');
$freader->setAccessible(true);
$freader->setValue($myContents, $mock);

// readFile()が"mocked data"を返すように
Phake::when($mock)->readFile->thenReturn('mocked data');

これを参考に汎用的に利用できるヘルパークラスを作成します。

namespace Test;

use ReflectionClass;

class MockHelper{

	public static function set(object $target, string $propName, $value){
		$reflection = new ReflectionClass(get_class($target));
		if(!$reflection->hasProperty($propName)){
			throw new \Exception("Property '{$propName}' not found in ".get_class($target).'.');
		}

		$prop = $reflection->getProperty($propName);
		$prop->setAccessible(true);
		$prop->setValue($target, $value);
	}
}

このヘルパークラスを利用してテストコードを書いてみます。
以下はテストコードのコード例です。

namespace Test;

use Phake;
use ReflectionClass;
use PHPUnit\Framework\TestCase;
use MyProduct\MyContents;

class UnitTest extends TestCase{

	public function test1(){
		$myContents = new MyContents();

		// freader_のモック作成とプロパティの置き換え
		$mock = Phake::mock('MyProduct\MyFileReader');
		MockHelper::set($myContents, 'freader_', $mock);

		// テスト1:readFile()が"mocked data"を返すように
		Phake::when($mock)->readFile('title.cache')->thenReturn('mocked data');
		$result = $myContents->getContents('title');
		$this->assertEquals('mocked data', $result);

		// テスト2:readFile()が例外を投げるように
		Phake::when($mock)->readFile('title.cache')->thenThrow(new \RuntimeException('Exception!'));
		$this->expectException(\RuntimeException::class);
		$this->expectExceptionMessage('Exception!');
		$result = $myContents->getContents('title');

		// テスト3:enable_をfalseに
		MockHelper::set($myContents, 'enable_', false);
		$result = $myContents->getContents('title');
		$this->assertNull($result);
	}
}

コメント