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

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

Всем привет, в прошлый раз мы говорили про юнит тесты с MockMVC, сегодня же напишем функциональные тесты для того же самого кода.

Начнем. Из пререквизитов у нас есть код приложения BookController.java.

package books;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

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);
	}

	@PostMapping
	@ResponseStatus(HttpStatus.CREATED)
	public Book create(@RequestBody Book book) {
    	return bookService.create(book);
	}

}

Давайте сосредоточимся на объявлении класса. Этот класс помечен аннотацией @RestController, что означает, что это класс, который будет принимать запросы и возвращать ответы. Класс сопоставляется с путем /books с помощью аннотации @RequestMapping :

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

Экземпляр класса BookService автоматически подключается Spring с помощью внедрения конструктора. BookService.java :

package books;

import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Arrays;
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);
	}

	public Book create(Book book) {
    	books.add(book);
    	return book;
	}

	/**
 	* 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 создает три книги и сохраняет их в списке. Существует метод findAll(), возвращающий все книги в коллекции, метод findOne(), возвращающий одну книгу, и метод create(), создающий новую книгу и добавляющий ее в коллекцию:

private final BookService bookService;

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

Также у нас есть book.java

package com.oreilly.functionalbooks;

public class Book {

	private int id;
	private String title;
	private String author;
	private String publisher;
	private String releaseDate;
	private String isbn;
	private String topic;

	public Book() {
	}

	public Book(int id, String title, String author, String publisher, String releaseDate, String isbn, String topic) {
    	this.id = id;
    	this.title = title;
    	this.author = author;
    	this.publisher = publisher;
    	this.releaseDate = releaseDate;
    	this.isbn = isbn;
    	this.topic = topic;
	}

	public int getId() {
    	return id;
	}

	public void setId(int id) {
    	this.id = id;
	}

	public String getTitle() {
    	return title;
	}

	public void setTitle(String title) {
    	this.title = title;
	}

	public String getAuthor() {
    	return author;
	}

	public void setAuthor(String author) {
    	this.author = author;
	}

	public String getPublisher() {
    	return publisher;
	}

	public void setPublisher(String publisher) {
    	this.publisher = publisher;
	}

	public String getReleaseDate() {
    	return releaseDate;
	}

	public void setReleaseDate(String releaseDate) {
    	this.releaseDate = releaseDate;
	}

	public String getIsbn() {
    	return isbn;
	}

	public void setIsbn(String isbn) {
    	this.isbn = isbn;
	}

	public String getTopic() {
    	return topic;
	}

	public void setTopic(String topic) {
    	this.topic = topic;
	}

	@Override
	public String toString() {
    	return "Book{" +
            	"id=" + id +
            	", title='" + title + '\'' +
            	", author='" + author + '\'' +
            	'}';
	}

}

модель используется для представления данных книги:

public class Book {

    private int id;
    private String title;
    private String author;
    private String publisher;
    private String releaseDate;
    private String isbn;
    private String topic;

    // constructors
    // getters and setters
}


Сперва запустим код, чтобы убедиться что все работfет

mvn spring-boot:run

Добавим новую книгу и вернем её:

curl --location --request POST 'http://localhost:8080/books' \
--header 'Content-Type: application/json' \
--data-raw '{
	"id": 4,
	"title": "Fundamentals of Software Architecture: An Engineering Approach",
	"author": "Mark Richards, Neal Ford",
	"publisher": "Upfront Books",
	"releaseDate": "Feb 2021",
	"isbn": "B08X8H15BW",
	"topic": "Architecture"
}'

Все работает, перейдем к написанию тестов:

Аннотацию @SpringBootTest можно указать в тестовом классе, выполняющем тесты на основе Spring Boot. Использование этой аннотации предоставляет множество функций помимо обычной Spring TestContext Framework. @SpringBootTest:

  • Использует SpringBootContextLoader в качестве загрузчика ContextLoader по умолчанию, когда не определена конкретная @ContextConfiguration(loader=...) :

  • Автоматически ищет @SpringBootConfiguration, когда вложенная @Configuration не используется и не указаны явные классы.

  • Позволяет определять пользовательские свойства среды с помощью атрибута properties.

  • Позволяет определять аргументы приложения с помощью атрибута args.

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

  • Регистрирует bean-компонент TestRestTemplate и/или WebTestClient для использования в веб-тестах, использующих полностью работающий веб-сервер.

Мы собираемся добавить аннотацию @SpringBootTest к классу BookControllerTest и указать Spring запустить полностью работающий веб-сервер на произвольном порту.

Создадим файл BookControllerTest.java со следующим кодом:

package com.oreilly.functionalbooks;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

// spring-boot-test
public class BookControllerTest {

	// test-rest-template
	// test-find-all
	// test-find-all-exchange
	// test-find-one
	// test-find-one-invalid
	// test-create

}

Добавим в класс следующую аннотацию:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

Эта аннотация даст нам много возможностей помимо обычной Spring TestContext Framework. Определим атрибут webEnvironment как RANDOM_PORT, который сообщает Spring запустить полностью работающий веб-сервер на случайном порту.

Добавление аннотации @SpringBootTest к классу зарегистрирует bean-компонент TestRestTemplate и @Autowired в ваш тестовый класс. Класс TestRestTemplate — удобная альтернатива RestTemplate, подходящая для интеграционных тестов.

  Если вы новичок в RestTemplate, знайте, что это синхронный клиент для выполнения HTTP-запросов с простым API, который строится на основе базовых клиентских библиотек HTTP, таких как JDK HttpURLConnection, Apache HttpComponents и другие.

Прежде чем использовать TestRestTemplate, уделите некоторое время изучению API. Вот несколько наиболее распространенных API, которые вы увидите на следующих этапах этого практического занятия.

  • getForEntity

  • getForObject

  • postForEntity

  • exchange

Чтобы получить экземпляр TestRestTemplate, нам просто нужно запросить его у Spring, объявив переменную экземпляра и используя аннотацию @Autowired. Добавим код в BookControllerTest:

@Autowired
    TestRestTemplate template;


Имя этой переменной экземпляра — template, и именно так мы будем обращаться к ней в следующих шагах.

Пришло время написать наш первый тест, в котором вы будете тестировать API findAll() в BookController:

@Test
    void shouldReturnAllBooks() {
        ResponseEntity<Book[]> entity = template.getForEntity("/books", Book[].class);

        assertEquals(HttpStatus.OK,entity.getStatusCode());
        assertEquals(MediaType.APPLICATION_JSON,entity.getHeaders().getContentType());

        Book[] books = entity.getBody();
        assertTrue(books.length >= 3);
        assertEquals("97 Things Every Java Programmer Should Know",books[0].getTitle());
        assertEquals("Spring Boot: Up and Running",books[1].getTitle());
        assertEquals("Hacking with Spring Boot 2.3: Reactive Edition",books[2].getTitle());
    }


Первое, что нужно сделать, это использовать метод getForEntity() TestRestTemplate, чтобы сделать запрос GET к конечной точке /books и вернуть массив книг:

ResponseEntity<Book[]> entity = template.getForEntity("/books", Book[].class);


Когда я впервые столкнулся с этой проблемой, подумал: «Метод контроллера возвращает List<Book>. Почему мы объявляем возвращаемый тип Book[]?» Короче говоря, мы не можем указать конкретный тип возвращаемого значения List. Все, что Spring знает, это то, что нам нужен список, но у него нет ограничений на тип:

// This will not work
ResponseEntity<List<Book>> entity = template.getForEntity("/books", List.class);

По умолчанию Spring десериализует объект JSON в LinkedHashMap, и это не дает нам того, что мы хотим. Мы узнали о методе exchange(), который принимает параметризованный тип, позволяющий нам вернуть List<Book>:

@Test
    void shouldReturnAllBooksUsingExchange() {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        ResponseEntity<List<Book>> entity = template.exchange("/books", HttpMethod.GET, new HttpEntity<>(headers), new ParameterizedTypeReference<List<Book>>() {});

        assertEquals(HttpStatus.OK,entity.getStatusCode());
        assertEquals(MediaType.APPLICATION_JSON,entity.getHeaders().getContentType());

        List<Book> books = entity.getBody();
        assertTrue(books.size() >= 3);
    }


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

mvn test

Мы использовали метод getForEntity() TestRestTemplate для получения массива книг. Теперь мы будем использовать getForObject() для получения одной книги.

@Test
    void shouldReturnAValidBook() {
        Book book = template.getForObject("/books/1", Book.class);
        assertEquals(1,book.getId());
        assertEquals("97 Things Every Java Programmer Should Know",book.getTitle());
        assertEquals("Kevlin Henney, Trisha Gee",book.getAuthor());
        assertEquals("OReilly Media, Inc.",book.getPublisher());
        assertEquals("May 2020", book.getReleaseDate());
        assertEquals("9781491952696",book.getIsbn());
        assertEquals("Java",book.getTopic());
    }


getForObject() упрощает работу, запрашивая URI и ResponseType и возвращая этот объект. В каждом из утверждений мы проверяем, что значения объектов, которые мы возвращаем, соответствуют ожиданиям.

Мы также хотим убедиться, что когда запрос GET отправляется для несуществующей книги, он возвращает правильный код ответа, 404 Not Found:

@Test
    void invalidBookIdShouldReturn404() {
        ResponseEntity<Book> entity = template.getForEntity("/books/99", Book.class);
        assertEquals(HttpStatus.NOT_FOUND, entity.getStatusCode());
    }

Запускаем тест:

mvn test

На этом последнем шаге мы создадим тест, чтобы убедиться, что мы можем создать новую книгу, которая будет добавлена в коллекцию:

@Test
    void shouldCreateNewBook() {
        Book book = new Book(4,
                "Fundamentals of Software Architecture: An Engineering Approach",
                "Mark Richards, Neal Ford",
                "Upfront Books",
                "Feb 2021",
                "B08X8H15BW",
                "Architecture");
        ResponseEntity<Book> entity = template.postForEntity("/books", book, Book.class);
        assertEquals(HttpStatus.CREATED,entity.getStatusCode());

        Book created = entity.getBody();
        assertEquals(4,created.getId());
        assertEquals("Fundamentals of Software Architecture: An Engineering Approach",created.getTitle());
        assertEquals("Mark Richards, Neal Ford",created.getAuthor());
        assertEquals("Upfront Books",created.getPublisher());
        assertEquals("Feb 2021", created.getReleaseDate());
        assertEquals("B08X8H15BW",created.getIsbn());
        assertEquals("Architecture",created.getTopic());
        assertEquals(4,template.getForObject("/books", List.class).stream().count());
    }


PostForEntity() TestRestTemplate будет принимать URI, объект, который мы хотим отправить в качестве тела запроса, и ResponseType. При создании нового ресурса возвращается код состояния 201 Created, поэтому сначала необходимо убедиться, что:

assertEquals(HttpStatus.CREATED,entity.getStatusCode());

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

Наконец, мы делаем еще один вызов, чтобы получить все книги, чтобы убедиться, что теперь в коллекции есть еще одна книга:

assertEquals(4,template.getForObject("/books", List.class).stream().count());


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

mvn test

На этом наш небольшой туториал по созданию функциональных тестов в Spring Boot подошел к концу. Материал подготовлен в преддверии старта курса "Разрабочик на Spring Framework".

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


  1. LeshaRB
    14.07.2023 18:01

    Почему нельзя разделить
    Тест на контроллер @WebMvcTest
    И тест на сервис?