Статьи

JUnit в двух словах: тестовая структура

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

Возможно, практический подход, использованный в этой мини-серии, может быть уместным, чтобы один или два дополнительных разработчика заинтересовались модульным тестированием, что стоило бы усилий.

В прошлый раз я представил основы теста — как он написан, выполнен и оценен. При этом я подчеркнул, что тест — это больше, чем простая машина проверки, и он может служить своего рода низкоуровневой спецификацией. Поэтому его следует разрабатывать с максимально возможными стандартами кодирования, которые только можно придумать.

Этот пост будет продолжен с примера учебного пособия и разработает общую структуру, которая характеризует хорошо написанные модульные тесты, используя номенклатуру, определенную Meszaros в тестовых шаблонах xUnit [MES].

Четыре этапа теста


Ухоженный дом, ухоженный ум
Старая поговорка

Пример учебника посвящен написанию простого счетчика диапазонов чисел, который выдает определенное количество последовательных целых чисел, начиная с заданного значения. Начиная с « счастливого пути», результатом последнего поста стал тест, который подтвердил, что NumberRangeCounter возвращает последовательные числа при последующих NumberRangeCounter метода:

1
2
3
4
5
6
7
8
9
@Test
  public void subsequentNumber() {   
    NumberRangeCounter counter = new NumberRangeCounter();
 
    int first = counter.next();
    int second = counter.next();
 
    assertEquals( first + 1, second );
  }

Обратите внимание, что для проверки в этой главе я использую встроенную функциональность JUnit. Я расскажу о плюсах и минусах конкретных библиотек соответствия ( Hamcrest , AssertJ ) в отдельном посте.

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

  1. Первый создает экземпляр тестируемого объекта, который называется SUT (тестируемая система). В целом, этот раздел устанавливает состояние проверяемого объекта перед любыми действиями, связанными с тестированием. Поскольку это состояние представляет собой четко определенный тестовый ввод, оно также обозначается как тестовое задание.
  2. После того, как прибор был установлен, пора вызвать те методы SUT, которые представляют определенное поведение, которое тест намеревается проверить. Часто это всего лишь один метод, и результат сохраняется в локальных переменных.
  3. Последний раздел теста отвечает за проверку того, был ли получен ожидаемый результат данного поведения. Хотя существует школа, пропагандирующая политику « один утверждают на тест» , я предпочитаю идею « один концепт на тест» , а это означает, что этот раздел не ограничен только одним утверждением, как это происходит в примере. [MAR1].

    Эта тестовая структура очень распространена и была описана различными авторами. Он был помечен как организовать, действовать, утверждать [KAC] — или строить, работать, проверять [MAR2] — шаблон. Но в этом уроке мне нравится быть точным и придерживаться четырех этапов Месароса [MES], которые называются setup (1), упражнение (2), verify ( 3) и teardown (4) .

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

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

Из-за объема этого поста я избегаю точного определения юнит-теста. Но я придерживаюсь трех типов тестов разработчиков, которые Томек Качановский описывает в практическом модульном тестировании с помощью JUnit и Mockito, и его можно обобщить следующим образом:

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

Но прежде чем мы продолжим с примером, остается один вопрос для обсуждения:

Почему это важно?


Соотношение времени, потраченного на чтение (код) к написанию, превышает 10 к 1…
Роберт С. Мартин, Чистый код

Цель шаблона четырех фаз состоит в том, чтобы упростить понимание поведения проверяемого теста. Программа установки всегда определяет предварительное условие теста, упражнение фактически вызывает тестируемое поведение, проверка определяет ожидаемый результат, а разборка — это все о ведении домашнего хозяйства , как выразился Месарош.

Такое чистое разделение фаз четко указывает на намерение провести одно испытание и повышает читабельность. Подход подразумевает, что тест проверяет только одно поведение для данного входного состояния за раз и поэтому обычно обходится без условных блоков и т. П. (Тест с одним условием).

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

Но теперь пришло время продолжить пример и посмотреть, что эти новые знания могут сделать для нас!

Угловые тесты

Как только мы закончим с тестами счастливого пути, мы продолжим, указав поведение в угловом случае . В описании счетчика диапазона номеров указано, что последовательность чисел должна начинаться с заданного значения. Что важно, так как определяет нижнюю границу (один угол …) диапазона счетчика.

Представляется разумным, что это значение передается в качестве параметра конфигурации конструктору NumberRangeCounter . Соответствующий тест может проверить, что первое число, возвращаемое next , равно этой инициализации:

1
2
3
4
5
6
7
8
@Test
  public void lowerBound() {
    NumberRangeCounter counter = new NumberRangeCounter( 1000 );
 
    int actual = counter.next();
     
    assertEquals( 1000, actual );
  }

Еще раз наш тестовый класс не компилируется. Исправление этого путем введения параметра lowerBound в конструктор счетчика приводит к ошибке компиляции в subsequentNumber тесте Number. К счастью, последний тест был написан так, чтобы быть независимым от определения нижней границы, поэтому этот параметр может быть использован и в этом тесте.

Однако буквенное число в тесте является избыточным и не указывает четко его назначение. Последний обычно обозначается как магическое число . Чтобы улучшить ситуацию, мы могли бы ввести константу LOWER_BOUND и заменить все литеральные значения. Вот как будет выглядеть тестовый класс впоследствии:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class NumberRangeCounterTest {
   
  private static final int LOWER_BOUND = 1000;
 
  @Test
  public void subsequentNumber() {
    NumberRangeCounter counter = new NumberRangeCounter( LOWER_BOUND );
     
    int first = counter.next();
    int second = counter.next();
     
    assertEquals( first + 1, second );
  }
   
  @Test
  public void lowerBound() {
    NumberRangeCounter counter = new NumberRangeCounter( LOWER_BOUND );
 
    int actual = counter.next();
     
    assertEquals( LOWER_BOUND, actual );
  }
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class NumberRangeCounterTest {
   
  private static final int LOWER_BOUND = 1000;
 
  @Test
  public void subsequentNumber() {
    NumberRangeCounter counter = setUp();
     
    int first = counter.next();
    int second = counter.next();
     
    assertEquals( first + 1, second );
  }
   
  @Test
  public void lowerBound() {
    NumberRangeCounter counter = setUp();
 
    int actual = counter.next();
     
    assertEquals( LOWER_BOUND, actual );
  }
   
  private NumberRangeCounter setUp() {
    return new NumberRangeCounter( LOWER_BOUND );
  }
}

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

Что означает, что эта функция имеет свою цену. Если мы хотим исключить избыточные вызовы setUp в тестах, мы должны ввести поле, которое принимает экземпляр нашего NumberRangeCounter :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class NumberRangeCounterTest {
   
  private static final int LOWER_BOUND = 1000;
   
  private NumberRangeCounter counter;
   
  @Before
  public void setUp() {
    counter = new NumberRangeCounter( LOWER_BOUND );
  }
 
  @Test
  public void subsequentNumber() {
    int first = counter.next();
    int second = counter.next();
     
    assertEquals( first + 1, second );
  }
   
  @Test
  public void lowerBound() {
    int actual = counter.next();
     
    assertEquals( LOWER_BOUND, actual );
  }
}

Легко видеть, что неявная установка может удалить много дублирования кода. Но это также привносит некую магию с точки зрения теста, которая может затруднить чтение. Итак, четкий ответ на вопрос «Какой тип установки мне следует использовать?» это: это зависит …

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

Среда выполнения JUnit гарантирует, что каждый тест вызывается для нового экземпляра класса теста. Это означает, что в нашем примере только конструктор может полностью setUp метод setUp . Назначение поля counter со свежим прибором может быть сделано неявно:

1
private NumberRangeCounter counter = new NumberRangeCounter( LOWER_BOUND );

Хотя некоторые люди @Before используют это, другие утверждают, что аннотированный метод @Before делает намерение более явным. Ну, я бы не стал воевать за это и оставил бы решение на ваш личный вкус…

Неявное снос

Представьте себе на мгновение, что NumberRangeCounter необходимо утилизировать по любой причине. Это означает, что мы должны добавить фазу разрыва к нашим тестам. Исходя из нашего последнего фрагмента, это будет легко с JUnit, так как он поддерживает неявное разложение с @After аннотации @After . Нам нужно только добавить следующий метод:

1
2
3
4
@After
  public void tearDown() {
    counter.dispose();
  }

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

Ожидаемые исключения

Особый случай — тестирование ожидаемых исключений. Для примера рассмотрим, что NumberRangeCalculator должен генерировать IllegalStateException если вызов next превышает количество значений для данного диапазона. Опять же, может быть целесообразно настроить диапазон с помощью параметра конструктора. Используя конструкцию try-catch, мы могли бы написать:

01
02
03
04
05
06
07
08
09
10
@Test
  public void exeedsRange() {
    NumberRangeCounter counter = new NumberRangeCounter( LOWER_BOUND, 0 );
 
    try {
      counter.next();
      fail();
    } catch( IllegalStateException expected ) {
    }
  }

Что ж, это выглядит несколько уродливо, поскольку размывает разделение тестовых фаз и не очень читабельно. Но так как Assert.fail() генерирует AssertionError он гарантирует, что тест завершится неудачей, если не будет Assert.fail() исключение. И блок catch обеспечивает успешное завершение теста в случае возникновения ожидаемого исключения.

В Java 8 можно писать чисто структурированные тесты исключений, используя лямбда-выражения. Для получения дополнительной информации, пожалуйста, обратитесь к
Чистые JUnit Throwable-тесты с Java 8 лямбда-выражений .

Если этого достаточно, чтобы убедиться, что возникло исключение определенного типа, JUnit предлагает неявную проверку с помощью expected метода аннотации @Test . Тест выше может быть записан как:

1
2
3
4
@Test( expected = IllegalStateException.class )
  public void exeedsRange() {
    new NumberRangeCounter( LOWER_BOUND, ZERO_RANGE ).next();
  }

Хотя этот подход очень компактен, он также может быть опасным. Это связано с тем, что неясно, было ли выбрано данное исключение во время настройки или фазы теста. Таким образом, тест будет зеленым и, следовательно, бесполезным, если конструктор случайно IllegalStateException .

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class NumberRangeCounterTest {
   
  private static final int LOWER_BOUND = 1000;
 
  @Rule
  public ExpectedException thrown = ExpectedException.none();
 
  @Test
  public void exeedsRange() {
    thrown.expect( IllegalStateException.class );
    
    new NumberRangeCounter( LOWER_BOUND, 0 ).next();
  }
 
  [...]
}

Однако, если вы не хотите ждать, вы можете взглянуть на подробные объяснения Рафаля Боровца в его посте. « Правильное ожидание исключений: за пределами основ»

Вывод

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

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

использованная литература

  • [MES] Тестовые таблицы xUnit, глава 19, Четырехфазный тест, Джерард Месарос, 2007
  • [MAR1] Чистый код, глава 9: модульные тесты, стр. 130 и далее, Роберт С. Мартин, 2009
  • [KAC] Практическое модульное тестирование с использованием JUnit и Mockito, 3.9. Фазы юнит-теста, Томек Качановский, 2013
  • [MAR2] Чистый код, глава 9: модульные тесты, стр. 127, Роберт С. Мартин, 2009
Ссылка: JUnit в двух словах: тестовая структура от нашего партнера JCG Фрэнка Аппеля в блоге Code Affine .