Статьи

JUnit в двух словах: утверждение модульного теста

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

Утверждение модульного теста

Доверяй, но проверяй
Рональд Рейган

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

Как показано в Hello World , проверка основана на типе ошибки AssertionError . Это основа для написания так называемых тестов самоконтроля . Утверждение модульного теста оценивает предикаты как true или false . В случае false AssertionError . Среда выполнения JUnit фиксирует эту ошибку и сообщает о том, что тест не пройден.

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

Утверждай

Встроенный механизм утверждений JUnit обеспечивается классом org.junit.Assert . Он предлагает несколько статических методов для облегчения проверки теста. В следующем фрагменте описывается использование доступных шаблонов методов:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
fail();
fail( "Houston, We've Got a Problem." );
 
assertNull( actual );
assertNull( "Identifier must not be null.",
            actual );
 
assertTrue( counter.hasNext() );
assertTrue( "Counter should have a successor.",
            counter.hasNext() );
 
assertEquals( LOWER_BOUND, actual );
assertEquals( "Number should be lower bound value.",
              LOWER_BOUND,
              actual );
  1. Assert#fail() генерирует ошибку подтверждения безоговорочно. Это может быть полезно для отметки незавершенного теста или для гарантии того, что ожидаемое исключение было выдано (см. Также раздел «Ожидаемые исключения» в структуре теста ).
  2. Assert#assertXXX(Object) используется для проверки состояния инициализации переменной. Для этого существует два метода: assertNull(Object) и assertNotNull(Object) .
  3. Assert#assertXXX(boolean) проверяют ожидаемые условия, переданные логическим параметром. Вызов assertTrue(boolean) ожидает, что условие будет true , тогда как assertFalse(boolean) ожидает обратного.
  4. Assert#assertXXX(Object,Object) и Assert#assertXXX(value,value) используются для проверки сравнений значений, объектов и массивов. Хотя результат не имеет значения, обычной практикой является передача ожидаемого значения в качестве первого параметра и фактического значения в качестве второго.

Все эти типы методов предоставляют перегруженную версию, которая принимает параметр String . В случае сбоя этот аргумент включается в сообщение об ошибке утверждения. Многие считают это полезным для более четкого определения причины сбоя. Другие воспринимают такие сообщения как беспорядок, затрудняя чтение тестов.

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

Hamcrest

Hamcrest — это библиотека, целью которой является предоставление API для создания гибких выражений намерений . Утилита предлагает вложенные предикаты, называемые Matcher s. Это позволяет писать сложные условия проверки таким образом, что многие разработчики считают более легким для чтения, чем выражения логических операторов.

Утверждение MatcherAssert теста поддерживается классом MatcherAssert . Для этого он предлагает статический assertThat(T, Matcher ). Первый передаваемый аргумент — это значение или объект для проверки. Второй — это предикат, используемый для оценки первого.

1
assertThat( actual, equalTo( IN_RANGE_NUMBER ) );

Как вы можете видеть, подход с использованием совпадений имитирует поток естественного языка для улучшения читабельности. Намерение становится еще более понятным из следующего фрагмента. Это использует метод is(Matcher ) для украшения фактического выражения.

1
assertThat( actual, is( equalTo( IN_RANGE_NUMBER ) ) );

MatcherAssert.assertThat(...) существует с еще двумя сигнатурами. Во-первых, есть вариант, который принимает логический параметр вместо аргумента Matcher . Его поведение соответствует Assert.assertTrue(boolean) .

Второй вариант передает дополнительную String в метод. Это может быть использовано для улучшения выразительности сообщений об ошибках:

1
2
3
assertThat( "Actual number must not be equals to lower bound value.",
             actual,
             is( not( equalTo( LOWER_BOUND ) ) ) );

В случае сбоя сообщение об ошибке для данной проверки будет выглядеть примерно так:

Hamcrest-недостаточность

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

По этой причине библиотека позволяет писать собственные соответствия.

Давайте вернемся к примеру учебника для обсуждения этой темы. Сначала мы скорректируем сценарий, чтобы он был более разумным для этой главы. Предположим, что NumberRangeCounter.next() возвращает тип RangeNumber вместо простого значения int :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
public class RangeNumber {
   
  private final String rangeIdentifier;
  private final int value;
 
  RangeNumber( String rangeIdentifier, int value  ) {
    this.rangeIdentifier = rangeIdentifier;
    this.value = value;
  }
   
  public String getRangeIdentifier() {
    return rangeIdentifier;
  }
   
  public int getValue() {
    return value;
  }
}

Мы могли бы использовать пользовательское сопоставление, чтобы проверить, что возвращаемое значение NumberRangeCounter#next() находится в пределах определенного диапазона счетчика:

1
2
3
RangeNumber actual = counter.next();
 
assertThat( actual, is( inRangeOf( LOWER_BOUND, RANGE ) ) );

Соответствующий пользовательский сопоставитель может расширять абстрактный класс TypeSafeMatcher<T> . Этот базовый класс обрабатывает null проверки и безопасность типов. Возможная реализация показана ниже. Обратите внимание, как он добавляет фабричный метод inRangeOf(int,int) для удобного использования:

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
28
29
30
31
32
33
34
public class InRangeMatcher extends TypeSafeMatcher<RangeNumber> {
 
  private final int lowerBound;
  private final int upperBound;
 
  InRangeMatcher( int lowerBound, int range ) {
    this.lowerBound = lowerBound;
    this.upperBound = lowerBound + range;
  }
   
  @Override
  public void describeTo( Description description ) {
    String text = format( "between <%s> and <%s>.", lowerBound, upperBound );
    description.appendText( text );
  }
   
  @Override
  protected void describeMismatchSafely(
    RangeNumber item, Description description )
  {
    description.appendText( "was " ).appendValue( item.getValue() );
  }
 
 
  @Override
  protected boolean matchesSafely( RangeNumber toMatch ) {
    return    lowerBound <= toMatch.getValue()
           && upperBound > toMatch.getValue();
  }
   
  public static Matcher<RangeNumber> inRangeOf( int lowerBound, int range ) {
    return new InRangeMatcher( lowerBound, range );
  }
}

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

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

Hamcrest обычай безотказная

Легко понять, каким образом реализация describeTo и describeMismatchSafely влияет на сообщение об ошибке. Он выражает, что ожидаемое значение должно быть между указанной нижней границей и (рассчитанной) верхней границей 1 и сопровождается фактическим значением.

Немного прискорбно, что JUnit расширяет API своего класса Assert для предоставления набора методов assertThat (…). Эти методы фактически дублируют API, предоставляемый MatcherAssert . Фактически реализация этих методов делегируется соответствующим методам этого типа.

Хотя это может показаться незначительной проблемой, я думаю, что стоит упомянуть. Благодаря такому подходу JUnit прочно привязан к библиотеке Hamcrest. Эта зависимость время от времени приводит к проблемам. В частности, при использовании с другими библиотеками, что еще хуже, благодаря включению копии их собственной версии Hamcrest …

Юнит тестовое утверждение а-ля Hamcrest не без конкурса. В то время как дискуссия об одном утверждении на тест против единой концепции на тест [MAR] выходит за рамки этого поста, сторонники последнего мнения могут воспринимать заявления о проверке библиотеки как слишком шумные. Особенно, когда концепции требуется более одного утверждения.

Вот почему я должен добавить еще один раздел в эту главу!

AssertJ

В посте Test Runners один из примеров фрагментов использует два оператора assertXXX . Они подтверждают, что ожидаемое исключение является экземпляром IllegalArgumentException и предоставляет определенное сообщение об ошибке. Отрывок выглядит примерно так:

1
2
3
4
Throwable actual = ...
 
assertTrue( actual instanceof IllegalArgumentException );
assertEquals( EXPECTED_ERROR_MESSAGE, actual.getMessage() );

В предыдущем разделе рассказывалось, как улучшить код с помощью Hamcrest. Но если вы новичок в библиотеке, вы можете спросить, какое выражение использовать. Или печатать может быть немного неудобно. Во всяком случае, множественные операторы assertThat добавят assertThat .

Библиотека AssertJ стремится улучшить это, предоставляя беглые утверждения для Java . Цель API интерфейса Fluent состоит в том, чтобы обеспечить легкий для чтения, выразительный стиль программирования, который уменьшает количество клея и упрощает ввод текста.

Так как же использовать этот подход для рефакторинга кода выше?

1
import static org.assertj.core.api.Assertions.assertThat;

Подобно другим подходам AssertJ предоставляет служебный класс, который предлагает набор статических методов assertThat . Но эти методы возвращают конкретную реализацию утверждения для данного типа параметра. Это отправная точка для так называемой цепочки операторов .

1
2
3
4
5
Throwable actual = ...
 
assertThat( actual )
  .isInstanceOf( IllegalArgumentException.class )
  .hasMessage( EXPECTED_ERROR_MESSAGE );

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

Таким образом, вы хотите предоставить выразительные сообщения о сбоях в загробном мире? Одна из возможностей — использовать describedAs как первую ссылку в цепочке, чтобы прокомментировать весь блок:

1
2
3
4
5
6
Throwable actual = ...
 
assertThat( actual )
  .describedAs( "Expected exception does not match specification." )
  .hasMessage( EXPECTED_ERROR_MESSAGE )
  .isInstanceOf( NullPointerException.class );

Фрагмент ожидает NPE, но предположим, что IAE выбрасывается во время выполнения. Тогда неудачный тестовый прогон выдаст следующее сообщение:

assertj-недостаточность

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

1
2
3
4
5
6
7
Throwable actual = ...
 
assertThat( actual )
  .describedAs( "Message does not match specification." )
  .hasMessage( EXPECTED_ERROR_MESSAGE )
  .describedAs( "Exception type does not match specification." )
  .isInstanceOf( NullPointerException.class );

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

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
28
29
30
31
32
33
34
35
36
37
38
39
40
public class RangeCounterAssertion
  extends AbstractAssert<RangeCounterAssertion, RangeCounter>
{
 
  private static final String ERR_IN_RANGE_OF
    = "Expected value to be between <%s> and <%s>, but was <%s>";
  private static final String ERR_RANGE_ID
    = "Expected range identifier to be <%s>, but was <%s>";
   
  public static RangeCounterAssertion assertThat( RangeCounter actual ) {
    return new RangeCounterAssertion( actual );
  }
   
  public InRangeAssertion hasRangeIdentifier( String expected ) {
    isNotNull();
    if( !actual.getRangeIdentifier().equals( expected ) ) {
      failWithMessage( ERR_RANGE_ID, expected, actual.getRangeIdentifier()  );
    }
    return this;
  }
   
  public RangeCounterAssertion isInRangeOf( int lowerBound, int range ) {
    isNotNull();
    int upperBound = lowerBound + range;
    if( !isInInterval( lowerBound, upperBound ) ) {
      int actualValue = actual.getValue();
      failWithMessage( ERR_IN_RANGE_OF, lowerBound, upperBound, actualValue );
    }
    return this;
  }
 
  private boolean isInInterval( int lowerBound, int upperBound ) {
    return actual.getValue() >= lowerBound
        && actual.getValue() < upperBound;
  }
 
  private RangeCounterAssertion( Integer actual ) {
    super( actual, RangeCounterAssertion.class );
  }
}

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

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

Пользовательское утверждение включено его фабричным методом assertThat(RangeNumber) . Поскольку он наследует доступные базовые проверки, утверждение может проверить довольно сложные спецификации из коробки.

1
2
3
4
5
6
7
RangeNumber first = ...
RangeNumber second = ...
 
assertThat( first )
  .isInRangeOf( LOWER_BOUND, RANGE )
  .hasRangeIdentifier( EXPECTED_RANGE_ID )
  .isNotSameAs( second );

Для полноты вот как выглядит RangNumberAssertion в действии:

assertj обычай безотказная

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

Такое расширение может использоваться для предоставления статических методов assertThat качестве точки входа для всех пользовательских утверждений проекта. При использовании этого пользовательского служебного класса по всему проекту не может возникнуть конфликтов импорта. Подробное описание можно найти в разделе « Предоставление единой точки входа для всех утверждений: ваша + AssertJ» из онлайн-документации о пользовательских утверждениях .

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

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

Вывод

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

Хотя механизм, основанный на Assert безусловно, несколько устарел и менее объектно-ориентирован, он все же имеет своих сторонников. Сопоставители Hamcrest обеспечивают четкое разделение утверждений и определений предикатов, тогда как утверждения AssertJ оцениваются с помощью компактного и простого в использовании стиля программирования. Так что теперь вы избалованы выбором …

Обратите внимание, что это будет последняя глава моего руководства по основам тестирования JUnit. Что не значит, что больше нечего сказать. Наоборот! Но это выходит за рамки этого мини-сериала. И вы знаете, что они говорят: всегда оставляйте их, желая большего …

  1. хм, мне интересно, будут ли границы интервала более понятными, чем нижняя граница и диапазон…
Ссылка: JUnit в двух словах: утверждение модульного теста от нашего партнера JCG Фрэнка Аппеля в блоге Code Affine .