はじめに
Java Advent Calendar 2022の記事です。事前に作成しきれなかったので、当日に編集して投稿してます。
JUnit5 + Mockito + spring boot をつかってテストコードを書く仕事があったので、そのときの知見のまとめです。JUnit4から比べると、だいぶいろんなことがやりやすくなっています。
-
@Nested
による階層化 - XXXSpecを参考にしたメソッド名のネーミング
- Arrange-Act-Assertパターンによるテストメソッドの実装
- Extensionを使ったテストの事前・事後処理の共通化
-
@ParameterizedTest
を使ったパターンの網羅
によって、保守性・可読性と、パラメタのパターンの網羅の、実践的なやり方の紹介になります。
テストクラス全体の構造
@Nested
によってテストクラスに、階層構造を簡単にいれられるようになりました。
それを利用して、 メソッド -> ハイレベルのテストケース -> 具体的なテストケース という風に、ネストしていくにつれ、詳細になっていく形をとります。
テストするコードとして、下記のようなspring bootでのcontrollerクラスをサンプルとしてあげます。
@RequestMapping("users")
@Controller
class UserController
private final UserService userService;
UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("{id}")
public User findById(@PathVariable long id) {
return userService.findById(id);
}
@GetMapping
public List<User> findAll() {
return userService.findAll();
}
これに対して、下記のようなテストコードを書いていきます。
class UserControllerTest {
@Mock
UserService userService;
@InjectMocks
UserController userController;
@Mock
User mockUser;
@BeforeEach
void setup() {
MockitoAnnotations.openMocks(this);
}
@Nested
class findBy {
@Nested
class existingId {
@Test
void shouldReturnUser() {
when(userService.findBy(1L)).thenReturn(mockUser);
User actual = controller.findBy(1L)
verify(userService, times(1)).findBy(1L);
assertEquals(mockUser, actual);
}
}
@Nested
class notFoundId {
@Test
void shouldThrowNotFoundException() {
when(userService.findBy(anyLong())).thenThrow(NotFoundException.class);
assertThrows(NotFoundException.class () -> controller.findBy(1L));
}
}
}
@Nested
class findAll {
@Test
void shouldCallFindAll() {
when(userService.findAll()).thenReturn(Collections.emptyList());
controller.findAll();
verify(userService, times(1)).findAll();
}
}
}
ここから、このテストコードの各部分について解説をしていきます。
Mockの使い方
// DIの対象
@Mock
UserService userService;
// mockされたものをテスト対象へ注入
@InjectMocks
UserController userController;
// テスト用のインスタンス生成のため
@Mock
User mockUser;
@BeforeEach
void setup() {
// @Mockなどのアノテーションがついたものの初期化
MockitoAnnotations.openMocks(this);
}
かつてはMockito#mock
でモック化されたインスタンスをつくるやり方でした。これだと、Genericsの場合にうまく対応できない問題があります。
@Mock
+ @BeforeEach
でMockitoAnnotations#openMocks
の組み合わせで、モック化されたインスタンスを作成します。
DIの対象はもちろん、テスト用に都合のいい状態をもったインスタンスをつくるためにも、@Mock
を使います。
これは、テストコードのためだけのインスタンス生成のメソッドを、productionコードや、テストパッケージ下につくるのをなるべく回避するためです。複数のケースで使う場合は、ParameterResolverなどをつかって提供する分にはよいのですが、特定のテストケースのためだけや、逆に不特定にいろんなテストケースに使われるメソッドができてしまうと、テストコード自体の保守が難しくなるからです。
単発なら、テストメソッド内でMockito#when
を、同じ条件を満たす複数のテストケースで使うなら、@Nested
+ @BeforeEach
をつけてsetupメソッド内でmockの振る舞いを設定すれば、大概のケースは対応できます。
Nestedクラスの命名規則
- 最初の階層は、テスト対象のメソッド名
- その下は、テスト対象のメソッドに対しての、ハイレベルのテストケース
- 「正常系」「異常系」など、大きく2つに分ける
- 値が存在する、存在しない
- 以上以下などの境界値の各ケース
最初にメソッドで階層をつくることで、そのメソッドに改修が入ったり、削除された場合など、テストコードの変更の範囲がすぐに特定できるようになります。
その次に、テストすべき、ハイレベルのケースで階層をいれることで、仕様や、変更があったときの影響範囲がわかりやすくなります。@BeforeEach
と組み合わせれば、前提条件の記述を一箇所にまとめ、複数のテストケースに再利用させやすくなります。
これだけでも、可読性・保守性がだいぶ維持できるようになります。
テストメソッドの命名規則
assertしている内容がメソッド名からわかるように
- shouldXXX
- XXXShouldYYY
の形にします。assertしたい内容によっては、canなどの別の助動詞もありとします。
だいぶ前ですが、playframework+scalaをやった経験があり、specsがテストコードのフレームワークでした。それをまねたネーミングです。
assertしている内容がわかるのと、チーム内で最低限度の統一感を出すために編み出したルールなので、これを満たせるなら別のネーミングでなんら問題ないです。
テストメソッド内の構造
Arrange - Act - Assert パターンを採用します。
- 前提条件を設定(Arrange)
- テスト対象のメソッドを実行(Act)
- 期待どおりの結果を確認(Assert)
の順に書いていきます。すこしこなれてくると、自然とこういった形になることが多いですが、名前をつけて、意識的にやるのは、再現性が高まるのでおすすめです。
@Test
void shouldReturnUser() {
// Arrange
when(userService.findBy(1L)).thenReturn(mockUser);
// Act
User actual = controller.findBy(1L)
// Assert
verify(userService, times(1)).findBy(1L);
assertEquals(mockUser, actual);
}
ちなみに、これはBDDでいうところの、Given - When - Then パターンの言い換え
ExtendedWithを使った事前・事後処理の共通化
JUnit4のTestRunnerなどに相当するものは、Junit5ではExtension になります。
- テスト用のwebサーバを起動する
- テスト用のDBサーバを起動する
- テスト用のランダムパラメタを提供する
- Utilクラスのstaticのモック化
などは、Extensionの機能で実現できます。
junit5のサンプルコードのリポジトリにあるこちらを参考に、staticメソッドのモック化の共通化のExtesionを作成しました。
public class UserUtilExtension implements BeforeEachCallBack, AfterEachCallback, ParameterResolver {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface MockedUserUtil {
}
private MockedStatic<UserUtil> mockedUserUtil;
@Override
public void beforeEach(ExtensionContext context) {
// テストメソッドごとのにmockインスタンス生成
mockedUserUtil = mockStatic(UserUtil.class);
mockedUserUtil.when(mockedUserUtil::getId).thenReturn(1L);
}
@Override
public void afterEach(ExtensionContext context) {
if (mockedUtil != null) {
// MockedStaticはAutoclosableなので、close処理を呼び出し
mockedUserUtil.close();
}
}
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
return mockedUserUtil;
}
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
return parameterContext.isAnnotated(MockedUserUtil.class);
}
mockitoのみでも、下記のようなコードで、staticメソッドをモック化できるようになりました。
try (MockedStatic<UserUtil> mocked = mockStatic(UserUtil.class)) {
mocked.when(UserUtil::getId).thenReturn(1L);
assertEquals(1L, UserUtil.getId());
mocked.verify(UserUtil::getId);
}
ただ、このやり方の難点としては、try-with-resources構文が必要で、ネストが一段深くなり、読みづらくなってしまうことです。
単発なら問題ないですが、ひとつのクラスのテストクラスで、これが複数回でてくると、厳しくなってきます。
staticメソッドではなく、インスタンスメソッドに依存するようにリファクタリングできれば回避できますが、それが難しい場合の、対象療法です。
例のためだけですが、下記のようなUserUtil#getIdに依存したクラスがあるとします。
public SampleController {
private UserService userService;
...
public long getUserId() {
return UserUtil.getId();
}
public String getUserName() {
User user = userService.findById(UserUtil.getId());
return user.getName();
}
}
これに対して、Extensionをつかうと、テストコードは以下のようになります。
@ExtendWith(UserUtilExtension.class)
public class SampleControllerTest {
@Mock
UserService userService;
@InjectMocks
SampleController sampleController;
@Mock
User mockUser;
@BeforeEach
void setup() {
MockitoAnnotations.openMocks(this);
}
@Test
void shouldReturnUserId() {
long actual = sampleController.getUserId();
assertEquals(1L, actual);
}
@Test
void shouldReturnUserName() {
when(mockUser.getName()).thenReturn("test")
when(userService.findById(1L)).thenReturn(mockUser);
String actual = sampleController.getUserName();
assertEquals("test", actual);
}
@Test
void shouldReturn2(@MockedUserUtil MockedStatic<UserUtil> mockedUserUtil) {
// モックの振る舞いを変えたい場合はparameter resolver経由で取得
mockedUserUtil.when(UserUtil::getId).thenReturn(2L);
long actual = sampleController.getUserId();
assertEquals(2L, actual);
}
}
Extensionなしだと、下記のように、毎回try-with-resources構文を使う形になります。
@Test
shouldReturnUserId() {
try(MockedStatic<UserUtil> mocked = mockStatic(UserUtil.class)) {
mocked.when(UserUtil::getId).thenReturn(1L);
...
}
}
@Test
shouldReturnUserName() {
try(MockedStatic<UserUtil> mocked = mockStatic(UserUtil.class)) {
mocked.when(UserUtil::getId).thenReturn(1L);
...
}
}
ParamterizedTestを使ったパターンの網羅
JUnitへのサポート
JUnit5の公式サイトからdonationできます。スポンサーになれば、自社サイトのリンクをJUnitのホームページに掲載できます。個人レベルのサポートでも、希望すれば名前を掲載することができます。
過去のJUnit関連の記事
参考
JUnitについて
Mockitoについて