Spring по верхам: тестирование REST сервиса

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

Видов тестирования, кстати, достаточно много: ручное тестирование, модульные тесты, интеграционные тесты, E2E-тесты, нагрузочное тестирование, пентестинг (тестирование на наличие проблем в безопасности) и т.д. В больших и важных проектах много тестов не бывает в принципе, но даже в маленьких проектах я рекомендую писать хотя бы модульные и интеграционные тесты — первыми вы проверите корректность работы каждого отдельного компонента, а вторыми — системы в целом.

Идеальным же решением будет ведение разработки через тестирование (Test driven development, TDD), когда в первую очередь пишутся тесты, а затем уже тестируемый код, но это уже тема для других статей и роликов, а в этой статье я буду писать тесты для написанного в предыдущей статье REST-сервиса.

Настройка проекта

Для модульного и интеграционного тестирования мне понадобится некоторое количество зависимостей, таких как JUnit, Mockito, MockMVC и т.д., всё это я могу получить при помощи библиотеки spring-boot-starter-test, которую добавлю в зависимости проекта.

Теперь можно переходить к написанию модульных тестов.

Модульные тесты

Демонстрировать процесс написания модульных тестов я буду на примере тестов для TasksRestController, поэтому я создам в директории src/test/java класс TasksRestControllerTest. Тестируемый класс-контроллер имеет две зависимости: TaskRepository и MessageSource, и вместо того, чтобы использовать какие-то реальные реализации этих интерфейсов или создавать их тестовые реализации, я буду создавать mock-объекты этих интерфейсов при помощи Mockito. Я могу это сделать при помощи метода Mockito.mock(), но в данном случае предпочту доверить всё тестовым феймворкам.

Чтобы Mockito сам создал необходимые mock-объекты, необходимо добавить расширение Mockito к классу модульных тестов при помощи аннотации @ExtendsWith(MockitoExtension.class), а так же отметить аннотацией @Mock члены тестового класса, для которых требуется создать mock-объекты. Чтобы Mockito создал экземпляр тестируемого класса и передал mock-объекты в качестве аргументов его конструктора, соответствующий член тестового класса должен быть отмечен аннотацией @InjectMocks.

Тест для handleGetAllTasks

Теперь можно перейти непосредственно к написанию тестовых методов. Первым тестом я буду тестировать handleGetAllTasks. Названия тестовых методов в целом могут быть любыми, однако существует практика составления названий тестовых методов из 2-3, а то и более частей, среди которых могут быть, например: название тестируемого метода, условия выполнения теста, если внутри метода есть ветвления, и ожидаемое поведение. В данном случае название тестового метода будет состоять из двух частей: handleGetAllTasks_ReturnsValidResponsEntity, условия выполнения опущены, так как тестируемый метод простой, и в нём отсутствуют ветвления.

Если вас пугают такие названия, и вы хотите в логах видеть что-то более человекочитаемое, то на помощь вам придёт аннотация @DisplayName, значением которой может быть любой более понятный вам текст.

Код внутри тестового метода можно условно разделить на три части: «дано» (given) — блок с исходным состоянием системы, «когда» (when) — блок с обращением к тестируемому коду и «тогда» (then) — блок с проверкой полученного результата. В тестовых фреймворках, ориентированных на разработку на основе поведения (Behaviour driven development, BDD), таких, как Spock или Cucumber, эти блоки выражены более явно в виде методов. В обычных тестах я разделяю код комментариями с названиями блоков кода, впрочем, они не обязательны, хотя и упрощают чтение кода тестов, особенно в крупных проектах.

При тестировании handleGetAllTasks для исходного состояния потребуется список из нескольких экземпляров класса Task. При этом их количество не имеет значения, хотя использовать пустой список я бы не стал. Этот список TasksRestController получает из TaskRepository при помощи метода findAll, поведение которого нужно смоделировать для созданного mock-объекта. Способов моделирования поведения mock-объектов достаточно много, и полное их описание тянет на отдельный материал. Конкретно в этом случае я буду использовать метод Mock.doReturn, который просто указывает, что должно быть возвращено при вызове метода mock-объекта.

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

  1. Полученный экземпляр ResponseEntity не null
  2. HTTP-код ответа — 200 OK
  3. HTTP-заголовок Content-Type присутствует в ответе и равен application/json
  4. Что в body полученного ResponseEntity находится возвращённый репозиторием список задач

Все эти проверки я могу делать при помощи assert или if/else throw, однако JUnit предоставляет набор удобных методов для различных проверок в классе Assertions, их я и буду использовать.

На этом написание модульного теста для handleGetAllTasks завершено. Да, кто-то обратит внимание на то, что объём кода модульного теста ощутимо превышает объём кода тестируемого метода, но это нормально. В реальных проектах объём кода тестов может быть в 3-5 раз больше тестируемого кода, а иногда и того больше.

Тесты для handleCreateNewTask

А вот для тестирования метода handleCreateNewTask потребуется уже два тестовых метода: первый будет проверять корректность поведения при валидном содержимом запроса, а второй — при невалидном.

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

В целом этот тестовый метод не сильно отличается от предыдущего, но на паре моментов я бы заострил внимание. Если в тестируемом методе происходит обращение к каким-то важным методам других объектов, например, к методу save репозитория задач, то эти обращения нужно дополнительно валидировать, что в данном случае сделано при помощи Mockito.verify. Да, в первом тестовом методе я не валидировал вызов findAll, но на это было две причины:

  1. findAll — метод чтения данных, его вызов не изменяет состояния системы
  2. Было бы выброшено исключение org.opentest4j.AssertionFailedError в случае, если бы findAll не был вызван в тестируемом коде

Кроме этого желательно проверить отсутствие каких-либо обращений кроме тех, что вы ожидали, а это можно сделать при помощи Mockito.verifyNoMoreInteractions.

Второй тестовый метод handleCreateNewTask_PayloadIsInvalid_ReturnsValidResponseEntity в целом ничего нового не содержит за исключением проверки того, что к репозиторию вообще не было обращений, которая реализована при помощи метода Mockito.noInteractions.

Интеграционные тесты

Для интеграционных тестов я создал отдельный класс TasksRestControllerIT, где IT — аббревиатура от Integration Tests. В отличие от модульных тестов, для интеграционных тестов потребуется работающий контекст приложения, что достигается добавлением аннотации @SpringBootTest к тестовому классу.

Для имитации HTTP-запросов потребуется экземпляр MockMvc, его можно сконфигурировать вручную, но Spring Boot предоставляет возможность сконфигурировать его автоматически при помощи аннотации @AutoConfigureMockMvc. Сконфигурированный экземпляр MockMvc можно внедрить в тестовый класс при помощи аннотации @Autowired.

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

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

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

Тест для handleGetAllTasks

Интеграционный тест для handleGetAllTasks будет один. В первую очередь нужно определить список задач, который будет находиться в репозитории. После этого можно будет описать HTTP-запрос и обратиться к сервису при помощи MockMvc.perform. А после этого можно проверить результат при помощи методов andDoandExpect и andExpectAll, а так же получить экземпляр MvcResult при помощи метода andReturn.

Методы andExpect и andExpectAll принимают экземпляры ResultMatcher. Класс MockMvcResultMatchers содержит большое количество методов для проверки запросов и ответов. В этом тесте я проверил:

  1. Код HTTP-состояния при помощи метода status()
  2. Значение заголовка Content-Type при помощи content().contentType()
  3. Содержимое ответа при помощи content().json()

Тесты для handleCreateNewTask

Интеграционные тесты для handleCreateNewTask в целом аналогичны тесту для handleGetAllTasks, разве что я добавил проверку изменений в репозитории.

Несмотря на то, что интеграционные тесты кажутся всеобъемлющими, они всё же не тестируют систему на все сто процентов. Для более глубокого тестирования лучше обратиться к сквозным тестам (или E2E-тестам).

Полезные ссылки