はじめに
Java のユニットテストMockitoを使用してテストを実装していたのですが、
Spy を使ったテストで when(...).thenReturn(...)
がうまく記述できず、
Cannot invoke "Map.entrySet()" because "source" is null
というエラーが出て詰まったため、備忘メモも兼ねて残したいと思います。
Mockitoとは?
Mockitoは、Javaでユニットテストを書く際に用いられるモックフレームワークです。
モック(Mock)とは、テスト対象のクラスが依存している別のクラスやメソッドの動作を模倣した「偽物のオブジェクト」のこと。実際の処理を実行させず、期待する値や振る舞いを自由に設定できるため、ユニットテストの際に非常に便利です。
Mockitoを使えば:
- 外部システムとの通信を伴うクラス
- データベースやファイルシステムに依存する処理
- 実行環境が整いにくいメソッド
などを簡単に模倣し、テストに集中できます。
公式サイト
検証環境
- Java 21
- Mockito 4.11
- JUnit 5.9.2
- Maven
サンプルコード
public class ReportAggregator {
public Map<String, Integer> aggregateReports(Map<String, Integer> reportA,
Map<String, Integer> reportB) {
Map<String, Integer> result = new HashMap<>();
Map<String, Integer> processedA = process(reportA);
Map<String, Integer> processedB = process(reportB);
merge(result, processedA);
merge(result, processedB);
return result;
}
protected Map<String, Integer> process(Map<String, Integer> input) {
// 実際はもっと複雑な処理を想定
return input;
}
private void merge(Map<String, Integer> target, Map<String, Integer> source) {
for (Map.Entry<String, Integer> e : source.entrySet()) { // ← source が null だとここで NullPointerExceptionが発生する
target.merge(e.getKey(), e.getValue(), Integer::sum);
}
}
}
上記はサンプルコードですが、aggregateReports
メソッドからprocess
メソッドを二回、merge
メソッドを二回呼んでいます。
このコードのテストコードを実装するために、それぞれ呼び出すメソッドの返り値にモックを設定したかったので、以下の失敗例にあるような実装を行ったのですが、Cannot invoke "Map.entrySet()" because "source" is null
というエラーが出てしまい、失敗しました。
失敗例 ― when(...).thenReturn(...)
@Spy
private ReportAggregator aggregator;
@BeforeEach
void setup() {
MockitoAnnotations.openMocks(this);
}
@Test
void aggregateReports_brokenMock() {
Map<String, Integer> mockProcessedA = Map.of("A", 10);
Map<String, Integer> mockProcessedB = Map.of("B", 20);
// Spy に対して when(...).thenReturn(...) を使うと、その場で実メソッドが実行されてしまう
when(aggregator.process(any()))
.thenReturn(mockProcessedA)
.thenReturn(mockProcessedB);
aggregator.aggregateReports(Map.of(), Map.of()); // ← NullPointerException 発生
}
なぜ失敗してしまうのかというと、Spy は実オブジェクトをラップしているだけなので、when()
を評価する瞬間に実メソッドが呼ばれます。
そのため、テストデータが空のため process()
が null を返し、merge()
内で entrySet()
にアクセスして NullPointerException
が発生してしまうようでした。
解決策 ― doReturn(...).when(...)
@Test
void aggregateReports_correctMock() {
Map<String, Integer> mockProcessedA = Map.of("A", 10);
Map<String, Integer> mockProcessedB = Map.of("B", 20);
// doReturn を使った書き方
doReturn(mockProcessedA)
.doReturn(mockProcessedB)
.when(aggregator)
.process(any());
Map<String, Integer> result =
aggregator.aggregateReports(Map.of(), Map.of());
// "A"=10 と "B"=20 がマージされるので合計 30
Assertions.assertEquals(30, result.get("A") + result.get("B"));
}
doReturn(...).when(...)
は、メソッドの実行を伴わずに指定した戻り値を設定するため、実メソッドの副作用を防ぐことができます。一方で、when(...).thenReturn(...)
はモックを設定する時点で実際のメソッドを呼び出してしまうため、予期せぬ副作用やエラーが発生する可能性があります。
そのため、特に Spy を利用している場合には、doReturn(...).when(...)
を使用するようにしましょう。
まとめ
- Spy に
when().thenReturn()
を使うと実メソッドが実行される - Spy では
doReturn().when()
を使用する - 純粋なモック(
@Mock
)の場合はwhen().thenReturn()
で OK
Mockitoの「Spy」と「Mock」の違い
アノテーション | 特徴 | 使いどころ |
---|---|---|
Mock | 完全なモックオブジェクト。すべてのメソッドを模倣し、実処理を行わない | 依存クラス全体を置き換えたいとき |
Spy | 実オブジェクトをラップして一部をモック化。指定しないメソッドは実際の処理を実行 | 一部メソッドだけ差し替え、他は実装を確認したいとき |
以上、Javaのテストコードのモックの書き方で詰まった話でした。
同じ問題でハマった方の参考になれば幸いです。
参考サイト