mike-neckのブログ

Java or Groovy or Swift or Golang

JUnit5のNestedクラスを使ってテスト条件をクラスの構造に閉じ込める(テスト小ネタ Advent Calendar 2017 6日目)

f:id:mike_neck:20171116211230p:plain

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); // succeed
    }
  }
}

これを応用して、テストの条件をクラス構造として表現します。

例えば、次のようなサービスクラスのメソッドがあって、モックを用いたテストを行います。

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 //1
  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 //2
  public void 指定日が実行日と同じ場合の結果は0件() {
    when(timeRepository.isBeforeToday(any())).thenReturn(false);

    final List<UserScore> actual = service.getDailyRanking(LocalDate.now());

    assertTrue(actual.isEmpty());
  }

  @Test //3
  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やスタイルの設定によっては読みづらくなるということです。ただし、このネストの深さについては、ここでの議論の入り口にある、クラス・メソッドの分割ができていないという状態を改善することで修正できますし、そうするべきだと思っています。