Тестирование на примере
Опубликовано: 2022-02-15Мы продолжаем нашу серию блогов обо всем, что связано с тестированием. В этом блоге мы сосредоточимся на реальных примерах.
Хотя примеры в этом посте написаны с использованием JUnit 5 и AssertJ, уроки применимы к любой другой среде модульного тестирования.
JUnit — самая популярная среда тестирования для Java. AssertJ — это библиотека Java, которая помогает разработчикам писать более выразительные тесты.
Базовая структура теста
Первый пример теста, который мы рассмотрим, — это простой калькулятор для сложения 2 чисел.
класс CalculatorShould { @Тест // 1 недействительная сумма () { Калькулятор калькулятор = новый калькулятор(); // 2 целочисленный результат = калькулятор.сумма(1, 2); // 3 утверждать, что (результат). isEqualTo (3); // 4 } }
Я предпочитаю использовать соглашение об именовании ClassShould
при написании тестов, чтобы избежать повторения should
или test
в каждом имени метода. Вы можете прочитать больше об этом здесь.
Что делает тест выше?
Разобьем тест построчно:
- Аннотация
@Test
позволяет платформе JUnit узнать, какие методы предназначены для запуска в качестве тестов. Совершенно нормально иметьprivate
методы в тестовом классе, которые не являются тестами. - Это этап подготовки нашего теста, когда мы готовим среду тестирования. Все, что нам нужно для этого теста, — это экземпляр
Calculator
. - Это фаза действия , когда мы запускаем поведение, которое хотим протестировать.
- Это фаза утверждения , на которой мы проверяем, что произошло, и все ли разрешилось так, как ожидалось.
assertThat(result)
является частью библиотеки AssertJ и имеет несколько перегрузок.
Каждая перегрузка возвращает специализированный объект Assert
. Возвращенный объект имеет методы, которые имеют смысл для объекта, который мы передали методу assertThat
. В нашем случае это объект AbstractIntegerAssert
с методами проверки целых чисел. isEqualTo(3)
проверит, равен ли result == 3
. Если это так, тест будет пройден и не пройден в противном случае.
В этом посте мы не будем фокусироваться на каких-либо реализациях.
Другой способ думать о Arrange , Act , Assert is Given , When , Then .
После того, как мы напишем нашу реализацию sum
, мы можем задать себе несколько вопросов:
- Как я могу улучшить этот тест?
- Есть ли еще тестовые случаи, которые я должен охватить?
- Что произойдет, если я добавлю положительное и отрицательное число? Два отрицательных числа? Один положительный и один отрицательный?
- Что, если я переполню целочисленное значение?
Давайте добавим эти случаи и немного улучшим существующее название теста.
Мы не допустим переполнения в нашей реализации. Если sum
переполняется, вместо этого мы выбрасываем ArithmeticException
.
класс CalculatorShould { частный калькулятор калькулятор = новый калькулятор(); @Тест недействительным sumPositiveNumbers () { целая сумма = калькулятор.сумма(1, 2); утверждать, что (сумма). isEqualTo (3); } @Тест недействительная суммаNegativeNumbers () { целая сумма = калькулятор.сумма (-1, -1); утверждать, что (сумма). isEqualTo (-2); } @Тест недействительным sumPositiveAndNegativeNumbers () { целая сумма = калькулятор.сумма (1, -2); утверждать, что (сумма). isEqualTo (-1); } @Тест недействительным failWithArithmeticExceptionWhenOverflown () { assertThatThrownBy(() -> calculate.sum(Integer.MAX_VALUE, 1)) .isInstanceOf(ArithmeticException.class); } }
JUnit создаст новый экземпляр CalculatorShould
перед запуском каждого метода @Test
. Это означает, что у каждого CalculatorShould
будет свой calculator
, поэтому нам не нужно использовать его в каждом тесте.
В тесте shouldFailWithArithmeticExceptionWhenOverflown
используется другой тип assert
. Он проверяет, что часть кода не удалась. assertThatThrownBy
запустит предоставленную нами лямбду и убедится, что она не удалась. Как мы уже знаем, все методы assertThat
возвращают специализированный Assert
, позволяющий нам проверить, какой тип исключения произошел.
Это пример того, как мы можем проверить, что наш код дает сбой, когда мы этого ожидаем. Если в какой-то момент мы рефакторим Calculator
и он не вызовет исключение ArithmeticException
при переполнении, наш тест завершится ошибкой.
Шаблон проектирования ObjectMother
Следующий пример — это класс валидатора для проверки правильности экземпляра Person.
класс PersonValidatorShould { частный валидатор PersonValidator = new PersonValidator(); @Тест недействительными failWhenNameIsNull () { Человек человек = новый человек (нуль, 20, новый адрес (...), ...); assertThatThrownBy (() -> validator.validate (человек)) .isInstanceOf(InvalidPersonException.class); } @Тест недействительными failWhenAgeIsNegative () { Человек человек = новый человек("Джон", -5, новый адрес(...), ...); assertThatThrownBy (() -> validator.validate (человек)) .isInstanceOf(InvalidPersonException.class); } }
Шаблон проектирования ObjectMother часто используется в тестах, которые создают сложные объекты, чтобы скрыть детали создания экземпляров от теста. Несколько тестов могут даже создавать один и тот же объект, но проверять на нем разные вещи.
Тест №1 очень похож на тест №2. Мы можем реорганизовать PersonValidatorShould
, извлекая проверку как частный метод, а затем передавая ему недопустимые экземпляры Person
, ожидая, что все они потерпят неудачу одинаковым образом.
класс PersonValidatorShould { частный валидатор PersonValidator = new PersonValidator(); @Тест недействительными failWhenNameIsNull () { shouldFailValidation(PersonObjectMother.createPersonWithoutName()); } @Тест недействительными failWhenAgeIsNegative () { shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge()); } private void shouldFailValidation(Person invalidPerson) { assertThatThrownBy (() -> validator.validate (invalidPerson)) .isInstanceOf(InvalidPersonException.class); } }
Проверка случайности
Как мы должны проверять случайность в нашем коде?
Предположим, у нас есть PersonGenerator
с generateRandom
для генерации случайных экземпляров Person
.
Начнем с того, что напишем следующее:
класс PersonGeneratorShould { частный генератор PersonGenerator = новый PersonGenerator(); @Тест пустота generateValidPerson() { Человек человек = генератор.генерировать случайным образом (); утверждать, что (человек). } }
И тогда мы должны спросить себя:
- Что я пытаюсь здесь доказать? Что должен делать этот функционал?
- Должен ли я просто проверить, что сгенерированный человек не является нулевым экземпляром?
- Нужно ли доказывать, что это случайно?
- Должен ли сгенерированный экземпляр следовать некоторым бизнес-правилам?
Мы можем упростить наш тест, используя Dependency Injection.
открытый интерфейс RandomGenerator { Строка generateRandomString(); int generateRandomInteger(); }
У PersonGenerator
теперь есть еще один конструктор, который также принимает экземпляр этого интерфейса. По умолчанию используется реализация JavaRandomGenerator
, которая генерирует случайные значения с помощью java.Random
.
Однако в тесте мы можем написать другую, более предсказуемую реализацию.
@Тест пустота generateValidPerson() { RandomGenerator randomGenerator = new PredictableGenerator("Джон Доу", 20); Генератор PersonGenerator = новый PersonGenerator (randomGenerator); Человек человек = генератор.генерировать случайным образом (); assertThat(person).isEqualTo(new Person("Джон Доу", 20)); }
Этот тест доказывает, что PersonGenerator
генерирует случайные экземпляры, как указано RandomGenerator
, не вдаваясь в подробности RandomGenerator
.
Тестирование JavaRandomGenerator
на самом деле не добавляет никакой ценности, поскольку это простая оболочка вокруг java.Random
. Тестируя его, вы, по сути, тестируете java.Random
из стандартной библиотеки Java. Написание очевидных тестов приведет только к дополнительному обслуживанию с небольшой пользой.
Чтобы избежать написания реализаций для целей тестирования, таких как PredictableGenerator
, вы должны использовать фиктивную библиотеку, такую как Mockito.
Когда мы писали PredictableGenerator
, мы фактически заглушили класс RandomGenerator
вручную. Вы также могли бы заглушить его с помощью Mockito:
@Тест пустота generateValidPerson() { RandomGenerator randomGenerator = макет (RandomGenerator.class); когда(randomGenerator.generateRandomString()).thenReturn("Джон Доу"); когда(randomGenerator.generateRandomInteger()).thenReturn(20); Генератор PersonGenerator = новый PersonGenerator (randomGenerator); Человек человек = генератор.генерировать случайным образом (); assertThat(person).isEqualTo(new Person("Джон Доу", 20)); }
Этот способ написания тестов более выразительный и требует меньшего количества реализаций для конкретных тестов.
Mockito — это библиотека Java для написания макетов и заглушек. Это очень полезно при тестировании кода, который зависит от внешних библиотек, которые вы не можете легко создать. Это позволяет вам писать поведение для этих классов, не реализуя их напрямую.

Mockito также позволяет использовать другой синтаксис для создания и внедрения макетов, чтобы уменьшить шаблон, когда у нас есть более одного теста, похожего на то, к чему мы привыкли:
@ExtendWith(MockitoExtension.класс) // 1 класс PersonGeneratorShould { @Мок // 2 Генератор случайных чиселГенератор случайных чисел; @InjectMocks // 3 частный генератор PersonGenerator; @Тест пустота generateValidPerson() { когда(randomGenerator.generateRandomString()).thenReturn("Джон Доу"); когда(randomGenerator.generateRandomInteger()).thenReturn(20); Человек человек = генератор.генерировать случайным образом (); assertThat(person).isEqualTo(new Person("Джон Доу", 20)); } }
1. JUnit 5 может использовать «расширения» для расширения своих возможностей. Эта аннотация позволяет ему распознавать макеты через аннотации и правильно вводить их.
2. Аннотация @Mock
создает имитацию экземпляра поля. Это то же самое, что написать mock(RandomGenerator.class)
в теле нашего тестового метода.
3. Аннотация @InjectMocks
создаст новый экземпляр PersonGenerator
и вставит макеты в экземпляр generator
.
Подробнее о расширениях JUnit 5 см. здесь.
Подробнее об инъекции Mockito см. здесь.
В использовании @InjectMocks
есть одна ловушка. Это может устранить необходимость объявлять экземпляр объекта вручную, но мы теряем безопасность конструктора во время компиляции. Если в какой-то момент кто-то добавит в конструктор еще одну зависимость, мы не получим здесь ошибку времени компиляции. Это может привести к неудачным тестам, которые нелегко обнаружить. Я предпочитаю использовать @BeforeEach
для ручной настройки экземпляра:
@ExtendWith(MockitoExtension.класс) класс PersonGeneratorShould { @Насмехаться Генератор случайных чиселГенератор случайных чисел; частный генератор PersonGenerator; @BeforeEach недействительным setUp () { генератор = новый PersonGenerator (randomGenerator); } @Тест пустота generateValidPerson() { когда(randomGenerator.generateRandomString()).thenReturn("Джон Доу"); когда(randomGenerator.generateRandomInteger()).thenReturn(20); Человек человек = генератор.генерировать случайным образом (); assertThat(person).isEqualTo(new Person("Джон Доу", 20)); } }
Тестирование срочных процессов
Фрагмент кода часто зависит от временных меток, и мы склонны использовать такие методы, как System.currentTimeMillis()
, чтобы получить временную метку текущей эпохи.
Хотя это выглядит нормально, трудно протестировать и доказать, что наш код работает правильно, когда класс принимает решения за нас внутри. Примером такого решения может быть определение текущего дня.
класс IndexerShould { частный индексатор indexer = новый индексатор(); @Тест пустота generateIndexNameForTomorrow () { Строка indexName = indexer.tomorrow("my-index"); // этот тест сработает сегодня, но что насчет завтра? УтвердитьЭто (имя_индекса) .isEqualTo ("мой-индекс.2022-02-02"); } }
Мы должны снова использовать Dependency Injection, чтобы иметь возможность «контролировать», какой сегодня день при создании имени индекса.
В Java есть класс Clock
для обработки подобных вариантов использования. Мы можем передать экземпляр Clock
нашему Indexer
, чтобы контролировать время. Конструктор по умолчанию может использовать Clock.systemUTC()
для обратной совместимости. Теперь мы можем заменить вызовы System.currentTimeMillis()
на clock.millis()
.
Внедрив Clock
, мы можем обеспечить предсказуемое время в наших классах и написать лучшие тесты.
Тестирование методов создания файлов
- Как мы должны тестировать классы, которые записывают свой вывод в файлы?
- Где мы должны хранить эти файлы, чтобы они работали на любой ОС?
- Как мы можем убедиться, что файл еще не существует?
При работе с файлами может быть сложно написать тесты, если мы попытаемся решить эти проблемы самостоятельно, как мы увидим в следующем примере. Тест, который следует, является старым тестом сомнительного качества. Он должен проверить, сериализует ли DogToCsvWriter
и записывает ли собак в файл CSV:
класс DogToCsvWriterShould { частный писатель DogToCsvWriter = new DogToCsvWriter("/tmp/dogs.csv"); @Тест недействительным convertToCsv () { Writer.appendAsCsv(новая собака(Порода.КОРГИ, Окрас.КОРИЧНЕВЫЙ, "Монти")); Writer.appendAsCsv(новая собака(Порода.МАЛЬТИЙСКАЯ, Окрас.БЕЛЫЙ, "Зоя")); Строка csv = Files.readString("/tmp/dogs.csv"); assertThat(csv).isEqualTo("Monty,corgi,brown\nZoe,maltese,white"); } }
Процесс сериализации следует отделить от процесса написания, но давайте сосредоточимся на исправлении теста.
Первая проблема с приведенным выше тестом заключается в том, что он не будет работать в Windows, поскольку пользователи Windows не смогут разрешить путь /tmp/dogs.csv
. Другая проблема заключается в том, что он не будет работать, если файл уже существует, поскольку он не удаляется при выполнении вышеуказанного теста. Он может нормально работать в конвейере CI/CD, но не локально, если выполняется несколько раз.
В JUnit 5 есть аннотация, которую вы можете использовать для получения ссылки на временный каталог, который создается и удаляется фреймворком для вас. Хотя механизм создания и удаления временных файлов варьируется от фреймворка к фреймворку, идеи остаются теми же.
класс DogToCsvWriterShould { @Тест void convertToCsv(@TempDir Путь tempDir) { Путь dogsCsv = tempDir.resolve("dogs.csv"); Писатель DogToCsvWriter = новый DogToCsvWriter (dogsCsv); Writer.appendAsCsv(новая собака(Порода.КОРГИ, Окрас.КОРИЧНЕВЫЙ, "Монти")); Writer.appendAsCsv(новая собака(Порода.МАЛЬТИЙСКАЯ, Окрас.БЕЛЫЙ, "Зоя")); Строка csv = Files.readString(dogsCsv); assertThat(csv).isEqualTo("Monty,corgi,brown\nZoe,maltese,white"); } }
С этим небольшим изменением мы теперь уверены, что приведенный выше тест будет работать в Windows, macOS и Linux, не беспокоясь об абсолютных путях. Он также удалит созданные файлы после теста, поэтому теперь мы можем запускать его несколько раз и каждый раз получать предсказуемые результаты.
Тестирование команд и запросов
В чем разница между командой и запросом?
- Команда : мы инструктируем объект выполнить действие, которое производит эффект, не возвращая значение (недействительные методы)
- Запрос : мы просим объект выполнить действие и вернуть результат или исключение
До сих пор мы тестировали в основном запросы, в которых мы вызывали метод, который возвращал значение или вызывал исключение на этапе действия. Как мы можем протестировать методы void
и посмотреть, правильно ли они взаимодействуют с другими классами? Фреймворки предоставляют другой набор методов для написания таких тестов.
Утверждения, которые мы до сих пор писали для запросов, начинались с assertThat
. При написании командных тестов мы используем другой набор методов, потому что мы больше не проверяем непосредственные результаты методов, как мы это делали с запросами. Мы хотим «проверить» взаимодействие нашего метода с другими частями нашей системы.
@ExtendWith(MockitoExtension.класс) класс FeedMentionServiceShould { @Насмехаться частный репозиторий FeedRepository; @Насмехаться частный эмиттер FeedMentionEventEmitter; частный сервис FeedMentionService; @BeforeEach недействительным setUp () { сервис = новый FeedMentionService (репозиторий, эмиттер); } @Тест недействительным вставитьMentionToFeed () { длинная подача = 1 л; Упоминание = ...; когда (repository.upsertMention (feedId, упоминание)) .thenReturn(UpsertResult.success(feedId, упоминание)); Событие FeedInsertionEvent = новое FeedInsertionEvent(feedId, упоминание); упоминатьService.insertMentionToFeed (событие); проверить (эмиттер).mentionInsertedToFeed (feedId, упоминание); проверитьNoMoreInteractions(эмитент); } }
В этом тесте мы сначала смоделировали наш репозиторий, чтобы он ответил UpsertResult.success
, когда его попросили добавить упоминание в нашу ленту. Здесь нас не интересует тестирование репозитория. Методы репозитория должны быть протестированы в FeedRepositoryShould
. Издеваясь над этим поведением, мы на самом деле не вызывали метод репозитория. Мы просто сказали ему, как реагировать в следующий раз, когда его вызовут.
Затем мы сказали нашему mentionService
упоминаний вставить это упоминание в нашу ленту. Мы знаем, что он должен выдать результат только в том случае, если он успешно вставил упоминание в ленту. Используя метод verify
, мы можем убедиться, что метод, mentionInsertedToFeed
, был вызван с нашим упоминанием и лентой и не был вызван снова с помощью verifyNoMoreInteractions
.
Последние мысли
Написание качественных тестов приходит с опытом, а лучший способ научиться — это делать. Советы, написанные в этом блоге, взяты из практики. Трудно увидеть некоторые подводные камни, если вы никогда с ними не сталкивались, и, надеюсь, эти предложения сделают ваш код более надежным. Надежные тесты повысят вашу уверенность в том, что вы сможете что-то менять, не беспокоясь каждый раз, когда вам нужно развернуть свой код.
Хотите присоединиться к команде Mediatoolkit?
Ознакомьтесь с нашей открытой вакансией Senior Frontend Developer !