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 из них провалились, но десять из них прошли успешно.

Assertions

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

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

import org.junit.jupiter.api.Test;

@Test void test1() { Phrases p = new Phrases(); p.add("Hello, world!"); p.add("London is a capital of Great Britain"); assert(p.greetings().count() == 1); } ```

Хотя этот тест намного лучше благодаря утверждениям [Hamcrest](https://www.hamcrest.org) (как называть методы тестов - это отдельная история, подробно объясненная [здесь](https://stackoverflow.com/questions/155436/)).

```java
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

@Test void countsSimpleGreetings() { Phrases p = new Phrases(); p.add("Hello, world!"); p.add("London is a capital of Great Britain"); assertThat( "Total count of greetings", p.greetings().count(), equalTo(1) ); } ```

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

## Test Classes

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

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

## Very Long Test Classes

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

## Test Prerequisites (Wrong Way)

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

class PhrasesTest { @Test void countsSimpleGreetings() { Phrases p = new Phrases(); prepare(p); assertThat( “Total count of greetings”, p.greetings().count(), equalTo(1) ); } private static void prepare(Phrases p) { p.add(“Hello, world!”); p.add(“London is a capital of Great Britain”); } }


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

src/ main/ java/ foo/ Phrases.java Greetings.java test/ java/ foo/ support/ FooUtils.java PhrasesTest.java GreetingsTest.java


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

## Test Prerequisites (Right Way)

Как вы знаете, утилитарные классы и приватные статические методы являются элементами императивного программирования. В объектно-ориентированном мире есть более эффективные альтернативы. В частности, JUnit5 предлагает довольно элегантные механизмы для создания предварительных условий тестирования: [расширения тестов](https://junit.org/junit5/docs/current/user-guide/#extensions-registration). Все, что нужно для выполнения тестового метода, мы предоставляем через его параметры, которые создаются расширениями, например:

```java
import org.junit.jupiter.api.extension.*;

class PhrasesExtension implements ParameterResolver { @Override public boolean supportsParameter( ParameterContext pctx, ExtensionContext ectx) { return pctx.getParameter().getType() == Phrases.class; } @Override public Object resolveParameter( ParameterContext pctx, ExtensionContext ectx) { Phrases p = new Phrases(); p.add("Hello, world!"); p.add("London is a capital of Great Britain"); return p; } } ```

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

```java
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(PhrasesExtension.class) class PhrasesTest { @Test void countsSimpleGreetings(Phrases p) { assertThat( "Total count of greetings", p.greetings().count(), equalTo(1) ); } } ```

Теперь тест и его предварительные требования находятся в двух разных местах и не так тесно связаны, как раньше. Более того, предварительные требования могут быть легко повторно использованы. Магическая аннотация `@ExtendWith` может быть применена к другим тестам. Реализация `PhrasesExtension` может стать умнее: она может начать обращать внимание не только на тип аргумента метода теста, но и на пользовательскую аннотацию, присоединенную к нему (так работает [`@TempDir`](https://junit.org/junit5/docs/5.4.1/api/org/junit/jupiter/api/io/TempDir.html)).

## Fake Objects (Best Way)

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

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

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

class FactoryOfPhrases { public Phrases aboutLondon() { Phrases p = new Phrases(); p.add(“Hello, world!”); p.add(“London is a capital of Great Britain”); return p; } }


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

class PhrasesTest { @Test void countsSimpleGreetings() { assertThat( “Total count of greetings”, new FactoryOfPhrases().aboutLondon() .greetings().count(), equalTo(1) ); } }


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

src/ main/ java/ foo/ FactoryOfPhrases.java Phrases.java Greetings.java test/ java/ foo/ FactoryOfPhrasesTest.java PhrasesTest.java GreetingsTest.java


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

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

## Integration Tests

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

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

pre-integration-test integration-test post-integration-test verify


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

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

src/ main/ java/ foo/ Phrases.java Greetings.java test/ java/ foo/ it/ SimpleGuessingITCase.java PhrasesTest.java GreetingsTest.java GreetingsITCase.java ```

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


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

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

Translated by ChatGPT gpt-3.5-turbo/35 on 2023-09-20 at 21:57

sixnines availability badge   GitHub stars