Привет, Хабр!

В последнее время мы вернулись к тщательной проработке темы тестирования, а в обозримых планах у нас даже есть отличная книга по Unit Testing. При этом считаем, что в данной теме как нигде важен контекст, поэтому сегодня предлагаем перевод сразу двух публикаций (объединенных в одну), вышедших в блоге видного специалиста по Java EE Себастьяна Дашнера — а именно, 1/6 и 2/6 из серии «Thoughts on efficient enterprise testing».

Тестирование в энтерпрайзе – это тема, которая до сих пор рассмотрена не столь подробно, как хотелось бы. Для написания и особенно для поддержки тестов требуется немало времени и сил, однако, и попытка сэкономить время, отказавшись от тестов – не выход. Какие объемы задач, подходы и технологии тестирования стоит исследовать, чтобы повысить эффективность тестирования?

Введение


Безотносительно различных типов тестов и области их применения, смысл подготовки набора тестов – убедиться на этом материале, что в продакшене наше приложение будет работать именно так, как ожидается. Именно такая мотивация должна быть основной при проверке того, выполняет ли система поставленную задачу, если рассматривать данную систему с точки зрения пользователя.

Поскольку длительность устойчивого внимания (attention span) и переключение контекстов – это вещи, с которыми приходится считаться, мы должны гарантировать, что выполнение и проверка наших тестов не займет много времени, а результаты тестов будут предсказуемыми. При написании кода критически важна быстрая верификация кода (осуществимая в пределах одной секунды) – так обеспечивается высокая продуктивность и сосредоточенность при работе.
С другой стороны, мы должны обеспечить поддерживаемость тестов. Софт меняется очень часто, и, при существенном покрытии кода функциональными тестами, каждое функциональное изменение в продакшен-коде потребует изменения на уровне тестов. В идеале код тестов должен меняться лишь тогда, когда меняется функциональность, т.е., бизнес-логика, а не при уборке ненужного кода и рефакторинге. В целом, тестовые сценарии должны предусматривать возможность нефункциональных, структурных изменений.

Когда мы рассматриваем различные области применения тестов, возникает вопрос: на какие из таких областей стоит тратить время и силы? Например, в микросервисных приложениях, а также в любой системе, предусматривающей существенную работу по распределению и интеграции кода, особую важность приобретают интеграционные тесты, помогающие нащупать границы системы. Следовательно, нам требуется эффективный способ проверки всего приложения в целом во время локальной разработки, при этом поддерживая окружение и структуру этого приложения в виде, максимально близком к продакшену, насколько это возможно.

Принципы и ограничения


Независимо от решений, которые будут выбраны, давайте определим для нашего набора тестов следующие принципы и ограничения:

  • Тесты должны выполняться и проверяться быстро, а также давать быструю обратную связь. Если речь идет о модульных тестах без всякой дальнейшей интеграции, то мы должны быть в состоянии прогонять сотни тестов за секунду. Что касается интеграционных тестов, время выполнения зависит от конкретного сценария, но в идеале не превышает одной секунды.
  • В ходе разработки тесты должны обеспечивать быструю обратную связь, в том числе, и на интеграционном уровне. Для этого требуется, чтобы тестовый контекст запускался быстро, либо продолжал работать, пока мы пишем код. Следовательно, должно быть возможно построить эффективный цикл разработки, предусматривающий повторное развертывание и оборачиваемость тестов менее чем за пять секунд.
  • Тесты должны предусматривать возможность рефакторинга продакшен-кода без существенного изменения охвата этого кода тестами. Те изменения в коде, которые не затрагивают функциональные поведения приложения, должны требовать лишь минимальных изменений в коде тестов.
  • Те изменения в коде, которые прямо затрагивают функциональную сторону кода, также должны приводить лишь к ограниченному изменению кода тестов. Пример: «Сколько сил потребуется для замены HTTP-границ на gRPC, для замены JSON на что-то еще или даже для замены enterprise-фреймворка, т.д.?”.
  • Технология тестирования и тестовый подход должны быть совместимы с созданием качественных абстракций, делегированием и высоким качеством кода, соответствующим нашим бизнес-требованиям. Мы должны быть в состоянии создавать выразительные API, расширять потенциальные DSL и делать корректные абстракции.
  • Технология тестирования должна поддерживать «режим разработки», то есть, запуск приложения таким образом, при котором возможны мгновенные изменения и переразвертывания в интегрированной среде, как, например, режимы “dev” и debug (отладка) на серверах, режим dev в Quarkus', Telepresence, подходы watch-and-deploy («наблюдай и развертывай») и другие.
  • Подход к тестированию должен быть совместим с отдельной настройкой цикла разработки и жизненного цикла тестирования. Таким образом, разработчик должен иметь возможность настроить и сконфигурировать локальное окружение вне тестового жизненного цикла, например, с использованием скриптов оболочки, а затем быстро прогонять тестовые сценарии в уже настроенной среде. Ради гибкости и возможностей переиспользования каждый из отдельных тестовых случаев должен управлять жизненным циклом тестовой установки.
  • Требуется возможность переиспользовать тестовые сценарии в разных областях применения, например, однократно определить бизнес-сценарий, а затем переиспользовать настроенную конфигурацию для системных тестов, нагрузочного тестирования, запускать их локально или применять к внешней развернутой среде. Должно быть просто копировать сценарии, каждый из которых должен состоять всего из нескольких строк кода, причем, для разных целей должны применяться различные реализации.

Модульные тесты


Модульный тест проверяет поведение единичного модуля, обычно – класса, тогда как все внешние факторы, не относящиеся к структуре модуля, игнорируются или имитируются. Модульные тесты должны проверять бизнес-логику отдельных модулей, без проверки их дальнейшей интеграции или конфигурации.

По моему опыту, большинство разработчиков, занятых в энтерпрайзе, весьма хорошо представляют, как составляются модульные тесты. Чтобы составить впечатление об этом, можете посмотреть этот пример в моем проекте coffee-testing. В большинстве проектов JUnit используется в комбинации с Mockito для имитации зависимостей, а в идеале и с AssertJ, чтобы эффективно определять удобочитаемые утверждения. Я всегда подчеркиваю, что модульные тесты можно выполнять без специальных расширений или пускателей, то есть, делать это при помощи обычного JUnit. Объясняется это просто: все дело во времени выполнения, ведь нам нужна возможность запускать сотни тестов за считанные миллисекунды.

Как правило, модульные тесты выполняются очень быстро, и из них легко собирать сложные тестовые наборы или специальные рабочие потоки, поскольку они просты в выполнении и не налагают никаких ограничений на жизненный цикл теста.

Однако, когда у вас множество модульных тестов, имитирующих зависимости тестируемого класса, в этом есть один недостаток: их тесная связь с реализацией (это особенно касается классов и методов), из-за чего наш код плохо поддается рефакторингу. Иными словами, за каждый акт рефакторинга в продакшен-коде требует вносить изменения и в тестовый код. В худшем случае разработчики даже начинают частично отказываться от рефакторинга, просто потому что он слишком обременителен, и качество кода в проекте быстро снижается. В идеале разработчик должен быть способен выполнять рефакторинг и перестановку элементов, при условии, что из-за этого не происходит изменений в работе приложения, заметных пользователю. Модульные тесты далеко не всегда упрощают рефакторинг продакшен-кода.

Но опыт подсказывает, что модульные тесты очень эффективны при проверке кода, который плотно наполнен лаконичной логикой или описывает реализацию конкретной функции, например, алгоритма, и, в то же время, не слишком активно взаимодействует с другими компонентами. Чем менее сложен или плотен код в конкретном классе, чем меньше его цикломатическая сложность, либо чем активнее он взаимодействует с другими компонентами, тем менее эффективны будут модульные тесты при тестировании данного класса. Особенно в случаях с микросервисами, в которых заключено сравнительно мало бизнес-логики, но предусмотрена обширная интеграция со внешними системами, пожалуй, невелика надобность во множестве применять модульные тесты. В таких системах отдельные модули (за редкими исключениями) обычно содержат мало специализированной логики. Это следует учитывать, решая, на что целесообразнее потратить время и силы.

Тестируем прикладные ситуации


Чтобы справиться с проблемой тесного связывания тестов с реализацией, можно попробовать немного иной подход, позволяющий расширить область применения тестов. В моей книге я писал о компонентных тестах, поскольку лучшего термина не нашел; но, в сущности, в данном случае речь идет о тестировании прикладных ситуаций.

Тесты прикладных ситуаций – это интеграционные тесты, действующие на уровне кода, в которых не используются встроенные контейнеры – от них отказываются ради ускорения запуска. Они проверяют бизнес-логику слаженных компонентов, которые обычно используются в рамках конкретного практического случая, от граничного метода – и далее вниз, до всех связанных с ними компонентами. Интеграция со внешними системами, например, с базами данных, имитируется при помощи моков.

Построение таких сценариев без использования более продвинутых технологий, которые автоматически подключали бы компоненты, кажется большим куском работы. Однако, мы определяем переиспользуемые тестовые компоненты, они же тестовые двойники, расширяющие компоненты путем имитации, подключения, а также добавления тестовых конфигураций; все это делается для минимизации общего объема усилий, необходимых для рефакторинга. Цель – создать единственные ответственности, ограничивающие степень влияния изменений единственным классом (или несколькими классами) в области тестирования. Выполняя такую работу с прицелом на переиспользование, мы сокращаем общий объем необходимой работы, и такая стратегия оправдывается, когда проект растет, но каждый компонент требует лишь мелкой починки, и эта работа быстро амортизируется.

Чтобы лучше себе все это представить, предположим, что мы тестируем класс, описывающий заказ кофе. В состав этого класса входит два других класса: CoffeeShop и OrderProcessor.



Классы тестовых двойников CoffeeShopTestDouble и OrderProcessorTestDouble, они же *TD, находятся в тестовой области проекта, где они наследуют компоненты CoffeeShop и OrderProcessor, расположенные в основной области программы. Тестовые двойники могут задавать необходимую логику имитации и подключения и потенциально расширять публичный интерфейс класса методами для имитации, нужной в данном прикладном случае, либо методами верификации.

Далее показан класс тестового двойника для компонента CoffeeShop:

public class CoffeeShopTestDouble extends CoffeeShop {

    public CoffeeShopTestDouble(OrderProcessorTestDouble orderProcessorTestDouble) {
        entityManager = mock(EntityManager.class);
        orderProcessor = orderProcessorTestDouble;
    }

    public void verifyCreateOrder(Order order) {
        verify(entityManager).merge(order);
    }

    public void verifyProcessUnfinishedOrders() {
        verify(entityManager).createNamedQuery(Order.FIND_UNFINISHED, Order.class);
    }

    public void answerForUnfinishedOrders(List<Order> orders) {
        // сымитировать поведение менеджера объектов 
    }
}

Класс тестового двойника может обращаться к полям и конструкторам базового класса CoffeeShop для установления зависимостей. Здесь в форме тестовых двойников используются и варианты других компонентов, в частности, OrderProcessorTestDouble, они нужны для вызова дополнительных имитационных или проверочных методов, которые входят в состав практического случая.

Классы тестовых двойников – это переиспользуемые компоненты, каждый из которых пишется по одному разу на область видимости каждого проекта, а затем используется во множестве практических случаев:

class CoffeeShopTest {

    private CoffeeShopTestDouble coffeeShop;
    private OrderProcessorTestDouble orderProcessor;

    @BeforeEach
    void setUp() {
        orderProcessor = new OrderProcessorTestDouble();
        coffeeShop = new CoffeeShopTestDouble(orderProcessor);
    }

    @Test
    void testCreateOrder() {
        Order order = new Order();
        coffeeShop.createOrder(order);
        coffeeShop.verifyCreateOrder(order);
    }

    @Test
    void testProcessUnfinishedOrders() {
        List<Order> orders = Arrays.asList(...);
        coffeeShop.answerForUnfinishedOrders(orders);

        coffeeShop.processUnfinishedOrders();

        coffeeShop.verifyProcessUnfinishedOrders();
        orderProcessor.verifyProcessOrders(orders);
    }

}

Компонентный тест верифицирует конкретный случай из бизнес-логики, который вызывается на точке входа, в данном случае, CoffeeShop. Такие тесты получаются краткими и удобочитаемыми, поскольку все подключение и имитация осуществляются в отдельных тестовых двойниках, а в дальнейшем они могут использовать узкоспециальные проверочные методы, например, verifyProcessOrders().

Как видите, тестовый класс расширяет область применения продакшен-класса, позволяя устанавливать моки и использовать методы, проверяющие поведение. Притом, что создается впечатление, будто на настройку такой системы уходит немало усилий, эти затраты быстро амортизируются, если в рамках всего проекта у нас найдется множество практических случаев, где компоненты могут переиспользоваться. Чем больше разрастается наш проект, тем полезнее становится такой подход – особенно если обратить внимание на время выполнения тестов. Все наши тестовые случая по-прежнему запускаются с применением JUnit, причем, за самое минимальное время они выполняются сотнями.

Вот в чем основная польза такого подхода: компонентные тесты выполняются столь же быстро, как обычные модульные тесты, однако, стимулируют рефакторинг продакшен-кода, так как изменения требуется вносить в единственный компонент или всего в несколько компонентов. Кроме того, улучшая тестовые двойники выразительными настроечными и проверочными методами, специфичными для нашей предметной области, мы повышаем удобочитаемость нашего кода, облегчаем его использование и избавляемся от стереотипного кода в тестовых сценариях.