Класс MockMvc предназначен для тестирования контроллеров. Он позволяет тестировать контроллеры без запуска http-сервера. То есть при выполнении тестов сетевое соединение не создается.
С MockMvc можно писать как интеграционные тесты, так и unit-тесты. Ниже рассмотрим оба варианта.
Тестировать будем небольшое REST-API, которое уже рассматривалось в другой статье ранее.
Интеграционный тест
В интеграционном тесте мы тестируем все слои приложения. А значит, подойдет аннотация @SpringBootTest, поднимающая контекст целиком.
Тест выглядит так:
@SpringBootTest @AutoConfigureMockMvc public class PersonControllerMockMvcIntegrationTest { @Autowired private ObjectMapper objectMapper; @Autowired private PersonRepository repository; @Autowired private MockMvc mockMvc; @AfterEach public void resetDb() { repository.deleteAll(); } @Test public void givenPerson_whenAdd_thenStatus201andPersonReturned() throws Exception { Person person = new Person("Michail"); mockMvc.perform( post("/persons") .content(objectMapper.writeValueAsString(person)) .contentType(MediaType.APPLICATION_JSON) ) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").isNumber()) .andExpect(jsonPath("$.name").value("Michail")); } //другие тесты private Person createTestPerson(String name) { Person emp = new Person(name); return repository.save(emp); } }
Мы тестируем приложение, в котором всего одна сущность Person и один репозиторий PersonRepository — он тоже внедрен в класс. База данных — H2, так как она не требует настройки, а требуется только Maven-зависимость.
@AutoConfigureMockMvc
Эта аннотация нужна для того, чтобы появилась возможность внедрить в тестовый класс бин MockMvc, о котором и идет речь в статье.
Вспомогательные методы
После каждого теста мы очищаем базу:
@AfterEach public void resetDb() { repository.deleteAll(); }
А в начале некоторых тестов мы добавляем в базу Person, поэтому создание Person вынесли в отдельный метод:
private Person createTestPerson(String name) { Person person = new Person(name); return repository.save(person); }
ObjectMapper
Этот класс преобразовывает объект в JSON-строку. Он нужен, так как мы тестируем REST API, MockMvc самостоятельно это преобразование не делает.
Тест создания Person: путь /persons
В первом тесте, которые показан выше, мы создаем новый Person и отправляем его Post-запросом по адресу /persons, предварительно преобразовав в JSON-строку. Это все делается в первой части:
mockmvc.perform(...)
Во второй части выполняется ряд проверок полученного ответа с помощью команд:
.andExpect(...)
Мы проверяем код ответа и содержимое полученного JSON-объекта — приходит вновь созданный Person.
Тест получения Person по id: путь /persons/{id}
@Test public void givenId_whenGetExistingPerson_thenStatus200andPersonReturned() throws Exception { long id = createTestPerson("Michail").getId(); mockMvc.perform( get("/persons/{id}", id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(id)) .andExpect(jsonPath("$.name").value("Michail")); }
Тут то же самое, только метод get(). Предварительно создаем Person в базе.
Обратите внимание на синтаксис path variables — он такой же, как в контроллерах.
Тест выбрасываемого исключения при попытке получить несуществующий Person
Теперь давайте проверим, что если в базе нет Person с id=1, то при попытке получить его по адресу /person/1 будет выброшено исключение EntityNotFoundException:
public void givenId_whenGetNotExistingPerson_thenStatus404anExceptionThrown() throws Exception { mockMvc.perform( get("/persons/1")) .andExpect(status().isNotFound()) .andExpect(mvcResult -> mvcResult.getResolvedException().getClass().equals(EntityNotFoundException.class)); }
Тест редактирования Person: путь /persons/{id}
@Test public void givePerson_whenUpdate_thenStatus200andUpdatedReturns() throws Exception { long id = createTestPerson("Nick").getId(); mockMvc.perform( put("/persons/{id}", id) .content(objectMapper.writeValueAsString(new Person("Michail"))) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("1")) .andExpect(jsonPath("$.name").value("Michail")); }
Здесь тестируем метод PUT.
В базе предварительно создаем Nick, а потом методом PUT меняем его на Michail. Проверяем, что возвращается новый обновленный Person.
Тест удаления Person: путь /persons/{id}
@Test public void givenPerson_whenDeletePerson_thenStatus200() throws Exception { Person person = createTestPerson("Nick"); mockMvc.perform( delete("/persons/{id}", person.getId())) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(person))); }
Ничего нового, только используется метод delete().
Тест получения списка Person: путь /persons
@Test public void givenPersons_whenGetPersons_thenStatus200() throws Exception { Person p1 = createTestPerson("Jane"); Person p2 =createTestPerson( "Joe"); mockMvc.perform( get("/persons")) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(Arrays.asList(p1, p2)))); ; }
Как видите, содержимое JSON-ответа можно проверять целиком методом json(), а не только проверять отдельные поля методом jsonPath(), как мы делали в предыдущих примерах.
Теперь перейдем к созданию Unit-теста.
Unit-тест
Здесь мы будем тестировать только слой контроллеров.
@WebMvcTest
Для этого аннотируем класс аннотацией @WebMvcTest. Эта аннотация создаст только бин контроллера, а репозиторий создавать не будет.
Бин MockMvc будет создан.
@MockBean
Репозиторий будет не настоящим. Для этого аннотируем:
@MockBean private PersonRepository repository;
и в каждом тестовом методе будем имитировать его поведение, а точнее, прописывать только те методы репозитория, которые вызываются в конкретном тесте при тестировании конкретного метода контроллера.
Например так:
Mockito.when(repository.save(Mockito.any())).thenReturn(person);
Код выше говорит, что при сохранении в репозитории любого объекта репозиторий возвращает конкретный объект person.
Весь класс с методом тестирования добавления Person
Вышеприведенный код мы используем в тесте givenPerson_whenAdd_thenStatus201andPersonReturned().
@WebMvcTest public class PersonControllerMockMvcUnitTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockBean private PersonRepository repository; @Test public void givenPerson_whenAdd_thenStatus201andPersonReturned() throws Exception { Person person = new Person(1l, "Michail"); Mockito.when(repository.save(Mockito.any())).thenReturn(person); mockMvc.perform( post("/persons") .content(objectMapper.writeValueAsString(person)) .contentType(MediaType.APPLICATION_JSON) ) .andExpect(status().isCreated()) .andExpect(content().json(objectMapper.writeValueAsString(person))); } // другие тесты }
Тест не отличается от интеграционного, за исключением того, что поведение репозитория сымитировано. На самом деле работы с базой тут нет. Поэтому из класса удалены вспомогательные методы очистки базы и создания Person, которые были в интеграционном тесте.
Остальные тесты тоже очень похожи на интеграционные, только в каждом тесте сымитированы свои методы репозитория.
Остальные тесты
Приведу их все вместе:
@Test public void givenId_whenGetExistingPerson_thenStatus200andPersonReturned() throws Exception { Person person = new Person(1l, "Michail"); Mockito.when(repository.findById(Mockito.any())).thenReturn(Optional.of(person)); mockMvc.perform( get("/persons/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("1")) .andExpect(jsonPath("$.name").value("Michail")); } @Test public void givenId_whenGetNotExistingPerson_thenStatus404anExceptionThrown() throws Exception { Mockito.when(repository.findById(Mockito.any())). thenReturn(Optional.empty()); mockMvc.perform( get("/persons/1")) .andExpect(status().isNotFound()) .andExpect(mvcResult -> mvcResult.getResolvedException().getClass().equals(EntityNotFoundException.class)); } @Test public void givePerson_whenUpdate_thenStatus200andUpdatedReturns() throws Exception { Person person = new Person(1l, "Michail"); Mockito.when(repository.save(Mockito.any())).thenReturn(person); Mockito.when(repository.findById(Mockito.any())).thenReturn(Optional.of(person)); mockMvc.perform( put("/persons/1") .content(objectMapper.writeValueAsString(new Person("Michail"))) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("1")) .andExpect(jsonPath("$.name").value("Michail")); } @Test public void givenPerson_whenDeletePerson_thenStatus200() throws Exception { Person person = new Person(1l, "Michail"); Mockito.when(repository.findById(Mockito.any())).thenReturn(Optional.of(person)); mockMvc.perform( delete("/persons/1")) .andExpect(status().isOk()); } @Test public void givenPersons_whenGetPersons_thenStatus200() throws Exception { Person p1 = new Person(1l, "Jane"); Person p2 = new Person(1l, "Joe"); Mockito.when(repository.findAll()).thenReturn(Arrays.asList(p1, p2)); mockMvc.perform( get("/persons")) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(Arrays.asList(p1, p2)))); ; }
Как видно выше, в каждом тесте с помощью Mockito.when() мы имитируем нужные методы репозитория нужным образом.
Итоги
Код примера доступен на GitHub.
О тестировании REST API с помощью TestRestTemplate тут. А с помощью REST-assured тут.
не делаете модульные тесты контроллера вообще без спринга?
Где тест с 404, исключение в обоих случаях выбросится в perform, до andExpect() не дойдёт, поэтому нужен класс с @ControllerAdvice, тогда сработают оба варианта в обоих классах, например, такой:
@ControllerAdvice
public class PersonControllerTestExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity handle(EntityNotFoundException ex) {
return new ResponseEntity(
«Not found», HttpStatus.NOT_FOUND);
}
}
По крайней мере, так сейчас, в 2024.
Добрый день! А где поиск по материалам сайта?
добавлен блок поиска в правый верхний угол — не знаю, насколько эффективно будет.