У статических методов есть одна мощная, но и в то же время весьма нежелательная особенность: их можно вызвать из любого места в коде, не особо имея возможность регламентировать порядок их вызова. Зачастую такой контроль очень важен, но иногда порядок не имеет очень большого смысла. Например, осуществлять проверки в юнит-тестах часто можно не в очень строгом порядке. И чтобы гарантировать, что в тестируемом юните выполенены все проверки, в Mockito существует всё тот же статический метод verifyNoMoreInteractions(...). Иногда можно по ошибке вызвать такой метод ещё до последнего verify(...) и потом с огорчением наблюдать "красный" тест. Но что если переложить заботу о порядке выполнения проверок на сам компилятор?
Предположим, что некий тестируемый модуль располагает следующим тестом:
public abstract class AbstractStructuredLoggingTest<T> { private final IStructuredLogger mockStructuredLogger = mock(IStructuredLogger.class); private T unit; @Nonnull protected abstract T createUnit(@Nonnull IStructuredLogger logger); protected final IStructuredLogger getMockStructuredLogger() { return mockStructuredLogger; } protected final T getUnit() { return unit; } @Before public void initializeMockStructuredLogger() { // Настройка мока. `selfAnswer()` -- ответ для мока, который возвращает сам мок для fluent-интерфейсов when(mockStructuredLogger.begin()).thenAnswer(selfAnswer()); when(mockStructuredLogger.put(any(LogEntryKey.class), any(Object.class))).thenAnswer(selfAnswer()); when(mockStructuredLogger.log(any(Scope.class), any(Severity.class), any(String.class))).thenAnswer(selfAnswer()); when(mockStructuredLogger.end()).thenAnswer(selfAnswer()); // Внедрение мока логгера в юнит (по крайней мере, формально). Каким образом оно устроено -- не имеет особого значения. unit = createUnit(mockStructuredLogger); } @After public void resetMockStructuredLogger() { try { // Проверка, не осталось ли непровернного взаимодействия с моком verifyNoMoreInteractions(mockStructuredLogger); } finally { // На всякий случай, сброс состояния мока, потому как ошибки падают каскадно... reset(mockStructuredLogger); } } }
public final class AdministratorServiceStructuredLoggingTest extends AbstractStructuredLoggingTest<IAdministratorService> { private static final String USERNAME = "john.doe"; private static final String PASSWORD = "opZK2lkXa"; private static final String FIRST_NAME = "john"; private static final String LAST_NAME = "doe"; private static final String EMAIL = "john.doe@acme.com"; @Nonnull protected IAdministratorService createUnit(@Nonnull final IStructuredLogger logger) { return createAdministratorService(logger); } @Test public void testCreate() { final T unit = getUnit(); unit.create(USERNAME, PASSWORD, FIRST_NAME, LAST_NAME, EMAIL); final IStructuredLogger mockStructuredLogger = getMockStructuredLogger(); verify(mockStructuredLogger).put(eq(OPERATION_CALLER_CLASS), any(IAdministratorService.class)); verify(mockStructuredLogger).put(eq(OPERATION_CALLER_METHOD), any(Method.class)); verify(mockStructuredLogger).put(eq(OPERATION_TYPE), eq(CREATE)); verify(mockStructuredLogger).put(eq(OPERATION_OBJECT_TYPE), eq(ADMINISTRATOR)); verify(mockStructuredLogger).put(eq(VALUE_ADMINISTRATOR_NAME), eq(USERNAME)); verify(mockStructuredLogger).put(eq(VALUE_FIRST_NAME), eq(FIRST_NAME)); verify(mockStructuredLogger).put(eq(VALUE_LAST_NAME), eq(LAST_NAME)); verify(mockStructuredLogger).put(eq(VALUE_EMAIL), eq(EMAIL)); verify(mockStructuredLogger).log(eq(APP_DEV), eq(INFO), any(String.class)); verifyNoMoreInteractions(mockStructuredLogger); } }
Догадаться, что именно проверяет этот тест несложно: он проверяет, вызвал ли тестируемый метод юнита все важные методы структурированного логгера. Поскольку в конце теста есть вызывается ещё и verifyNoMockInteractions(...), гарантируется, что у мока не осталось методов, для которых не было написано проверок. Кстати, интерфейс структурированного логгера предельно прост, но я приведу его здесь в несколько урезанном виде, потому как код взят из реального проекта.
public interface IStructuredLogger { // В самом тесте не участвует, но имеет смысл, потому как позволяет демаркировать начало и конец сообщения для журналирования. @Nonnull IStructuredLogger begin() throws IllegalStateException; // Заполнение сообщения, которое попадёт в журнал. // key -- перечисление (enum) возможных ключей в сообщении (OPERATION_CALLER_CLASS, VALUE_FIRST_NAME и т.д.) // value -- произвольный аргумент @Nonnull IStructuredLogger put(@Nonnull LogEntryKey key, @Nullable Object value) throws IllegalStateException; // Запись сообщения в журнал. // scope -- перечисление типа журнала (например, APP_DEV -- запись одновременно и в пользовательский и в отладочный журналы) // severity -- снова перечисление для определения порога, которое должно преодолеть сообщение, чтобы попасть в журнал (ERROR, INFO и т.д.) // message -- произвольное сообщение, которое может легко прочитать человек @Nonnull IStructuredLogger log(@Nonnull Scope scope, @Nonnull Severity severity, @Nonnull String message) throws IllegalStateException; // Двойник для begin() @Nonnull IStructuredLogger end() throws IllegalStateException; }
Как было сказано выше, статические методы, которыми усеян тест, не гарантируют, что проверки всех типов будут осуществлены. И, наверняка, такой тест завершится с ошибкой. Под типами проверок в этом тесте я подразумеваю возможность определить:
- к какому классу привязано событие в журнале;
- какое действие было совершено и над каким видом объектов;
- какими аргументами для действия был заполнен журнал событий;
- какие журналы физически были задействованы в процессе журналирования;
- структурированный логгер в методе юнита больше ни для чего не применялся.
По сути, имеется конечный набор требований, который бы можно было заставить выполнится в определённом порядке.
Для решения этой задачи можно рассмотреть варианты с использованием шаблона стратегия, где бы был некий интерфейс с методами на каждый тип проверки, и каждый метод отвечал бы за свой аспект журналирования. Или, например, шаблонный метод. Но очевидно, что такие подходы были бы весьма громоздки, ненадёжны в плане гарантирования разделения аспектов по именно соответсвующим им методам. Да и читабельностью бы пришлось пожертвовать, чего уж точно не хотелось бы делать.
Лет пять назад, помнится, я наткнулся в Интернете на статью, описывающей реализацию шаблона строитель, которая, используя некоторые не совсем очевидные техники, гарантировала, что создание сложного объекта будет осуществлено в правильном порядке. Имеется ввиду следующее: для некоего объекта-строителя сначала можно вызвать только метод setFoo(), и лишь потом — setBar() с последующим build(). И никак не в другом порядке, ведь за порядком следит компилятор!
Похожий подход, но уже с другой реализацией, можно использовать и для упрощённого написания тестов по правилам, описанным выше строго в одном порядке, при этом без использования шаблонного метода. Учитывая кое-как формализированные особенности тестирования в этом случае, можно создать набор таких интерфейсов, которые и будут заниматься сцеплением таких переходов. И для удобства можно использовать текучий интерфейс, который позволит выстроить элегантную цепочку проверок.
// Шаг, который проверяет, к какому юниту и его методу было ассоциировано сообщение @FunctionalInterface public interface IOperationCallerVerificationStep { // unitMatcherSupplier -- возвращает юнит // methodMatcherSupplier -- возвращает метод, из которого ожидается вызов журналирования // Метод осуществляет несколько проверок, и если не было ошибок -- создать объект для следующего шага @Nonnull IOperationTypeVerificationStep withOperationCaller( @Nonnull Supplier<?> unitMatcherSupplier, @Nonnull Supplier<Method> methodMatcherSupplier ); // По умолчанию считаем, что нас не волнует метод юнита, с которого был вызван логгер. Это довольно // спорное утверждение, потому что это может дискредитировать сами юнит-тесты. Но для систем с автоматическим // журналированием (например, с помощью процессора аннотаций журнала во время исполнения, а не [APT](http://docs.oracle.com/javase/7/docs/technotes/guides/apt/)) это не // имеет большого значения. По крайней мере, автоматическая генерация журнала позволяет _удобно_ // формировать объект типа Method, чего не скажешь про ручное формирование такого сообщения. @Nonnull default IOperationTypeVerificationStep withOperationCaller( @Nonnull final Supplier<?> unitMatcherSupplier ) { return withOperationCaller(unitMatcherSupplier, () -> any(Method.class)); } }
// Шаг, проверяющий операцию и тип объекта этой операции @FunctionalInterface public interface IOperationTypeVerificationStep { // operationTypeMatcherSupplier -- возвращает тип операции // objectTypeMatcherSupplier -- возвращает тип объекта, над которым осуществляется операция @Nonnull IValueVerificationStep withOperationType( @Nonnull Supplier<OperationType> operationTypeMatcherSupplier, @Nonnull Supplier<ObjectType> objectTypeMatcherSupplier ); }
// Шаг, проверяющий именно контекстные данные (т.е., те, которые были переданы в юнит) public interface IValueVerificationStep { // logEntryKeyMatcherSupplier -- возвращает тип ключа для структурированного сообщения // valueMatcherSupplier -- произвольный аргумент // Кстати, этот метод не возвращает следующий объект следующего шага, потому как этот метод является // вариадическим -- т.е., подразумевают вызов нескольких таких методов подряд, потому как количество таких // пар неизвестно заранее. @Nonnull IValueVerificationStep withValue( @Nonnull Supplier<LogEntryKey> logEntryKeyMatcherSupplier, @Nonnull Supplier<?> valueMatcherSupplier ); // Весьма синтетический метод. Нужен только для того, чтобы осуществить переход на следующий шаг. @Nonnull ILogVerificationStep then(); }
// Финальный шаг, проверяющий, в какой журнал и с каким порогом будет записано сообщение @FunctionalInterface public interface ILogVerificationStep { // scopeMatcherSupplier -- тип журнала // severityMatcherSupplier -- порог для сообщения // messageMatcherSupplier -- произвольное сообщение // Это финальная проверка, поэтому этот метод не возвращает ничего void withLog( @Nonnull Supplier<Scope> scopeMatcherSupplier, @Nonnull Supplier<Severity> severityMatcherSupplier, @Nonnull Supplier<String> messageMatcherSupplier ); // Здесь нас не волнует сообщение вообще default void withLog( @Nonnull final Supplier<Scope> scopeMatcherSupplier, @Nonnull final Supplier<Severity> severityMatcherSupplier ) { withLog(scopeMatcherSupplier, severityMatcherSupplier, () -> any(String.class)); } // А здесь считаем, что по умолчанию сообщение пишется в два журнала одновременно (и APP, и DEV) default void withLog( @Nonnull final Supplier<Severity> severityMatcherSupplier ) { withLog(() -> eq(APP_DEV), severityMatcherSupplier, () -> any(String.class)); } }
Почти все интерфейсы получилось проаннотировать как @FunctionalInterface, хотя это и не является необходимостью. Тем не менее, "вариадический" интерфейс обладает двумя методами, поскольку нужно как-то сообщить о завершении проверки журналирования аргументов операции. Итак, оригинальный код теста теперь может принять следующий вид:
public abstract class AbstractStructuredLoggingTest<T> { private final IStructuredLogger mockStructuredLogger = mock(IStructuredLogger.class); private T unit; @Nonnull protected abstract T createUnit(@Nonnull IStructuredLogger logger); // Сюрприз-сюрприз! Метод больше не нужен, потому что все проверки инкапсулируются именно в этом классе /*protected final IStructuredLogger getMockStructuredLogger() { return mockStructuredLogger; }*/ protected final T getUnit() { return unit; } @Before public void initializeMockStructuredLogger() { when(mockStructuredLogger.begin()).thenAnswer(selfAnswer()); when(mockStructuredLogger.put(any(LogEntryKey.class), any(Object.class))).thenAnswer(selfAnswer()); when(mockStructuredLogger.log(any(Scope.class), any(Severity.class), any(String.class))).thenAnswer(selfAnswer()); when(mockStructuredLogger.end()).thenAnswer(selfAnswer()); unit = createUnit(mockStructuredLogger); } @After public void resetMockStructuredLogger() { try { verifyNoMoreInteractions(mockStructuredLogger); } finally { reset(mockStructuredLogger); } } // Метод, создающий первый шаг для проверки журналирования. Именно в нём и сосредоточены все проверки. // Код выглядит довольно неуклюже, но он хорошо решает свою задачу. Ситуация несколько упрощается // наличием лямбда-выражений. До того как перейти на следующий шаг, вызываются методы verify(...), // которые и были полностью инкапсулированы. В последнем шаге verifyNoMoreInteractions не вызывается, // поскольку этот метод вызывается после каждого теста автоматически. protected final IOperationCallerVerificationStep verifyLog() { return (unitMatcherSupplier, methodMatcherSupplier) -> { verify(mockStructuredLogger).put(eq(OPERATION_CALLER_CLASS), unitMatcherSupplier.get()); verify(mockStructuredLogger).put(eq(OPERATION_CALLER_METHOD), methodMatcherSupplier.get()); return (IOperationTypeVerificationStep) (operationTypeMatcherSupplier, objectTypeMatcherSupplier) -> { verify(mockStructuredLogger).put(eq(OPERATION_TYPE), operationTypeMatcherSupplier.get()); verify(mockStructuredLogger).put(eq(OPERATION_OBJECT_TYPE), objectTypeMatcherSupplier.get()); return new IValueVerificationStep() { @Nonnull @Override public IValueVerificationStep withValue(@Nonnull final Supplier<LogEntryKey> logEntryKeyMatcherSupplier, @Nonnull final Supplier<?> valueMatcherSupplier) { verify(mockStructuredLogger).put(logEntryKeyMatcherSupplier.get(), valueMatcherSupplier.get()); return this; } @Nonnull @Override public ILogVerificationStep then() { return (scopeMatcherSupplier, severityMatcherSupplier, messageMatcherSupplier) -> verify(mockStructuredLogger).log(scopeMatcherSupplier.get(), severityMatcherSupplier.get(), messageMatcherSupplier.get()); } }; }; }; } }
Ну и, собственно, само упрощение, ради которого было усложнено базовый функционал тестов:
public final class AdministratorServiceStructuredLoggingTest extends AbstractStructuredLoggingTest { private static final String USERNAME = "usr"; private static final String PASSWORD = "qwerty"; private static final String FIRST_NAME = "john"; private static final String LAST_NAME = "doe"; private static final String EMAIL = "usr@mail.com"; @Nonnull protected IAdministratorService createUnit(@Nonnull final IStructuredLogger logger) { return createAdministratorService(logger); } @Test public void testCreate() { final T unit = getUnit(); unit.create(USERNAME, PASSWORD, FIRST_NAME, LAST_NAME, EMAIL); verifyLog() .withOperationCaller(() -> any(IAdministratorService.class)) .withOperationType(() -> eq(CREATE), () -> eq(ADMINISTRATOR)) .withValue(() -> eq(VALUE_ADMINISTRATOR_NAME), () -> eq(USERNAME)) .withValue(() -> eq(VALUE_FIRST_NAME), () -> eq(FIRST_NAME)) .withValue(() -> eq(VALUE_LAST_NAME), () -> eq(LAST_NAME)) .withValue(() -> eq(VALUE_EMAIL), () -> eq(EMAIL)) .then() .withLog(() -> eq(INFO)); } }
Как по мне, код стал надёжнее и весьма красивее. Да и удобнее тоже. И главное — любая умная IDE при нажатии на точку сама подсказывает, каким должен быть следующий шаг. Таким образом, и компилятор и IDE добавляют ешё немного уверенности в том, насколько хорошо написан тест. Кстати, почему используются Supplier-ы и лямбда-выражения? Дело в том, что Mockito проверяет, передаются ли стабы напрямую в моки, и если нет — бросает исключение. На самом деле, здесь, насколько мне известно, правила немного сложнее, и, например, Mockito игнорирует анонимные классы. И ввиду этого факта есть небольшая лазейка: Mockito не отслеживает передачу матчеров через return, что открывает дорогу к использованию лямбд. Это немного усложняет код и читабельность, но лямбды достаточно неплохо с этим справляются.
В итоге получился следующий результат:
- однотипные тесты;
- каждый следующий шаг в тесте формально описывает свой следующий шаг, что прекрасно дополняется поддержкой со стороны компилятора и IDE, что недостижимо в случае использования статических методов (по крайней мере, в начальном варианте теста);
- инициализация тестов и их последующая проверка осуществляется абстрактным тестом, а конкретный тест просто описывает проверки, даже по сути прямо не взаимодействуя с оригинальным юнитом.
