前回の続き
これまでの内容をまとめて、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でオブジェクトを構築する Extension
を DateServiceGuiceExtension
と名前をつけるとして、テストは次のような感じにする。
@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
の実装
Extension
は BeforeAllCallback
、 AfterAllCallback
、 ParameterResolver
を実装します。
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()) )); }
これでテストを実行すると次のようになります。
なお、サンプルコードはこちらにあります。