Тестирование является одним из ключевых этапов разработки. Вне зависимости от того, над каким проектом вы работаете, тестирование — это залог того, что вы сможете поставлять качественные приложения и радовать пользователей. Только представьте себе, что случится, если ваше любимое приложение по заказу такси поведет водителя не в то место, или стоимость поездки окажется выше заявленной? Эта постыдная ошибка приведет к серии катастрофических событий, которые нанесут вред репутации приложения, и, что еще хуже, повредят имиджу самой компании.
Если вы вращаетесь в ИТ-сфере, то, должно быть, уже слышали об известном термине «разработка через тестирование (TDD)». Данный подход изменяет способ тестирования кода. Только вспомните, сколько времени занимает разработка бизнес-логики приложения и написание тестов для всех возможных сценариев. Разве вам не кажется, что для этого необходимы глубокие познания о самой системе? Разумеется. Да и риск сломать другую часть приложения также крайне велик.
TDD можно свести к 3 простым этапам:
Давайте начнем с первого шага. Какой же тест лучше написать?
Плохой/хороший полицейский
Приступая к разработке какой-либо опции, вы изначально знаете ее назначение. К примеру, вы создаете приложение для хранения статей. Затем добавляете базовые функции (Добавить, Обновить, Читать, Удалить). Давайте сначала пропишем опцию «Добавить».
Ее цель предельно проста — она сохраняет новую статью в базе данных. Поэтому прописываем ее как-то так:
@Test
public void shouldSaveNewArticleAndReturnGeneratedId() throws ArticleProcessingException {
Article articleToSave = mock();
Article savedArticle = mockSaved();
when(repository.save(articleToSave)).thenReturn(savedArticle);
Article saved = service.save(articleToSave);
assertThat(saved.getId(), is(DEFAULT_ID));
}
Трудно читается, да? Не волнуйтесь, этот фрагмент кода идеально подходит для объяснения техник и концепций тестирования.
Опять начинаем с самого начала: тестируем название метода.
Имя тестового метода играет важную роль. Всегда помните, что код интерпретируется машинами, но пишется и читается людьми. Название должно объяснять, что именно мы тестируем и в каком сценарии. Несколько примеров соглашения по именованию:
- ShouldThrowExceptionWhenAgeLessThan18
- WhenAgeLessThan18ExpectIsAdultAsFalse
- testIsNotAnAdultIfAgeLessThan18
Искусство двойников и заглушек
При написании модульного теста вы задаетесь целью изолированно проверить только какой-то определенный фрагмент кода. В нашем приложении заложено множество взаимосвязанных слоев. Поэтому при проверке одного слоя потребуется смоделировать внешние зависимости.
Mock-объект — это объект, имитирующий другой объект. Двойник. Предположим, у нас есть класс ArticlesRepository и ArticleService. ArticleRepository — это зависимость ArticleService, поскольку последний отправляет данные на хранение в слой репозитория. Мы же хотим протестировать только ArticleService. Поэтому создаем mock-объект репозитория.
Для этой цели обращаемся к известной библиотеке Mockito. В ней действительно легко создавать mock-объекты и имитировать необходимые зависимости.
@Mock
private ArticleRepository repository;
Да, мы указываем только аннотацию mock-объекта. Затем инициируем экземпляр ArticleRepository… но не настоящий. При вызове любого метода данного класса мы попадаем в NullPointerException, ведь этих элементов не существует. Нам нужно смоделировать данное поведение. И в этом нам поможет еще одна мощная опция Mockito — Mockito.when().
Очень простая для человеческого восприятия. В строке 5 мы говорим: «Когда вызывается мой метод save с данным параметром, тогда возвращаются эти данные». Какой параметр мы отправляем? В Mockito есть вспомогательные методы для имитации anyString(), anyDouble() или другого типа с приставкой any().
Уже прониклись Mockito? Я тоже. Почитать о библиотеке можно здесь: https://site.mockito.org/
Заглушки — это еще один метод имитации объектов. Со stub-объектами мы не зависим от внешних библиотек и создаем реализацию существующего объекта без копирования его реального поведения. Например, мы знаем, что ArticleRepository поглощает объект Article и возвращает то же объект, но со сгенерированным ID. Поэтому мы просто прописываем его вот так:
public class ArticleRepositoryStub implements ArticleRepository {
// Without the real repository logic
public save(Article article){
article.setId(1);
return article;
}
}
Как вы уже заметили, мы определяем все поведение вручную. Заглушки чрезвычайно удобны для моделирования конкретных методов класса без настройки цикла when-then в Mockito.
Теперь момент истины. Вызываем метод. По сути, мы выполняем простой вызов метода, поэтому не будем расписывать детали.
Последнее, но не менее важное: проверка утверждений.
Самое время проверить, работает ли наш фрагмент кода так, как нужно. В данном случае мы заготовили встроенную статью для моделирования mock-ответа. По сути, нам нужно проверить, что возвращаемый ID совпадает с уже заданным. Здесь мы воспользуемся небольшими помощниками: Mockito (опять) и Hamcrest. Hamcrest — это библиотека с хорошими синтаксическими методами для сравнения чего-то с текущим результатом.
Подробнее о Hamcrest можно почитать здесь: https://www.baeldung.com/hamcrest-core-matchers
Дельный совет: старайтесь прописывать по одному утверждению на тест.
Теперь попробуем запустить тест… и увидим, что ничего не работает. Ну, конечно, — не хватает решения метода! Выше мы лишь определили поведение метода и возвращаемые значения при отсутствие ошибок. Это хороший полицейский.
Плохой полицейский
Не бывает счастья без несчастья. То же касается и разработки приложений. Могут ли методы выбрасывать ошибки? Разумеется. И наша цель — быть готовыми к этим ошибкам. Мы должны знать, к какому типу исключений или неожиданного поведения нужно подготовиться.
Что случится, если отправить статью длиннее, чем это поддерживается базой данных? А если попытаться сохранить существующую статью дважды? Что должно вернуть приложение, если мы попытаемся обновить несуществующую статью? Существует великое множество сценариев, к которым следует быть готовым. К счастью, и такие сценарии можно протестировать.
@Test(expected = NoExistingArticleException.class)
public void shouldThrowNoExistingArticleExceptionWhenArticleDoesNotExists() throws NoExistingArticleException {
Article noExistingArticle = mock();
when(repository.findById(DEFAULT_ID)).thenReturn(null);
service.update(noExistingArticle);
}
Все предельно ясно. Мы просто ждем, что каждый раз при вызове метода с ошибочным параметром или неправильным типом результата, будет выбрасываться исключение. Теперь мы уверены, что задали нужный тип исключения, и все ошибки обрабатываются без возвращения неожиданных результатов.
Неужели это все, что нам нужно знать о тестировании?
Вы освоили базовые шаги в тестировании. Эти примеры мы делали в Java, однако данные техники и методики применимы к любому языку программирования. В данной статье мы занимались модульным тестированием. Но существуют и другие разновидности. Тестирование приложений можно разделить на 3 слоя:
Выводы
Тестирование — необходимо. Включите его в стандартный процесс разработки. Когда пишете тесты, не думайте как разработчик. Поставьте себя на место пользователя, который ничего не знает о происходящем в системе. Вы знаете только, что если написать 2 + 2, то получится 4. Ну а вдруг пользователь решит указать 2,01 + 1,99? Надо быть готовым и к такому.
Перевод статьи Diego Arguelles: Testing for no Testers