前回の続き
これまでの内容をまとめて、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)
.map(Pair.createPair(Function.identity()))
.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)
.map(Pair.createPair(b -> b.get(Key.get(paramType))))
.map(Pair.mapPair(Optional::ofNullable))
.map(Pair.mapPair(o -> o.map(Binding::getProvider)))
.map(Pair.mapPair(o -> o.map(p -> (Object) p.get())))
.map(Pair.mapPair(o -> o.map(Optional::of)))
.flatMap(Pair.transformPair((m, o) -> o.orElseGet(
findBindingByClassAndAnnotation(paramType, annotations, m))))
.orElseThrow(() -> new ParameterResolutionException(
String.format(
"System cannot find binding for type: [%s] parameter index: %d"
, parameter.getType()
, parameterContext.getIndex())
));
}
これでテストを実行すると次のようになります。
なお、サンプルコードはこちらにあります。
github.com