mike-neckのブログ

Java or Groovy or Swift or Golang

JUnit5入門(6) - JUnit5とGuiceでDIが必要なオブジェクトを構築してテストする

前回の続き

これまでの内容をまとめて、DIが必要なオブジェクトをテストするやり方(のひとつ)をやってみる。DIにはGuiceを用いる。


環境

JDK|1.8.0_102 junit-jupiter-api|5.0.0-M3 junit-platform-engine|1.0.0-M3 guice|4.1.0


テスト対象のインターフェース

次のようなインターフェース/実装クラスをテストする。

インターフェース
public interface DateService {

  Locale getLocale();

  String date(int year, int month, int day);

  String today();

  String time(int hour, int minutes);

  String now();

  ZoneId getZone();
}
実装クラス
public class DateServiceImpl implements DateService {

  private final ZoneId zone;
  private final DateTimeFormatter dateFormat;
  private final DateTimeFormatter timeFormat;
  private final Locale locale;

  @Inject
  public DateServiceImpl(
      ZoneId zone
      , @Date DateTimeFormatter dateFormat
      , @Time DateTimeFormatter timeFormat
      , Locale locale
  ) {
    this.zone = zone;
    this.dateFormat = dateFormat.withLocale(locale);
    this.timeFormat = timeFormat.withLocale(locale);
    this.locale = locale;
  }

  @Override
  public Locale getLocale() {
    return locale;
  }
  @Override
  public String date(int year, int month, int day) {
    return LocalDate.of(year, month, day).format(dateFormat);
  }
  @Override
  public String today() {
    return LocalDate.now(zone).format(dateFormat);
  }
  @Override
  public String time(int hour, int minutes) {
    return LocalTime.of(hour, minutes).format(timeFormat);
  }
  @Override
  public String now() {
    return LocalDateTime.now(zone).format(timeFormat);
  }
  @Override
  public ZoneId getZone() {
    return zone;
  }
}

@Date@Timeバインディングアノテーション


テスト

guiceでオブジェクトを構築する ExtensionDateServiceGuiceExtension と名前をつけるとして、テストは次のような感じにする。

@DisplayName("DateServiceのテスト - 実装はDateServiceImpl")
public class DateServiceTest {
  @Nested
  @DisplayName("ロケールはen日付はMMM/d/uu,時刻はhh:mm,America/Los_Angeles")
  @Lang("en")
  @Date.Format("MMM/d/uu")
  @Time.Format("hh:mm")
  @Zone("America/Los_Angeles")
  @ExtendWith({ DateServiceGuiceExtension.class })
  class LosAngelesTest {
    @Test
    @DisplayName("dateの最初の3文字は英字")
    void first3CharactersIsAlphabet(DateService service) {
      final String today = service.today();
      assertTrue(today.substring(0, 3).matches("\\p{Alpha}{3}"));
    }
    @Test
    @DisplayName("時刻は必ず2文字ある")
    void hourHasTwoCharacters(DateService service) {
      final String now = service.now();
      assertEquals(2, now.split(":")[0].length());
    }
    @Test
    @DisplayName("タイムゾーンはAsia/Tokyoではない")
    void timeZoneIsNotAsiaTokyo(DateService service) {
      final ZoneId zone = service.getZone();
      assertNotEquals(of("Asia/Tokyo"), zone);
    }
  }
  @Nested
  @DisplayName("ロケール指定なし(デフォルト)/Dateの指定なし(uuuu/MM/dd)/Timeの指定なし(HH:mm:ss.SSS)/Zoneの指定なし(UTC)")
  @ExtendWith({ DateServiceGuiceExtension.class })
  class DefaultInjection {
    @Test
    @DisplayName("日付のフォーマットの確認")
    void validateDateFormat(DateService service) {
      assertAll(
          () -> assertEquals("2017/01/01", service.date(2017, 1, 1))
          , () -> assertEquals("2016/11/11", service.date(2016, 11, 11))
      );
    }
    @Test
    @DisplayName("時刻のフォーマットの確認")
    void validateTimeFormat(DateService service) {
      assertAll(
          () -> assertEquals("04:00:00.000", service.time(4, 0))
          , () -> assertEquals("13:13:00.000", service.time(13, 13))
      );
    }
  }
}

Extension の実装

ExtensionBeforeAllCallbackAfterAllCallbackParameterResolver を実装します。

  • BeforeAllCallback にてテストクラスの情報から必要な Injector を生成します。
  • ParameterResolver でテストが必要としているオブジェクトを解決します。
  • AfterAllCallback にて Injector を破棄します。
BeforeAllCallback/AfterAllCallback の実装
public abstract class GuiceExtension implements
    ParameterResolver
    , BeforeAllCallback
    , AfterAllCallback
{
  private static final Namespace GUICE = Namespace.create(com.example.guice.GuiceExtension.class);
  private static Store getStore(ExtensionContext context) {
    return context.getStore(GUICE);
  }

  abstract protected Module prepareModule(
      @NotNull Class<?> testClass
      , @NotNull Set<String> tags
      , @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
        @NotNull 
            Optional<AnnotatedElement> annotations
  );

  @Override
  public void beforeAll(ContainerExtensionContext context) throws Exception {
    final Store store = getStore(context);
    final Optional<AnnotatedElement> annotations = context.getElement();
    context.getTestClass()
        .map(c -> prepareModule(c, context.getTags(), annotations))
        .map(Guice::createInjector)
        .ifPresent(i -> store.put(Injector.class, i));
  }

  @Override
  public void afterAll(ContainerExtensionContext context) throws Exception {
    getStore(context).remove(Injector.class, Injector.class);
  }
}

このクラスを継承するクラスは、テストクラスに与えられたメタ情報(タグ/アノテーション)情報を元に、 Module を作ります。このクラスは Module から Injector を作りコンテキストに保持します。JUnit5のドキュメントによると Extensionインスタンスはテスト実行中に1つしか作られないので、テストの状態は Extension のフィールドには持たせずに Context が保持する Store に保持させるようにします。

ParameterResolver の実装

Store からテストクラスをキーにして Injector を取得し、さらにパラメーターの型、パラメーターに付与されたバインディングアノテーションをキーにしてインスタンスを取得します。

@Override
public boolean supports(
    ParameterContext parameterContext
    , ExtensionContext extensionContext
) throws ParameterResolutionException {
  final Parameter parameter = parameterContext.getParameter();
  final Class<?> paramType = parameter.getType();
  final Stream<? extends Key<?>> keyStream = Arrays
      .stream(parameter.getAnnotations())
      .map(a -> Key.get(paramType, a));
  return extensionContext.getTestClass()
      .map(c -> getStore(extensionContext).get(c, Injector.class))
      .map(Injector::getAllBindings)
      // a -> (a,a)
      .map(Pair.createPair(Function.identity()))
      // (a -> Bool) -> (b -> Bool) -> (a,b) -> Bool
      .filter(Pair.orFilterPair(
          b -> b.containsKey(Key.get(paramType))
          , b -> keyStream.anyMatch(b::containsKey)
      )).isPresent();
}
@Override
public Object resolve(
    ParameterContext parameterContext
    , ExtensionContext extensionContext
) throws ParameterResolutionException {
  final Parameter parameter = parameterContext.getParameter();
  final Class<?> paramType = parameter.getType();
  final Annotation[] annotations = parameter.getAnnotations();
  return extensionContext.getTestClass()
      .map(c -> getStore(extensionContext).get(c, Injector.class))
      .map(Injector::getAllBindings)
      // a -> (a, b)
      .map(Pair.createPair(b -> b.get(Key.get(paramType))))
      // (a, b) -> (b -> Maybe b) -> (a, Maybe b)
      .map(Pair.mapPair(Optional::ofNullable))
      // (a, Maybe b) -> (b -> c) -> (a, Maybe c)
      .map(Pair.mapPair(o -> o.map(Binding::getProvider)))
      // (a, Maybe c) -> (c -> Maybe t) -> (a, Maybe (Maybe t))
      .map(Pair.mapPair(o -> o.map(p -> (Object) p.get())))
      .map(Pair.mapPair(o -> o.map(Optional::of)))
      // (a, Maybe (Maybe t)) -> (a -> Maybe t -> Maybe t) -> Maybe t
      .flatMap(Pair.transformPair((m, o) -> o.orElseGet(
          // returns Optional<Object>
          findBindingByClassAndAnnotation(paramType, annotations, m))))
      .orElseThrow(() -> new ParameterResolutionException(
          String.format(
              "System cannot find binding for type: [%s] parameter index: %d"
              , parameter.getType()
              , parameterContext.getIndex())
      ));
}

これでテストを実行すると次のようになります。

f:id:mike_neck:20170103205333p:plain


なお、サンプルコードはこちらにあります。

github.com