Тестирование контроллеров с помощью MockMvc

Класс 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 тут.

Тестирование контроллеров с помощью MockMvc: 4 комментария

  1. Где тест с 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.

    1. добавлен блок поиска в правый верхний угол — не знаю, насколько эффективно будет.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *