On the Layout of Tests

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

Я не знаю, на каком языке программирования вы работаете, но мой опыт работы с недавним кодированием на Java, Ruby, JavaScript, PHP, Python, C++ и Rust говорит мне, что принцип, которому я постараюсь убедить вас следовать, универсален для всех языков. Речь идет о названии файлов с тестами. Возможно, вам это покажется вопросом низкой важности, но позвольте мне показать, что это не так. Как вы называете свои файлы с тестовыми классами? Сколько из них вы создаете в каталоге src/test/java? Где размещать класс, который используется только в тесте, но сам по себе не является тестом? На большинство из этих вопросов большинство из вас ответит “Как угодно!” Итак, давайте попробуем найти лучший ответ.

Основная цель моих модульных тестов - помочь мне писать код. Они - сетка безопасности, которая “ловит” меня, когда я допускаю ошибку. Например, предположим, что я возвращаюсь и редактирую несколько файлов, которые редактировал несколько лет назад, и, конечно же, на этот раз я делаю это неправильно. Затем я запускаю все 500 модульных тестов в проекте, и… десять из них становятся красными. Обратите внимание, я не говорю “неудачно”, потому что, подобно сетке безопасности вокруг здания, неудавшиеся тесты - это те тесты, которые не поймали падающий молоток и не заметили только что появившуюся ошибку. Таким образом, 490 из них неудачны, но десять из них удачны.

Далее я почесываю голову и думаю - что именно я сделал не так? Какой файл я сломал? Я только что изменил несколько десятков строк кода. Где именно была ошибка? Чтобы выяснить это, я читаю вывод тестов. Я ожидаю, что сообщения, которые они выводят в консоль, будут достаточно описательными, чтобы помочь мне понять проблему. Я не хочу отменять все свои изменения и начинать с нуля, верно? Я хочу быстро перейти к строке с ошибкой, исправить ее, запустить все 500 тестов заново, увидеть их все зелеными, зафиксировать свои изменения и закончить на сегодня.

Само собой разумеется, что описательные сообщения проверок и правильное именование методов тестирования - рецепт успеха. Рассмотрим простой объект Фразы, куда мы добавляем несколько английских фраз, и он волшебным образом понимает, какие из них являются приветствиями (очевидно, с помощью ML). Для такого класса этот тест на Java/JUnit5 был бы очень плохим:

В то время как данный тест намного лучше благодаря утверждениям Hamcrest (как назвать методы тестов - это отдельная история, подробно объясненная здесь):

Первый фрагмент выведет довольно неясное сообщение об ошибке, тогда как второй очень поможет мне в борьбе с только что возникшей ошибкой: Сообщение будет само по себе понятным. Я быстро пойму, в чем проблема.

Описательные сообщения помогут мне понять, в чем проблема. Однако я узнаю где находится проблема? В каком классе Java? На самом деле нет. Она находится в Phrases.java, или, может быть, в Greetings.java, который возвращается Phrases.greetings()? Я могу узнать эту информацию только из имени тестового класса. Если он называется PhrasesTest.java, - все ошибки, которые он обнаруживает, скорее всего находятся в Phrases.java. Если он называется GreetingsTest.java - … ну, вы понимаете.

Моя точка зрения заключается в том, что имя тестового класса - это не просто имя. Это инструкция для поискающего программиста: “Посмотрите в исходный файл, имя которого можно получить из моего имени, удалив суффикс Test”. Если я попытаюсь следовать этой инструкции и она не приведет меня ни к чему, я очень разочаруюсь, особенно если проект не мой. Я не могу получить требуемую информацию ни откуда. Имя тестового класса - моя последняя надежда.

Что, если тестовый класс становится слишком длинным? Он может содержать несколько десятков или более тестовых методов. Нам не хочется, чтобы класс был слишком большим, верно? Неправда! Тестовый класс - это не класс. Это даже не утилитарный класс. Это контейнер для тестовых сценариев. Он называется классом только потому, что Java (и многие другие языки) не имеют альтернативных инструментов организации кода. Поэтому не беспокойтесь о том, что ваши тестовые классы становятся чрезмерно длинными. 5000 строк кода в тестовом классе - совсем не проблема. Еще раз, потому что это не класс, а только набор тестовых сценариев.

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

Здесь, в закрытом методе prepare(), у меня есть удобный конструктор объекта класса Phrases. Этот конструктор может быть полезен для других тестов, таких как GreetingsTest. Я не хочу копировать его из PhrasesTest в GreetingsTest. Вместо этого я хочу поместить его в место, где его можно будет повторно использовать. Это будет правильное место для него (foo - это пакет Java, к которому относятся все наши классы).

Статический метод FooUtils.prepare() теперь находится в утилитарном классе FooUtils (ужасное анти-паттерн!), который находится в пакете foo.support. Но обратите внимание, не в пакете foo, а в подпакете, которого нет в живом коде: здесь нет директории src/main/java/foo/support. Это ясное сообщение программисту, который встретит этот репозиторий через несколько лет: все классы, которые находятся в foo.support, принадлежат только тестовому конвейеру и сами по себе не являются тестами.

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

Затем, тест будет выглядеть таким образом:

Теперь тест и его предварительные требования находятся в двух разных местах и не так тесно связаны, как раньше. Более того, предварительные требования могут быть легко повторно использованы. Волшебная аннотация @ExtendWith может быть применена к другим тестам. Реализация PhrasesExtension может стать умнее: она может начать обращать внимание не только на тип аргумента метода теста, но и на пользовательскую аннотацию, примененную к нему (так работает @TempDir).

Несмотря на красоту расширений JUnit, я не считаю их лучшим способом разделения требований к тестовым методам. Расширения JUnit все еще довольно связаны… не с тестовыми методами, а с полным набором тестов проекта. Если вы решите использовать их в другом месте, в другом проекте, вы не сможете сделать это.

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

Решение заключается в использовании фиктивных объектов. Они находятся вместе с другими живыми объектами, но имеют особое “фиктивное” поведение, например (кстати, я не люблю фабрики, но в этом случае это нормально):

Затем тест будет выглядеть так:

Расположение репозитория будет выглядеть следующим образом:

Обратите внимание на тест FactoryOfPhrasesTest. Он тестирует “фальшивый” объект FactoryOfPhrases, который является частью коллекции живых классов. Фабрика фраз поставляется вместе с остальными классами. Поэтому ее можно использовать другими проектами, а не только для тестовых целей.

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

В мире Maven существуют классы модульных тестов (с суффиксом “Test”) и классы интеграционных тестов (с суффиксом “ITCase”). Разница между ними огромна. Оба компилируются в фазе “test-compile” с помощью плагина “maven-compiler-plugin”, но они не выполняются вместе. Вместо этого модульные тесты выполняются на фазе “test”. Сборка завершается с ошибкой немедленно, если хотя бы один модульный тест не проходит. Это довольно простой подход, который похож на другие средства автоматизации сборки.

Интеграционные тесты выполняются в четыре шага (это названия фаз Maven):

Сначала ресурсы, необходимые для интеграционного тестирования, получаются на этапе pre-integration-test. Например, может быть запущен тестовый экземпляр базы данных MySQL. Затем тесты с помощью ITCase выполняются на этапе «integration-test». Результат их выполнения игнорируется, но записывается в файл. Затем ресурсы освобождаются на этапе post-integration-test. Например, сервер MySQL останавливается. Наконец, на этапе verify результаты тестов проверяются, и сборка завершается с ошибкой, если некоторые из них не пройдены успешно.

Я храню файлы ITCase вместе с файлами Test только тогда, когда они являются интеграционными тестами для конкретных рабочих классов. Очень часто это не так - вот почему они и называются интеграционными тестами. Они могут интегрировать и тестировать несколько классов вместе. В этом случае я помещаю их в отдельный пакет и даю им произвольные имена, которые не совпадают с именами рабочих классов.

Здесь GreetingsITCase.java - это интеграционный тест для Greetings.java, в то время как SimpleGuessingITCase.java - это интеграционный тест без привязки к конкретному классу. Очевидно, что пакет foo.it существует только в тестах и отсутствует в src/main/java.

Таким образом, есть первое правило: класс теста может иметь только методы с аннотацией @Test (в случае Java).

Затем есть второе правило: пакет с тестами может содержать только классы с суффиксами Test или ITCase, которые однозначно соответствуют живым классам и ничему другому.

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-11-22 at 10:26

sixnines availability badge   GitHub stars