Testing APIs is an integral part of the development process. It verifies that your application's individual components (unit testing) and the interaction between those components (integration testing) are working as expected. Specifically, for controllers in Spring Boot, testing ensures that they are processing requests and responses correctly and that the data flow within the application is as intended. This topic will guide you through writing effective unit and integration tests for controllers in Spring Boot.
Getting started
Let's consider a simple Spring Boot application that manages a collection of books in an in-memory storage. This application will have a BookController that exposes a GET endpoint to fetch book objects.
Here's a basic structure of the Book model:
Java
public class Book {
private int id;
private String title;
private String author;
public Book(int id, String title, String author) {
this.id = id;
this.title = title;
this.author = author;
}
// getters and setters
}Kotlin
data class Book(val id: Int, val title: String, val author: String)The following BookService class handles a simple business logic:
Java
@Service
public class BookService {
private final Map<Integer, Book> books = Map.of(
1, new Book(1, "The Art of Programming", "John Doe"),
2, new Book(2, "Mastering Spring Boot", "Jane Smith"),
3, new Book(3, "Understanding Algorithms", "Alice Johnson")
);
public Book getBookById(int bookId) {
return books.get(bookId);
}
}Kotlin
@Service
class BookService {
private val books = mapOf(
1 to Book(1, "The Art of Programming", "John Doe"),
2 to Book(2, "Mastering Spring Boot", "Jane Smith"),
3 to Book(3, "Understanding Algorithms", "Alice Johnson")
)
fun getBookById(bookId: Int): Book? = books[bookId]
}And the BookController class uses BookService to fetch books:
Java
@RestController
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping(path = "/books/{id}")
public ResponseEntity<Book> getBook(@PathVariable int id) {
var payload = bookService.getBookById(id);
if (payload == null) {
return ResponseEntity.notFound().build();
} else {
return ResponseEntity.ok(payload);
}
}
}Kotlin
@RestController
class BookController(private val bookService: BookService) {
@GetMapping(path = ["/books/{id}"])
fun getBook(@PathVariable id: Int): ResponseEntity<Book> {
val payload = bookService.getBookById(id)
return if (payload == null) {
ResponseEntity.notFound().build()
} else {
ResponseEntity.ok(payload)
}
}
}This controller exposes a GET endpoint at /books/{id}. It fetches a book by its id and returns it. If no book is found, it returns a 404 NOT FOUND status. In the following steps, you will see how to test this REST controller.
Testing REST controllers
Testing controllers in Spring Boot is facilitated by several annotations and classes provided by the framework. In the first example, we will use the @SpringBootTest annotation to set up integration tests that will load the entire application context.
In our tests, we will set up a random port for the server to avoid conflicts with other running instances if tests run concurrently. This can be done by setting the webEnvironment attribute of @SpringBootTest to RANDOM_PORT.
We also need a way to interact with our REST endpoints. For this, we can use TestRestTemplate. It is a class provided by Spring Boot for client-side testing of REST endpoints that makes actual HTTP requests to the server.
Here is a basic setup for our test:
Java
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.boot.test.web.server.LocalServerPort;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BookControllerRestTemplateTests {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
// tests go here...
}Kotlin
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.boot.test.web.server.LocalServerPort
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class BookControllerRestTemplateTests(
@Autowired private val restTemplate: TestRestTemplate
) {
@LocalServerPort
private val port = 0
// tests go here...
}The @LocalServerPort annotation is used to inject the actual port number that the server is listening at. This is necessary because we've specified a random port that we don't know beforehand. We also autowire TestRestTemplate to allow us to make HTTP requests to our endpoints.
Let's write a test to check if our GET endpoint returns the correct HTTP status code and the correct response body:
Java
import org.junit.jupiter.api.DisplayName;
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.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BookControllerRestTemplateTests {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
@DisplayName("GET /books/id returns 200 OK and a Book JSON")
void getBook_validId_returnsValidResponseEntity() {
String url = "http://localhost:" + port + "/books/1";
ResponseEntity<Book> response = restTemplate.getForEntity(url, Book.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getAuthor()).isEqualTo("John Doe");
}
}Kotlin
import org.assertj.core.api.Assertions.assertThat
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.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class BookControllerRestTemplateTests(
@Autowired private val restTemplate: TestRestTemplate
) {
@LocalServerPort
private val port = 0
@Test
fun `GET books with valid id returns valid ResponseEntity`() {
val url = "http://localhost:$port/books/1"
val response = restTemplate.getForEntity(
url,
Book::class.java
)
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
assertThat(response.body).isNotNull()
assertThat(response.body?.author).isEqualTo("John Doe")
}
}In this test method, we make a GET request to the /books/1 endpoint using the getForEntity method of the TestRestTemplate class. This method returns a ResponseEntity which includes both the HTTP status code and the body of the response. To make the request, we construct the appropriate URL using the port variable mentioned above.
Finally, we make assertions about the response. The test asserts that the HTTP status code is 200 OK, that the body of the response is not null, and that the author field of the returned book is "John Doe". These assertions check that the endpoint is working as expected and if they return the correct data for a valid book id.
You can write a similar test to verify that the getBook method of the BookController returns a 404 NOT FOUND status code if there is no book with the requested id.
Using @WebMvcTest
In most cases, we want to run unit tests where we isolate the controller from the rest of the application. For this purpose, the Spring Test framework provides @WebMvcTest annotation. It autoconfigures the Spring MVC infrastructure and loads the web layer of the context — that is, the controllers and their related components, such as @ControllerAdvice.
To test Spring MVC applications in a more controlled environment, we can use the MockMvc class. It does not start a full HTTP server but instead executes requests within the same process and thus is faster than using TestRestTemplate.
Here is a basic setup for our test:
Java
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;
@WebMvcTest(BookController.class)
public class BookControllerMockMvcTests {
@Autowired
private MockMvc mockMvc;
@MockBean
private BookService bookService;
// tests go here...
}Kotlin
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
@WebMvcTest(BookController::class)
internal class BookControllerMockMvcTests(
@Autowired private val mockMvc: MockMvc
) {
@field:MockBean
private lateinit var bookService: BookService
// tests go here...
}In this setup, we autowire MockMvc to allow us to make HTTP requests to the application endpoints. BookService is mocked so we can control its behavior during tests. This mock will replace any existing bean of the same type in the Spring context.
Let's write a similar test to the one before, but this time using MockMvc:
Java
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(BookController.class)
public class BookControllerMockMvcTests {
// declared fields
@Test
@DisplayName("GET /books/1 returns 200 OK and a valid JSON")
void getBook_returnsValidResponseEntity() throws Exception {
var mockBook = new Book(1, "Test Book", "Test Author");
when(bookService.getBookById(1)).thenReturn(mockBook);
var requestBuilder = get("/books/1");
mockMvc.perform(requestBuilder)
.andExpectAll(
status().isOk(),
content().contentType(MediaType.APPLICATION_JSON),
content().json("""
{
"id": 1,
"title": "Test Book",
"author": "Test Author"
}
"""
)
);
}
}Kotlin
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.Mockito.`when`
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.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@WebMvcTest(BookController::class)
internal class BookControllerMockMvcTests(
@Autowired private val mockMvc: MockMvc
) {
@field:MockBean
private lateinit var bookService: BookService
@Test
@Throws(Exception::class)
fun `GET books returns valid ResponseEntity`() {
val mockBook = Book(1, "Test Book", "Test Author")
`when`(bookService.getBookById(1)).thenReturn(mockBook)
val requestBuilder = get("/books/1")
mockMvc.perform(requestBuilder)
.andExpectAll(
status().isOk(),
content().contentType(MediaType.APPLICATION_JSON),
content().json(
"""
{
"id": 1,
"title": "Test Book",
"author": "Test Author"
}
""".trimIndent()
)
)
}
}In this test, we first create a mock Book object. Then, we use the when method from Mockito to specify that when bookService.getBookById(1) is called, the mock Book should be returned. Next, we create a MockHttpServletRequestBuilder with the help of the statically imported MockMvcRequestBuilders.get method. This request builder will be used to perform a GET request to the /books/1 endpoint.
Next, we call the perform method to send the GET request. After that, we call the andExpectAll method to make several assertions about the response. It checks that the HTTP status is 200 OK, that the content type is JSON, and that the response content matches the JSON representation of our mock Book object.
As you can see, MockMvc provides a fluent API for making requests and checking responses. MockMvcRequestBuilders offers other static methods to construct any required HTTP request.
Assertions with MockMvc
As an alternative to the andExpectAll method, you can perform assertions in MockMvc by chaining the andExpect method. This method accepts a ResultMatcher, which can be used to assert different aspects of the HTTP response, such as status, headers, and content.
Here are some common assertions you might use with MockMvc:
Status
You can assert the HTTP status of the response using the MockMvcResultMatchers.status method:
mockMvc.perform(get("/books/1"))
.andExpect(status().isOk())This asserts that the HTTP status of the response is 200 OK.
Content
You can assert the content of the response using the content method. For example, you can check if the response is JSON and matches a specific structure:
mockMvc.perform(get("/books/1"))
.andExpect(content().json("{\"id\":\"1\",\"title\":\"Test Book\",\"author\":\"Test Author\"}"))This asserts that the response is a JSON that matches the provided string.
View
If your controller returns a view (for example, in a web application that uses Freemarker, Thymeleaf, or JSP), you can assert the returned view name:
mockMvc.perform(get("/books/1"))
.andExpect(view().name("bookDetail"))This asserts that the name of the returned view is "bookDetail".
In the context of the BookController, we can re-write assertions using a chain of andExpect methods:
Java
@Test
@DisplayName("GET /books/1 returns 200 OK and a valid JSON")
void getBook_returnsValidResponseEntity() throws Exception {
var mockBook = new Book(1, "Test Book", "Test Author");
when(bookService.getBookById(1)).thenReturn(mockBook);
mockMvc.perform(get("/books/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("Test Book"))
.andExpect(jsonPath("$.author").value("Test Author"));
}Kotlin
@Test
@Throws(java.lang.Exception::class)
fun `GET books returns valid ResponseEntity`() {
val mockBook = Book(1, "Test Book", "Test Author")
`when`(bookService.getBookById(1)).thenReturn(mockBook)
mockMvc.perform(get("/books/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("Test Book"))
.andExpect(jsonPath("$.author").value("Test Author"))
}The jsonPath method is used to assert the values of specific fields in the JSON response. In this case, it checks that the id, title, and author fields have the correct values.
You can use MockMvc with @SpringBootTest as well. For this, apply the @AutoConfigureMockMvc annotation to the test class along with the @SpringBootTest:
Java
@SprintBootTest
@AutoConfigureMockMvc
class DemoTests {
@Autowired
private MockMvc mockMvc;
// tests go here...
}Kotlin
@SpringBootTest
@AutoConfigureMockMvc
internal class DemoTests(@Autowired private val mockMvc: MockMvc) {
// tests go here...
}Conclusion
Testing controllers is a crucial part of the development process. We explored how to set up tests using @SpringBootTest and @WebMvcTest annotations, how to use TestRestTemplate for client-side tests and MockMvc for more detailed tests. We learned how to inject dependencies and control their behavior using mocking. We also delved into various assertions with MockMvc, such as status, content, and view assertions. Overall, a thorough understanding of these tools and techniques is key to ensuring that a web service functions correctly.