Статьи

Семь грехов испытания и как их избежать

В этой статье я буду использовать Java в фрагментах кода, а также JUnit и Mockito .

Эта статья предназначена для предоставления примеров тестового кода, который может быть:

  • трудно читать
  • трудно поддерживать

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

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

1. Общие названия тестов

Вы могли видеть тесты, названные как ниже

1
2
3
4
5
6
@Test
void testTranslator() {
    String word = new Translator().wordFrom(1);
 
    assertThat(word, is("one"));
}

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

Мы можем сделать намного лучше, чем это, и поэтому мы можем видеть ниже:

1
2
3
4
5
6
@Test
void translate_from_number_to_word() {
    String word = new Translator().wordFrom(1);
 
    assertThat(word, is("one"));
}

Как видно из вышесказанного, он лучше объясняет, что на самом деле делает этот тест. Кроме того, если вы называете свой тестовый файл чем-то вроде TranslatorShould вы можете сформировать разумное предложение в своем уме, когда вы комбинируете тестовый файл и отдельное имя теста: Translator should translate from number to word .

2. Мутация в тестовой настройке

Весьма вероятно, что в тестах у вас будет желание создать объекты, используемые в тесте, чтобы быть в определенном состоянии. Есть разные способы сделать это, ниже показан один из таких способов. В этом фрагменте мы решаем, является ли персонаж на самом деле «Люком Скайуокером», основываясь на информации, содержащейся в этом объекте (представьте, что это isLuke() метод isLuke() ):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
@Test
void inform_when_character_is_luke_skywalker() {
    StarWarsTrivia trivia = new StarWarsTrivia();
    Character luke = new Character();
    luke.setName("Luke Skywalker");
    Character vader = new Character();
    vader.setName("Darth Vader");
    luke.setFather(vader);
    luke.setProfession(PROFESSION.JEDI);
 
    boolean isLuke = trivia.isLuke(luke);
 
    assertTrue(isLuke);
}

Вышеприведенное строит объект Character для представления «Люка Скайуокера», после чего происходит мутация значительных пропорций. Он продолжает устанавливать имя, родительский статус и профессию в последующих строках. Это, конечно, игнорирование аналогичной вещи, происходящей с нашим другом «Дартом Вейдером».

Этот уровень мутации отвлекает от того, что происходит в тесте. Если мы вернемся к моему предыдущему предложению на секунду:

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

Однако то, что происходит в приведенном выше тесте, на самом деле состоит из двух этапов:

  • Построить объекты
  • Мутировать их, чтобы быть в определенном состоянии

Это не нужно, и мы можем избежать этого. Можно предположить, что, чтобы избежать мутации, мы можем просто трансплантировать все и выбросить это в конструктор, гарантируя, что мы создаем объекты в данном состоянии, избегая мутации:

01
02
03
04
05
06
07
08
09
10
@Test
void inform_when_character_is_luke_skywalker() {
    StarWarsTrivia trivia = new StarWarsTrivia();
    Character vader = new Character("Darth Vader");
    Character luke = new Character("Luke Skywalker", vader, PROFESSION.JEDI);
 
    boolean isLuke = trivia.isLuke(luke);
 
    assertTrue(isLuke);
}

Как видно из вышесказанного, мы сократили количество строк кода, а также мутацию объектов. Однако в процессе мы потеряли смысл того, что — теперь параметры Character — представляют в тесте. Чтобы метод isLuke() возвращал true, передаваемый объект Character должен иметь следующее:

  • Имя «Люк Скайуокер»
  • У отца по имени Дарт Вейдер
  • Будь джедаем

Однако из теста не ясно, что это так, мы должны были бы проверить внутреннюю часть Character чтобы узнать, для чего эти параметры (или ваша IDE скажет вам).

Мы можем сделать это немного лучше, мы можем использовать шаблон Builder для создания объекта Character в желаемом состоянии, в то же время поддерживая читабельность в тесте:

01
02
03
04
05
06
07
08
09
10
11
12
13
@Test
void inform_when_character_is_luke_skywalker() {
    StarWarsTrivia trivia = new StarWarsTrivia();
    Character luke = CharacterBuilder().aCharacter()
        .withNameOf("Luke Skywalker")
        .sonOf(new Character("Darth Vader"))
        .employedAsA(PROFESSION.JEDI)
        .build();
 
    boolean isLuke = trivia.isLuke(luke);
 
    assertTrue(isLuke);
}

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

3. Утверждение безумия

Во время тестирования вы собираетесь утверждать / проверять, что что-то произошло в вашей системе (обычно находится в конце каждого теста). Это очень важный шаг в тесте, и может быть заманчиво добавить ряд утверждений, например, утверждение значений возвращаемого объекта.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@Test
void successfully_upgrades_user() {
    UserService service = new UserService();
    User someBasicUser = UserBuilder.aUser()
        .withName("Basic Bob")
        .withAge(23)
        .withTypeOf(UserType.BASIC)
        .build();
 
    User upgradedUser = service.upgrade(someBasicUser);
 
    assertThat(upgradedUser.name(), is("Basic Bob"));
    assertThat(upgradedUser.type(), is(UserType.SUPER_USER));
    assertThat(upgradedUser.age(), is(23));
}

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
@Test
void successfully_upgrades_user() {
    UserService service = new UserService();
    User someBasicUser = UserBuilder.aUser()
        .withName("Basic Bob")
        .withAge(23)
        .withTypeOf(UserType.BASIC)
        .build();
 
    User expectedUserAfterUpgrading = UserBuilder.aUser()
        .withName("Basic Bob")
        .withAge(23)
        .withTypeOf(UserType.SUPER_USER)
        .build();
 
 
    User upgradedUser = service.upgrade(someBasicUser);
 
    assertThat(upgradedUser, is(expectedUserAfterUpgrading));
}

Теперь мы сравниваем пользователя, который обновляется, с тем, как мы ожидаем, что объект будет выглядеть после обновления. Для этого вам нужно, чтобы сравниваемый объект ( User ) имел переопределенные equals и hashCode .

4. Магические ценности

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

01
02
03
04
05
06
07
08
09
10
11
12
@Test
void denies_entry_for_someone_who_is_not_old_enough() {
    Person youngPerson = PersonBuilder.aPerson()
        .withAgeOf(17)
        .build();
 
    NightclubService service = new NightclubService(21);
 
    String decision = service.entryDecisionFor(youngPerson);
 
    assertThat(decision, is("No entry. They are not old enough."));
}

Читая выше, у вас может быть несколько вопросов, таких как:

  • что означает 17 ?
  • что означает 21 в конструкторе?

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
private static final int SEVENTEEN_YEARS = 17;
private static final int MINIMUM_AGE_FOR_ENTRY = 21;
private static final String NO_ENTRY_MESSAGE = "No entry. They are not old enough.";
 
@Test
void denies_entry_for_someone_who_is_not_old_enough() {
    Person youngPerson = PersonBuilder.aPerson()
        .withAgeOf(SEVENTEEN_YEARS)
        .build();
 
    NightclubService service = new NightclubService(MINIMUM_AGE_FOR_ENTRY);
 
    String decision = service.entryDecisionFor(youngPerson);
 
    assertThat(decision, is(NO_ENTRY_MESSAGE));
}

Теперь, когда мы смотрим на вышесказанное, мы знаем, что:

  • SEVENTEEN_YEARS — это значение, используемое для обозначения 17 лет, мы не оставили никаких сомнений в уме читателя. Это не секунды или минуты, это годы.
  • MINIMUM_AGE_FOR_ENTRY — это значение, для которого кто-то должен быть допущен в ночной клуб. Читателю даже не нужно заботиться о том, что это значение, просто чтобы понять, что оно означает в контексте теста.
  • NO_ENTRY_MESSAGE — это значение, возвращаемое для обозначения того, что кому-то запрещен вход в ночной клуб. По своей природе строки часто имеют больше шансов быть описательными, однако всегда просматривайте код, чтобы определить области, в которых он может быть улучшен.

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

5. Трудно читать названия тестов

1
2
3
4
@Test
void testingNumberOneAndNumberTwoCanBeAddedTogetherToProduceNumberThree() {
    ...
}

Сколько времени вам понадобилось, чтобы прочитать выше? Было ли это легко прочитать, вы могли бы понять, что здесь тестируется с первого взгляда, или вам нужно было бы проанализировать много символов?

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

1
2
3
4
@Test
void twoNumbersCanBeAdded() {
    ...
}

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

1
2
3
4
@Test
void two_numbers_can_be_added() {
    ...
}

Это вопрос предпочтения, и он должен быть согласован с теми, кто вносит вклад в данную кодовую базу. Использование регистра «змея» (как описано выше) может помочь улучшить читаемость названий тестов, поскольку вы, скорее всего, стремитесь подражать письменному предложению. Поэтому использование случая змеи близко следует физическим пространствам, присутствующим в обычном письменном предложении. Тем не менее, Java не допускает пробелов в именах методов, и это лучшее, что у нас есть, если не использовать что-то вроде Спока.

6. Сеттеры для внедрения зависимостей

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

01
02
03
04
05
06
07
08
09
10
11
@Test
void save_a_product() {
    ProductService service = new ProductService();
    TestableProductRepository repository = mock(TestableProductRepository.class);
    service.setRepository(repository);
    Product newProduct = new Product("some product");
 
    service.addProduct(newProduct);
 
    verify(repository).save(newProduct);
}

Выше используется метод установки, а именно setRepository() , чтобы внедрить макет TestableProductRepository , чтобы мы могли убедиться, что между службой и хранилищем произошло правильное сотрудничество.

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

01
02
03
04
05
06
07
08
09
10
@Test
void save_a_product() {
    TestableProductRepository repository = mock(TestableProductRepository.class);
    ProductService service = new ProductService(repository);
    Product newProduct = new Product("some product");
 
    service.addProduct(newProduct);
 
    verify(repository).save(newProduct);
}

Итак, теперь мы внедрили коллаборатора в конструктор, теперь мы знаем при конструировании, в каком состоянии будет объект. Однако вы можете спросить: «Не потеряли ли мы некоторый контекст в процессе?».

Мы ушли от

1
service.setRepository(repository);

в

1
ProductService service = new ProductService(repository);

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

01
02
03
04
05
06
07
08
09
10
11
12
@Test
void save_a_product() {
    TestableProductRepository repository = mock(TestableProductRepository.class);
    ProductService service = ProductServiceBuilder.aProductService()
                                .withRepository(repository)
                                .build();
    Product newProduct = new Product("some product");
 
    service.addProduct(newProduct);
 
    verify(repository).save(newProduct);
}

Это решение позволило нам избежать мутации ProductService при документировании внедрения соавтора с помощью withRepository() .

7. Неописательные проверки

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

01
02
03
04
05
06
07
08
09
10
11
@Test
void no_error_is_shown_when_user_is_valid() {
    UIComponent component = mock(UIComponent.class);
    User user = mock(User.class);
    when(user.isValid()).thenReturn(true);
    LoginController controller = new LoginController();
 
    controller.attemptLogin(component, user);
 
    verifyZeroInteractions(component);
}

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

Тем не менее, это означает что-то другое в вашем тесте. Как насчет того, чтобы мы попытались сделать это понятнее?

01
02
03
04
05
06
07
08
09
10
11
@Test
void no_error_is_shown_when_user_is_valid() {
    UIComponent component = mock(UIComponent.class);
    User user = mock(User.class);
    when(user.isValid()).thenReturn(true);
    LoginController controller = new LoginController();
 
    controller.attemptLogin(component, user);
 
    verify(component, times(0)).addErrorMessage("Invalid user");
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@Test
void no_error_is_shown_when_user_is_valid() {
    UIComponent component = mock(UIComponent.class);
    User user = mock(User.class);
    when(user.isValid()).thenReturn(true);
    LoginController controller = new LoginController();
 
    controller.attemptLogin(component, user);
 
    verifyNoErrorMessageIsAddedTo(component);
}
 
private void verifyNoErrorMessageIsAddedTo(UIComponent component) {
    verify(component, times(0)).addErrorMessage("Invalid user");
}

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

Заключительные слова

Я надеюсь, вам понравилась эта статья, и в следующий раз, когда вы закончите писать тест, вы проведете один или два шага по рефакторингу. До следующего раза я оставляю вам следующую цитату:

«Программы должны быть написаны для того, чтобы люди могли читать, и только для машин — для выполнения». — Гарольд Абельсон, Структура и интерпретация компьютерных программ

Опубликовано на Java Code Geeks с разрешения Сэма Дэвиса, партнера нашей программы JCG . Смотрите оригинальную статью здесь: Семь грехов и как их избежать

Мнения, высказанные участниками Java Code Geeks, являются их собственными.