UnitTesting

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

Если вы вращаетесь в ИТ-сфере, то, должно быть, уже слышали об известном термине «разработка через тестирование (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 слоя:

1. Сквозное тестирование (E2E): тестирование всего приложения через комплексное пользовательское взаимодействие с интерфейсом и сравнение ответов приложения. 2. Интеграционное тестирование: проверяется, как функционал каждого слоя приложения работает вместе, сообща. 3. Модульное тестирование: проверяется только одна функциональная возможность, изолированно от среды.

Выводы

Тестирование — необходимо. Включите его в стандартный процесс разработки. Когда пишете тесты, не думайте как разработчик. Поставьте себя на место пользователя, который ничего не знает о происходящем в системе. Вы знаете только, что если написать 2 + 2, то получится 4. Ну а вдруг пользователь решит указать 2,01 + 1,99? Надо быть готовым и к такому.

Перевод статьи Diego Arguelles: Testing for no Testers