JUnit4まででは増えすぎたテストを整理するために、スタティックなメンバークラスを作って整理してきたかと思います。
@RunWith(Enclosed.class)
public class Foo {
public static class Bar {
@Test
public void baz() {}
}
}
JUnit5 ではネストしたクラスを用いる場合、 スタティックでないメンバークラスを用いるようになりました。
class Foo {
class Bar {
@Test
void baz() {}
}
}
このような構造のクラスの場合、メンバークラスからルートにあるクラスのフィールドにアクセスできます。
class Foo {
private String name;
@BeforeEach
void setup() {
this.name = "foo";
}
@Nested
class Bar {
@Test
void test() {
assertEquals("foo", name);
}
}
}
これを応用して、テストの条件をクラス構造として表現します。
例えば、次のようなサービスクラスのメソッドがあって、モックを用いたテストを行います。
public class ReportServiceImpl imlements ReportService {
private final TimeRepository timeRepository;
private final GameDao gameDao;
private final UserExternalRepository userExternalRepository;
public List<UserScore> getDailyRanking(final LocalDate date) {
if (!timeRepository.isBeforeToday(date)) {
return Collections.emptyMap();
}
final List<UserScore> userScoreList = gameDao.dailyUserScoreList(date);
if (userScoreList.isEmpty()) {
return Collections.emptyMap();
}
final Set<UserId> users = userScore.stream().map(UserScore::getUserId).collect(toSet());
final Collection<UserId> premiumUsers = userExternalRepository.findPremiumUsers(users);
if (premiumUsers.isEmpty()) {
return Collections.emptyMap();
}
return userScoreList.stream()
.filter(s -> premiumUsers.contains(s.getUserId()))
.sorted((l,r) -> Long.compare(l.getScore(), r.getScore()))
.collect(toList());
}
}
JUnit4であると、このようなメソッドのテストは次のように条件の記述が占めるようになります。
@RunWith(MockitoJUnitRunner.class)
public class ReportServiceGetDailyRankingTest {
@Mock TimeRepository timeRepository;
@Mock GameDao gameDao;
@Mock UserExternalRepository userExternalRepository;
@InjectMock ReportServiceImpl service;
@Test
public void 指定日のスコアがない場合の結果は0件() {
when(timeRepository.isBeforeToday(any())).thenReturn(true);
when(gameDao.dailyUserScoreList(any())).thenReturn(Collections.emptyList());
final List<UserScore> actual = service.getDailyRanking(LocalDate.now());
assertTrue(actual.isEmpty());
}
@Test
public void 指定日が実行日と同じ場合の結果は0件() {
when(timeRepository.isBeforeToday(any())).thenReturn(false);
final List<UserScore> actual = service.getDailyRanking(LocalDate.now());
assertTrue(actual.isEmpty());
}
@Test
public void プレミアムユーザーがいない場合の結果は0件() {
when(timeRepository.isBeforeToday(any())).thenReturn(true);
when(gameDao.dailyUserScoreList(any())).thenReturn(Arrays.asList(score1, score2, score3));
when(userExternalRepository.findPremiumUsers(anyIterable())).thenReturn(Collections.emptySet());
final List<UserScore> actual = service.getDailyRanking(LocalDate.now());
assertTrue(actual.isEmpty());
}
}
メソッドやクラスの設計が適切な状態であれば、メソッドが依存するモジュールの数も少なく、モックの記述量も少なくなるため、これでも良いかもしれません。しかし、現実的にはメソッドの分割が不十分で依存するモジュールも多いため、モックの記述量もばかにならないようなテストがあったりします。また、幾つかのモックの記述が重複するため、共通できるものを @Before
で定義しすぎた結果、 UnnecessaryStubbingException
という例外が出て、 MockitoJunitRunner
ではなく MockitoJunitRunner.Silent
を使わざるを得なくなるのではないかと思います。基本的な使い方が変わらないJUnit5でも言えてくるのではないかと思います。
そこで、最初に書いた メンバークラスがスタティックでないことを利用します。
ルートにあるクラスから、条件を積み重ねていった入れ子クラスを作っていって、それをテストの事前条件にします。
例えば、先の例では 1個目のテストと3個目のテストは timeRepository
の条件を共有しているので、それらのテストを同じクラスのメンバークラスとして記述するようにします。また、2個目のテストの timeRepository
の条件は 1個目と3個目とは異なるので、それらとは異なるクラスのメンバークラスに記述します。また、1個目と3個目のテストは gameDao
については異なる条件ですので、これらは別々のクラスに所属します。
@DisplayName("getDailyRankingのパラメーターが")
class ReportServiceGetDailyRankingTest {
private TimeRepository timeRepository = mock(TimeRepository.class);
@DisplayName("本日の場合")
@Nested
class TodayOrAfter {
private ReportService service;
@BeforeEach
void setup() {
when(timeRepository.isBeforeToday(any())).thenReturn(true);
service = new ReportServiceImpl(timeRepository, mock(GameDao.class, mock(UserExternalRepository.class)))
}
@DisplayName("件数は0件")
@Test
void test() {
final List<UserScore> actual = service.getDailyReport(LocalDate.now());
assertTrue(actual.isEmpty());
}
}
@DisplayName("前日の場合に")
@Nested
class BeforeToday {
private GameDao gameDao = mock(GameDao.class);
@BeforeEach
void setup() {
when(timeRepository.isBeforeToday(any())).thenReturn(false);
}
@DisplayName("プレイしたプレイヤーがいないときは")
@Nested
class NoPlayer {
private ReportService service;
@BeforeEach
void setup() {
when(gameDao.dailyUserScoreList(anyIterable())).thenReturn(Collecitons.emptySet());
service = new ReportServiceImpl(timeRepository, mock(GameDao.class, mock(UserExternalRepository.class)))
}
@DisplayName("件数は0件")
@Test
void test() {
final List<UserScore> actual = service.getDailyReport(LocalDate.now());
assertTrue(actual.isEmpty());
}
}
}
}
このテストの書き方のメリットはテストクラスの構造によってテスト条件が明確になるので、テストのメンテナンスなどの際に条件が理解しやすく修正箇所が小さくなるということです。デメリットはネストが深くなってしまってIDEやスタイルの設定によっては読みづらくなるということです。ただし、このネストの深さについては、ここでの議論の入り口にある、クラス・メソッドの分割ができていないという状態を改善することで修正できますし、そうするべきだと思っています。