はじめに
ひとりJUnitアドベントカレンダー7日目の記事です。
いろいろあって放置しているうちに12月18日になってました。まだ7日目なのに。
使用バージョン
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.6.RELEASE</version>
$ mvn dependency:tree | grep mockito
[INFO] | +- org.mockito:mockito-junit-jupiter:jar:3.1.0:test
[INFO] | +- org.mockito:mockito-core:jar:3.1.0:test
springbootの古さは目を瞑ってください。
テスト対象クラスのロジックだけテストしたい
単体テストなのでテスト対象クラス単体だけテストしたいですね。
SpringbootにはDependency Injectionという仕組みがあります。
DIの説明は他の記事に譲りますが、テスト対象クラスに他のクラスが注入されていると
そのクラスをテストしようとした際、勝手にDIされて他クラスのロジックも通ってしまいます。
(もしくはDIに失敗してぬるぽになります)
注入されている側のロジックは無視、というか挙動を固定しておいて、
テスト対象クラスのロジックだけテストすることができたら、それはもううれしい。
ということで使えるのがMockitoです。
今回は以下のようなクラス構成で考えます。
@Service
@RequiredArgsConstructor
public class HogeService {
@NonNull
private final MogeService mogeService;
public void execute() {
System.out.println(mogeService.returnStr());
mogeService.returnNothing();
System.out.println("hoge executed.");
}
}
@Service
public class MogeService {
public String returnStr() {
System.out.println("returnStr executed.");
return "moge return";
}
public void returnNothing() {
System.out.println("returnNothing executed.");
}
}
HogeService
はコンストラクタインジェクションでMogeService
が注入されていて、
MogeService
はStringを返すメソッドとvoidのメソッドをそれぞれ持っている形です。
HogeService
をテストしようとした時、MogeService
をMockにできれば嬉しいよね、
というのが今回の趣旨です。
手順1:HogeSerivce
のテストクラスを作る
さて、まずは普通にテストクラスを作ります。
class HogeServiceTest {
private HogeService hogeService;
@Test
void テスト1() {
hogeService.execute();
}
}
ここはただの下準備なので何も言うことはありません。
テストクラスを作ってテスト対象クラスを呼び出してみただけです。
ちなみに変数hogeService
に何も代入していないため、今のところぬるぽで即落ちます。
手順2:HogeService
のインスタンスを作る
次はぬるぽを解消しましょう。
hogeService
にインスタンスを詰めてあげるだけです。
class HogeServiceTest {
private HogeService hogeService;
@BeforeEach
private void setup() {
hogeService = new HogeService(new MogeService());
}
@Test
void テスト1() {
hogeService.execute();
}
}
HogeService
はアノテーション@RequiredArgsConstructor
を付与しているので、
MogeService
のインスタンスを一つ受け取るコンストラクタをpublicで持っています。
よってnewしたMogeService
を渡してあげることでインスタンス化できますが、
これではexecute()
を呼んだ時にMogeService
の処理まで動いてしまいますね。
手順3:MogeService
をmockにする
そこで、@Mock
です。
@ExtendWith(MockitoExtension.class)
class HogeServiceTest {
private HogeService hogeService;
@Mock
private MogeService mockedMogeService;
@BeforeEach
private void setup() {
hogeService = new HogeService(mockedMogeService);
}
@Test
void テスト1() {
hogeService.execute();
}
}
重要なポイントは以下の3点です。
- クラスに
@ExtendWith
でMockitoExtention.class
を指定する - モック化したいクラスに対して
@Mock
を付与する - モック化したクラスをテスト対象クラスにinjectionする
@Mock
を付与されたmogeService
はモックとなるため
hogeService.execute()
を実行しても、実際のMogeService
の処理は呼ばれません。
余談
コンストラクタインジェクションだからこうやって書いてたけど、
hogeService = new HogeService(mockedMogeService);
仮にフィールドインジェクションだったらどうなるの?
public class HogeService {
@Autowired
private MogeService mogeService;
そういう場合はアノテーションを使います。
というか、コンストラクタインジェクションでもこれで書けますし、
これを推奨している記事が多いイメージですが、なんとなく手癖が・・・
@InjectMocks
private HogeService hogeService;
手順4:Mockの挙動を設定する
実行しても、実際の
MogeService
の処理は呼ばれません。
じゃあ何が呼ばれるんだよという話になるので、
以下の処理を呼んでいるテストの実行結果を見てみます。
public void execute() {
System.out.println(mogeService.returnStr());
mogeService.returnNothing();
System.out.println("hoge executed.");
}
null
hoge executed.
1行目の通り、mogeService
のreturnStr()
からの戻り値はnull
でした。
returnNothing()
も元の処理ではsysoutが行われますが、今回は何も出力されません。
すなわち、戻り値ありのメソッドはnull
を返し、voidのメソッドは何もしなくなります。
ぬるぽは起きなくなりました、でもこのままでは意味がありません。
mogeService.returnStr()
が空文字を返却するパターンも、
mogeService.returnNothing()
が例外を投げるパターンも、
まだ何もテストしていないからです。こんなところで終わるわけにはいかない。
@Test
void テスト1() {
when(mogeService.returnStr()).thenReturn("mogemoge");
// もしくは
doReturn("mogemoge").when(mogeService).returnStr();
hogeService.execute();
}
@Test
void テスト2() {
doThrow(new NullPointerException()).when(mogeService).returnNothing();
// 引数なしの場合はwhen()から始めることはできない
hogeService.execute();
}
ので、mockの挙動を自分で設定してみます。
使っているwhen
やdoThrow
はMockito
が提供しているメソッドで、
今回はorg.mockito.Mockito.*
をstaticインポートして使用しています。
覚えてしまえば直感的に使えるためわかりやすいですが、
戻り値の有無で書き方が変わることと、mock側メソッドの引数の扱いには注意が必要です。
まず戻り値の有無について。
戻り値があるメソッドはwhen()
で囲んだ後、thenReturn()
を生やす書き方と
doReturn()
から始めてwhen()
に繋げる書き方の2パターンが可能です。
戻り値のないvoidのメソッドについては、
先にdoThrow()
を書いておいて、そこからwhen().method()
と続くのみで、
when()
から始めることはできません。
// whenから始める(戻り値あり)
when(mogeService.returnStr()).thenReturn("mogemoge");
// whenから始める(戻り値なし):これはコンパイルエラーになる
when(mogeService.returnNothing()).thenThrow(new NullPointerException());
// doXXから始める(戻り値あり)
doReturn("mogemoge").when(mogeService).returnStr();
// doXXから始める(戻り値なし)
doThrow(new NullPointerException()).when(mogeService).returnNothing();
条件→結果の順に読めるので個人的にはwhen()
から始める記法が好みですが、
そちらを使うと戻り値の有無で記述が変わるため、まあ一長一短ではあります。
そしてもう一点、mock側メソッドへの引数の扱いですが、
上記例では記載ないものの、仮に以下のような引数を取るメソッドをmock化するとします。
@Service
public class MogeService {
public String returnArg(String arg1, String arg2) {
return arg1 + arg2;
}
}
その場合は「どういう引数を受け取った時の挙動なのか」を指定しなければなりません。
以下の例ではany()
を指定していますが、
これは「どんな引数が来ても、returnArg()
が呼び出されたら」という指定になります。
when(mogeService.returnArg(any(), any())).thenReturn("mogemoge");
「特定の値がreturnArg()
に渡されたら」という指定は以下です。
when(mogeService.returnArg("hoge", "moge")).thenReturn("bar");
when(mogeService.returnArg(eq("foo"), eq("bar"))).thenReturn("bar");
any()
やeq()
はMatcherと呼ばれるものです。
上記のように、値をそのまま入れても、eq()
で囲んでもどちらでも動きますが
Matcherを使いたい時はMatcherで統一する必要があるため、
混ぜて使うとMockitoに怒られて動きません。
// これはダメ
when(mogeService.returnArg("hoge", any())).thenReturn("bar");
when(mogeService.returnArg("hoge", eq("moge"))).thenReturn("bar");
// こう書く
when(mogeService.returnArg(eq("hoge"), any())).thenReturn("bar");
when(mogeService.returnArg(eq("hoge"), eq("moge"))).thenReturn("bar");
Matcherの有無で記述を変えるのも面倒なので、
常にeq()
で囲むくらいの気持ちでいいかなと思っています。