Автор статьи: Рустем Галиев

IBM Senior DevOps Engineer & Integration Architect. Официальный DevOps ментор и коуч в IBM

Привет, Хабр! Сегодня мы посмотрим на то, как тестировать Spring Boot через MockMVC.

MockMvc – это тестовый фреймворк на стороне сервера, который позволяет проверять большинство функциональных возможностей приложения Spring MVC с помощью облегченных и целевых тестов

Прежде чем мы изучим механизм написания тестов с помощью MockMVC, нам нужно понять, почему вы хотите это сделать. Берем код простого книжного приложения. Давайте начнем с просмотра этого кода.

BookController.java

package books;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/books")
public class BookController {

	private final BookService bookService;

	public BookController(BookService bookService) {
    	this.bookService = bookService;
	}

	@GetMapping
	public List<Book> findAll() {
    	return bookService.findAll();
	}

	@GetMapping("/{id}")
	public Book findOne(@PathVariable int id) {
    	return bookService.findOne(id);
	}

}

Класс помечен @RestController, что означает, что это класс, который будет принимать запросы и возвращать ответы:

Следующий код BookService.java

package books;

import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;

@Service
public class BookService {

	private List<Book> books = new ArrayList<>();

	public List<Book> findAll() {
    	return books;
	}

	public Book findOne(int id) {
    	return books.stream().filter(book -> book.getId() == id).findFirst().orElseThrow(BookNotFoundException::new);
	}

	/**
 	* This method will be called once after the bean was initialized and add some seed data to the books list.
 	*/
	@PostConstruct
	private void loadBooks() {
    	Book one = new Book(1,
            	"97 Things Every Java Programmer Should Know",
            	"Kevlin Henney, Trisha Gee",
            	"OReilly Media, Inc.",
            	"May 2020",
            	"9781491952696",
            	"Java");
    	Book two = new Book(2,
            	"Spring Boot: Up and Running",
            	"Mark Heckler",
            	"OReilly Media, Inc.",
            	"February 2021",
            	"9781492076919",
            	"Spring");
    	Book three = new Book(3,
            	"Hacking with Spring Boot 2.3: Reactive Edition",
            	"Greg L. Turnquist",
            	"Amazon.com Services LLC",
            	"May 2020",
            	"B086722L4L",
            	"Spring");

    	books.addAll(Arrays.asList(one,two,three));
	}

}

Экземпляр класса BookService автоматически подключается Spring с помощью внедрения конструктора. Файл создает три книги и сохраняет их в списке. Метод findAll() вернет все книги в коллекции, а метод findOne вернет одну книгу или выдаст исключение, если она не найдена.

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

mvn spring-boot:run

Мы смогли запустить приложение и вручную протестировать каждую из конечных точек. Но что, если конечных точек больше двух? А если их пятьсот? Ручное тестирование каждого из них потребует много времени и чревато ошибками. Мы также хотим, чтобы ваши тесты выполнялись автоматически в процессе CI/CD, чтобы мы могли убедиться, что приложение работает должным образом, прежде чем развертывать его в другой среде.

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

Создадим файл BookControllerTest.java

package books;

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// web-mvc-test
class BookControllerTest {

	// mock-mvc
	// mock-bean
	// test-find-all
	// test-find-one

	private List<Book> getBooks() {
    	Book one = new Book(1,
            	"97 Things Every Java Programmer Should Know",
            	"Kevlin Henney, Trisha Gee",
            	"OReilly Media, Inc.",
            	"May 2020",
            	"9781491952696",
            	"Java");
    	Book two = new Book(2,
            	"Spring Boot: Up and Running",
            	"Mark Heckler",
            	"OReilly Media, Inc.",
            	"February 2021",
            	"9781492076919",
            	"Spring");
    	return List.of(one, two);
	}

}

Затем мы добавляем следующую аннотацию над объявлением класса:

@WebMvcTest(BookController.class)

Аннотацию WebMvcTest можно использовать для теста Spring MVC, ориентированного только на компоненты Spring MVC. Использование этой аннотации отключит полную автоконфигурацию и вместо этого применит только конфигурацию, относящуюся к тестам MVC (например, @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer и HandlerMethodArgumentResolver bean-компоненты, но не @Component, @Service или @Repository бобы).

В этом случае мы сообщаем Spring, что единственный bean-компонент, который следует использовать в этом тесте, — это класс BookController. Это может не иметь большого значения в нашем небольшом примере приложения, но по мере того, как наши приложения растут, и у вас есть сотни или тысячи bean-компонентов, управляемых Spring, мы не хотим, чтобы они все загружались в ApplicationContext только для запуска этого единственного теста.

По умолчанию тесты, аннотированные @WebMvcTest, также автоматически настраивают Spring Security и MockMVC. Это означает, что просто используя эту аннотацию, мы получим доступ к экземпляру MockMVC.

Инфраструктура Spring MVC Test, также известная как MockMVC, обеспечивает поддержку тестирования приложений Spring MVC. Он выполняет полную обработку запросов Spring MVC, но через фиктивные объекты запросов и ответов вместо работающего сервера.

На предыдущем шаге вы добавили аннотацию @WebMvcTest в класс BookControllerTest. При этом Spring автоматически настроит MockMVC для нас, и мы сможем получить экземпляр, добавив следующий код:

@Autowired
    MockMvc mvc;

Когда мы пишем юнит тесты, вы хотите сосредоточиться на одной функциональной единице. В этом случае вы хотите протестировать класс BookController. Если вы помните наш обзор приложения, конструктор BookController отвечает за подключение BookService. Мы можем использовать аннотацию Mockito @MockBean, чтобы добавить макеты в контекст приложения Spring. Затем в наших отдельных тестах мы можем имитировать поведение методов BookService:

@MockBean
    BookService bookService;

Теперь, когда у нас есть вся инфраструктура, мы можем приступить к написанию тестов нашего контроллера. Первое, что мы напишем, — это тестирование метода BookController.findAll(). Мы добавляем следующий код в ваш тестовый класс:

@Test
    void findAllShouldReturnAllBooks() throws Exception {
        Mockito.when(this.bookService.findAll()).thenReturn(getBooks());

        mvc.perform(get("/books"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(2));
    }

Метод Mockito.when() имитирует поведение метода findAll() BookService. Когда этот метод встречается в контроллере, он будет использовать ваше фиктивное поведение для возврата списка книг.

Метод execute исходит от MockMvc и выполняет запрос и возвращает тип, который позволяет связывать дальнейшие действия, такие как утверждение ожиданий, с результатом. Метод andExpect подтверждает ожидаемый результат. В этом примере мы проверяем успешный ответ, содержащий ожидаемое количество книг.

Если приложение уже запущено с предыдущего шага, вам нужно будет остановить его из командной строки. Затем запустите приложение с помощью следующей команды:

mvn spring-boot:run

Запустим тест

mvn -Dtest=BookControllerTest#findAllShouldReturnAllBooks test

Далее нам нужно протестировать функциональность получения одной книги. Мы будем использовать MockMVC для отправки запроса GET к /books/1, который является действительной книгой и должен вернуть первую книгу в коллекции. Оттуда мы проверяем значения в книге:

@Test
    void findOneShouldReturnValidBook() throws Exception {
        Mockito.when(this.bookService.findOne(1)).thenReturn(getBooks().get(0));

        mvc.perform(get("/books/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title").value("97 Things Every Java Programmer Should Know"))
                .andExpect(jsonPath("$.author").value("Kevlin Henney, Trisha Gee"))
                .andExpect(jsonPath("$.publisher").value("OReilly Media, Inc."))
                .andExpect(jsonPath("$.releaseDate").value("May 2020"))
                .andExpect(jsonPath("$.isbn").value("9781491952696"))
                .andExpect(jsonPath("$.topic").value("Java"));
    }

Запустим тест

mvn -Dtest=BookControllerTest#findOneShouldReturnValidBook test

Мы выполнили команду для запуска одного теста. Если вы хотите запустить все тесты, вы можете сделать это, выполнив следующую команду:

mvn test

Полный код:

package books;

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(BookController.class)
class BookControllerTest {

	@Autowired
	MockMvc mvc;

	@MockBean
	BookService bookService;

	@Test
	void findAllShouldReturnAllBooks() throws Exception {
    	Mockito.when(this.bookService.findAll()).thenReturn(getBooks());

    	mvc.perform(get("/books"))
            	.andExpect(status().isOk())
            	.andExpect(jsonPath("$.length()").value(2));
	}

	@Test
	void findOneShouldReturnValidBook() throws Exception {
    	Mockito.when(this.bookService.findOne(1)).thenReturn(getBooks().get(0));

    	mvc.perform(get("/books/1"))
            	.andExpect(status().isOk())
            	.andExpect(jsonPath("$.id").value(1))
            	.andExpect(jsonPath("$.title").value("97 Things Every Java Programmer Should Know"))
            	.andExpect(jsonPath("$.author").value("Kevlin Henney, Trisha Gee"))
            	.andExpect(jsonPath("$.publisher").value("OReilly Media, Inc."))
            	.andExpect(jsonPath("$.releaseDate").value("May 2020"))
            	.andExpect(jsonPath("$.isbn").value("9781491952696"))
            	.andExpect(jsonPath("$.topic").value("Java"));
	}


	private List<Book> getBooks() {
    	Book one = new Book(1,
            	"97 Things Every Java Programmer Should Know",
            	"Kevlin Henney, Trisha Gee",
            	"OReilly Media, Inc.",
            	"May 2020",
            	"9781491952696",
            	"Java");
    	Book two = new Book(2,
            	"Spring Boot: Up and Running",
            	"Mark Heckler",
            	"OReilly Media, Inc.",
            	"February 2021",
            	"9781492076919",
            	"Spring");
    	return List.of(one, two);
	}

}

В заключение приглашаем всех желающих на открытое занятие «Введение в облака, создание кластера в Mongo DB Atlas». На нем поговорим, какие бывают облака и настроим бесплатный Mongo DB кластер для своих проектов. Записаться на урок можно на странице курса «Разработчик на Spring Framework».

Комментарии (7)


  1. marks
    07.07.2023 15:52

    Пост с претензиями к частоте публикаций вы скрыли. Оставлю здесь. Ребята, хватит постить абы что 5 раз в день. Это что за стратегия такая с таким-то качеством контента?


    1. MaxRokatansky Автор
      07.07.2023 15:52

      Здравствуйте, Максим. Мы действительно публикуем много разного контента и по разным направлениям. Среди прочего публикуем не только хардкорные статьи, но и статьи для новичков, указывая соответствующий уровень сложности статьи. Мы нигде не заявляли о том, что в статье про iptables рассказываем о чем-то "новом и светлом", как писал комментатор, развязавший негатив под статьей, но при этом согласны, что много софта завязано на эти команды, поэтому новичку материал может быть полезен.

      Более того, увидев комментарии, мы также обнаружили в личке аккаунта сообщение от человека написавшего первые негативные комментарии под статьей, где он предлагает связаться в телеграмм для того, чтоб проконсультировать нас о том, как сделать лучше. Зайдя в профиль не сложно догадаться, что человек занимается консалтингом и таким образом продает свои услуги. Не дождавшись ответа он пошел в комментарии и получилось то, что получилось.


      1. dlinyj
        07.07.2023 15:52

        Это был я, не скрываю этого. Не следует путать помощь в улучшении качества блога, и просто профессиональный взгляд на статьи.


        Меня смущает, когда идёт генерация статей по 4-8 в день, при чём сомнительного содержания (ощущение, что всё писал ChatGPT, и он вполне сносно генерирует контент по темам, что вы освещаете). Нормальный размер статьи от 10 тысяч символов, у вас это число сильно меньше. Если в этой статье удалить блоки кода, то 5 тысяч символов. Если учесть, что обычно только вступление пишу 1000-2000 символов (до ката), то "статья" тянет на заметку. И кто целевая аудитория этой статьи?


        1. MaxRokatansky Автор
          07.07.2023 15:52
          +1

          1. Мы не используем ChatGPT при написании статей.

          2. Нормальный размер статьи - это понятие довольно относительное. Есть много практических материалов, которые на 90% состоят из кода.

          3. Ваши статьи действительно интересны, но это совсем не означает, что такой формат является неким шаблоном стандарта, к которому должны стремиться все.

          4. Вы хотели продать свои услуги, но не дождавшись ответа устроили холливар под предыдущей статьей, которую нам пришлось просто скрыть. Довольно необычные методы продажи своих услуг...


          1. dlinyj
            07.07.2023 15:52

            Вы хотели продать свои услуги, но не дождавшись ответа устроили холливар под предыдущей статьей, которую нам пришлось просто скрыть. Довольно необычные методы продажи своих услуг...

            Бизнесмен из меня так себе, это правда :).


            На меня не стоит равняться, потому что у меня несколько иной подход, который пришёл ещё со времён бумажной прессы.


            Моя мысль в другом, что брать количеством, а не качеством — смысла нет, так как просто снижается количество просмотров (у всех, при чём), и охват аудитории. Если вы стремитесь делать себе рекламу, то лучше сделать одну-две хороших качественных статьи в неделю, вложить силы и средства в них, чем восемь в день.


  1. marks
    07.07.2023 15:52

    А теперь вы себе еще и наплюсовали посты с сотнями (не тысячами) просмотров, причем сделали это в одно время. Наверное, время дать жалобу в администрацию. Ребят, думайте головой, что вы делаете и как это выглядит.


    1. MaxRokatansky Автор
      07.07.2023 15:52

      Довольно серьезное заявление, но уверяю вас, мы не используем никаких схем накрутки рейтинга, просмотров и т.д. У нас нет фабрики по производству фейковых аккаунтов для плюсования или чего-то подобного. Ну а если коллеги лайкают посты друг друга, правилами это вроде не запрещено.