Класс 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.
Добрый день! А где поиск по материалам сайта?
добавлен блок поиска в правый верхний угол — не знаю, насколько эффективно будет.