モック化
ユニットテスト最大の障壁
例えばログオブジェクトや、DB取得オブジェクト等です。
DBから取得した値が『hoge』なら《true》を返却するメソッドのテストに対してDBにテストレコードを入れるのは面倒ですよね。 そこでテストとは無関係な情報をダミーデータに置き換える必要がでてきます。
これが『モック化』です。 冒頭のサンプルソースを使いモック化を説明します。
package service;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class SampleServiceTest {
private SampleService target = new SampleService();
@Test
void test_01() {
assertEquals(target.chkUserInfo("hoge"), "ユーザー『hoge』は存在します。");
}
}
テストケースとして、引数《hoge》を関数《chkUserInfo》に渡した際の返却結果を確認。
※テスト終了後は『JUnit』タブを選択します 結果は、テスト失敗と言うよりか『ユニットテスト処理自体のエラー』となります。
障害トレースを見てみます。
java.lang.NullPointerException: Cannot invoke "dto.SampleDto.setName(String)" because "this.dto" is null
at service.SampleService.chkUserInfo(SampleService.java:24)
at service.SampleServiceTest.test_01(SampleServiceTest.java:10)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
対象クラスの[24行目]で『dto.setName()』って関数あるけど、DTO自体が《null》だぞ!
private修飾子を無視してモック化する
またフィールドプロパティは『private』修飾子を使いがちですよね。
当然テストクラスは、テスト対象クラスとは縁もゆかりもないのでアクセスできません。 そこで、Reflection(リフレクション)機能を使いモック化します。
先ほどのエラーとなった『SampleServiceTest』の内容を以下の様に書き換えます。
package service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import dto.SampleDto;
import java.lang.reflect.Field;
class SampleServiceTest {
private SampleService target;
// モック化
SampleDto mockSampleDto = mock(SampleDto.class);
@BeforeEach
void setUp() throws Exception {
target = new SampleService();
Field field = SampleService.class.getDeclaredField("dto");
field.setAccessible(true);
field.set(target, mockSampleDto);
}
@Test
void test_01() {
assertEquals(target.chkUserInfo("hoge"), "ユーザー『hoge』は存在します。");
}
}
『JUnit』タブを確認すると【赤帯】となりますが、処理エラーの内容が変わっています。 java.lang.NullPointerException: Cannot invoke "logic.SampleLogic.selectUserInfo(dto.SampleDto, String)" because "this.appLogic" is null つまり最初のエラーは解消され、次のエラーに到達したって事ですw
ユニットテストは下手をすると、この『ヌルポ地獄』で沼に嵌るケースが多いので慎重に設定する必要があります。 それでは、最初の『DTO』エラーが何故解消したかを説明します。
モック化のお手軽設定
そこで以下の様にコードを変えてみましょう。
package service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import dto.SampleDto;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
class SampleServiceTest {
private Field findFieldInHierarchy(Class<?> clazz, String fieldName) throws NoSuchFieldException {
Class<?> current = clazz;
while (current != null) {
try {
return current.getDeclaredField(fieldName);
} catch (NoSuchFieldException e) {
current = current.getSuperclass();
}
}
throw new NoSuchFieldException(fieldName + " が,プロパティフィールドから見つかりません。");
}
private SampleService target;
// モック化
SampleDto mockSampleDto = mock(SampleDto.class);
@BeforeEach
void setUp() throws Exception {
target = new SampleService();
Map<String, Object> fields = new HashMap<>();
fields.put("dto", mockSampleDto);
for (Map.Entry<String, Object> entry : fields.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
Field field = findFieldInHierarchy(target.getClass(), key);
field.setAccessible(true);
field.set(target, value);
}
}
@Test
void test_01() {
assertEquals(target.chkUserInfo("hoge"), "ユーザー『hoge』は存在します。");
}
}
でもこれ、案外便利なんですよ。
例えば先ほどの章の『because "this.appLogic" is null』でエラーとなっていた《appLogic》をモック化する場合は、以下を追記するだけです。
ですので実質②の1行を設定するだけでモックインスタンスをテスト対象クラスに設定ができます。 それではこの状態でテストを実行してみましょう。
org.opentest4j.AssertionFailedError: expected: <ユーザーは誰もいません。> but was: <ユーザー『hoge』は存在します。>
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:182)
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:177)
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1145)
at service.SampleServiceTest.test_01(SampleServiceTest.java:47)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
無事ユニットテストの障害が解消され、本来のテスト失敗である《赤帯》となりましたw
モックインスタンスの返却値を制御する
では何が原因で失敗したかを確認しましょう。
public String chkUserInfo(String name) {
logger.info("------------- SampleService.chkUserInfo ---------------");
dto.setName(name);
String ymd = Da<teUtil.getNowToString();
List<User> list = appLogic.selectUserInfo(dto, ymd);
if (list != null && list.size() > 0) {
for (User ent : list) {
if (ent.getName().equals(name)) {
return "ユーザー『" + name + "』は存在します。";
}
}
return "ユーザー『" + name + "』はいません。";
}
return "ユーザーは誰もいません。";
}
原因はインスタンス『list』の値となります。
『list』はモック化した『appLogic』からの取得となり、モックした以上は当然《null》が返却されます。 あとはコードの通り『ユーザーは誰もいません。』が返却される流れです。 ここで重要なのが『このクラスのテスト観点は何だっけ?』を理解する事です。 テスト観点は以下となります。
受取った『list』インスタンスの中身をループ処理した結果、引数と同様の《name》が存在するケースの返却内容を確認する。 つまりモック化した『appLogic.selectUserInfo(dto, ymd)』のテストではありませんので、任意の値を代入してもテストの品質に影響はありません。 それではテストソースを修正して、モックに返却値を設定します。
@Test
void test_01() {
String name = "hoge";
doAnswer(invocation -> {
List<User> list = new ArrayList<>();
User ent = new User();
ent.setName(name);
list.add(ent);
return list;
}).when(mockAppLogic).selectUserInfo(any(), any());
assertEquals(target.chkUserInfo(name), "ユーザー『hoge』は存在します。");
}
詳細は後述いたしますが、モック化した特定のメソッドに対して任意の値を返却する事ができます。 上記の内容でユニットテストを実行すると、今度は《緑帯》となり成功します!
テストインスタンスのモック化(SPY)
package service;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import utility.DateUtil;
import dto.SampleDto;
import entity.User;
import jakarta.enterprise.context.Dependent;
import jakarta.inject.Inject;
@Dependent
public class SampleService {
private static final Logger logger = LoggerFactory.getLogger(SampleService.class);
@Inject
private SampleDto dto;
public String chkUserInfo(String name) {
logger.info("------------- SampleService.chkUserInfo ---------------");
dto.setName(name);
String ymd = DateUtil.getNowToString();
List<User> list = this.selectUserInfo(dto, ymd);
if (list != null && list.size() > 0) {
for (User ent : list) {
if (ent.getName().equals(name)) {
return "ユーザー『" + name + "』は存在します。";
}
}
return "ユーザー『" + name + "』はいません。";
}
return "ユーザーは誰もいません。";
}
List<User> selectUserInfo(SampleDto dto, String ymd) {
return null;
}
}
ただしこのメソッドはまだ途中で、現状は《null》しか返却しません。 別に途中である必要もありませんが、メソッド『selectUserInfo』の内容が複雑な場合は、テスト観点でもない関数内のコードの返却値を複数セットするのは非効率です。 この状態の場合、何が厄介かと言うと『テスト対象クラスをモック化はできない』事となります。
しかしこのままだと、メソッド『selectUserInfo』は《null》を返却するためテストは確実に《赤帯》となります。 この場合の解決策は、テスト対象クラスに【SPY(スパイ)】を忍び込ませるのです。
以下テストクラスのサンプルとなります。
package service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import dto.SampleDto;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
class SampleServiceTest {
private Field findFieldInHierarchy(Class<?> clazz, String fieldName) throws NoSuchFieldException {
Class<?> current = clazz;
while (current != null) {
try {
return current.getDeclaredField(fieldName);
} catch (NoSuchFieldException e) {
current = current.getSuperclass();
}
}
throw new NoSuchFieldException(fieldName + " が,プロパティフィールドから見つかりません。");
}
private SampleService target;
private SampleService spyTarget = new SampleService();
// モック化
SampleDto mockSampleDto = mock(SampleDto.class);
@BeforeEach
void setUp() throws Exception {
target = Mockito.spy(spyTarget);
Map<String, Object> fields = new HashMap<>();
fields.put("dto", mockSampleDto);
for (Map.Entry<String, Object> entry : fields.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
Field field = findFieldInHierarchy(target.getClass(), key);
field.setAccessible(true);
field.set(target, value);
}
}
@Test
void test_01() {
String name = "hoge";
assertEquals(target.chkUserInfo(name), "ユーザー『hoge』は存在します。");
}
}
実はSPYを代入するタイミングはとても重要です。
感覚としてはスパイだけに『誰もいない時に忍び込ませる』です。
理由は単純で『スパイは忍び込んだが、まだ仕事をしていない』からとなります。 それではスパイに仕事をしてもらいます。
@Test
void test_01() {
String name = "hoge";
doAnswer(invocation -> {
List<User> list = new ArrayList<>();
User ent = new User();
ent.setName(name);
list.add(ent);
return list;
}).when(target).selectUserInfo(any(), any());
assertEquals(target.chkUserInfo(name), "ユーザー『hoge』は存在します。");
}
そう《appLogic》をモック化してた時とほぼ同じ設定となります。
違いは『when』の引数の内容が【mockAppLogic】から【target】に変更しています。 この様に、SPYを忍ばせる事でテスト対象クラスのインスタンスである『target』を扱える様になります。
今回『SPY』を仕込んだメソッド『selectUserInfo』があるクラス『SampleService』に注目して下さい。
実は対象メソッドに『public』や『private』の修飾子を付与していません。
この状態を『パッケージプライベート』と呼び、同じパッケージ内からしかアクセスができません。
ここが『ユニットテストと実装のジレンマ』となります。 つまり見方によっては『テストの為に堅牢性を捨ててるのでは?』と思われます。
しかし他クラスからコールする場合は当然『public』を使います。
これって堅牢性を捨ててますか? 当然必要だから『public』にしている為それには当たりません。
でも本来『クラスA』からしかコールしない設計でも『public』にすると『クラスB』からも呼べてしまいますよね? ここで『う~ん、困った』なんて言うレビュアーはいません。
そのためのレビューで、仕様通り対象クラスからのみコールされていればレビュー合格です。 今回の事例も同じで単純に『ユニットテストで必要だから「パッケージプライベート」にしたのか。よし合格!』となるだけです。 たまにプロジェクトで、設計は嬉々として指摘するのに製造のレビューに関しては無言で合格にするレビュアーがいます。
要するに何も見ていません(理解していません)。 仮にパッケージプライベートにした事で障害が発生した場合、そのプロジェクトは『public』『private』に関わらず障害を発生させるでしょう。
『static』クラスのモック化
実は今回のテスト対象クラスである『SampleService』クラス内にも『static』クラスから値を取得しているコードがあります。
String ymd = DateUtil.getNowToString(); このクラスの内容は以下となります。
package utility;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class DateUtil {
public static String getNowToString() {
LocalDate date = LocalDate.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
return date.format(formatter);
}
}
システムでは良くある仕組みですよね。 ただしこの《日付》がテストの重要なキーだった場合はどうでしょう?
ユニットテストを実施する日によって、テスト内容が変わってしまうと品質を担保できません。 従って、この静的クラスをモック化する必要が生まれてきます。
⇒
return "ユーザー『" + name + "』は存在します(" + ymd + ")";
実は『static』クラスのモックインスタンスには特殊なルールが存在します。
それは『テスト単位でモックインスタンスを閉じる事』です。
実行後各テストメソッドのアイコンをクリックする事で『障害トレース』の内容が切り替わりますが、2回目のテスト結果は明らかにユニットテストのエラーとなっています。 これは1度目のモックインスタンスを閉じていない事に起因するエラーとなります。
解決方法は簡単で各テストメソッドの終了時にモックインスタンスを閉じれば良いです。
各テストケースの終了時に呼ばれるアノテーション『@AfterEach』を使いましょう。
void afterEach() throws Exception {
if (mockDateUtil != null) {
mockDateUtil.close();
}
}
カバレッジからテスト結果を確認する
テストをする際の『実行(R)』の近くに『カバレッジ(V)』がありますので、そのメニューからユニットテストを実行します。
[ヘルプ] > [Eclipseマーケットプレース] > 検索窓『EclEmma』で検索
・EclEmma Java Code Coverage
『インストール済み』となっている場合は、ボタンを押下します。
『更新』ボタンとなるので押下します。
『完了』を押下します。 エクリプスから再起動を求められますので、再起動します。
『テストクラス』『テスト対象クラス』共にソース内がカラフルになります。
緑が実際に処理が通過したコードとなります。 『カバレッジ』タブから、テスト対象クラスである『SampleService.java』を開いてみましょう。
カバレッジが【90%】前後になっていいれば、そのユニットテストは【OK】を貰えるレベルと言えるでしょう。




上記コードの『any()』は、いわゆるワイルドカード的なメソッドです。
答えは、全て同じ値が返却されます。今回はメソッド『selectUserInfo』に対応する返却値を設定しましたが、テスト中に複数このメソッドが記述されている場合はどうなるでしょう?
詳細は後述しますが、それでは困るテストケースの場合は、引数の内容を厳密に記述する等対策する必要があります。