“Testing your API is like a monkey double-checking its banana stash—you want to be absolutely sure no one’s tampered with it!” 🍌✅
You’ve built a Spring Boot API, implemented features, and perhaps even secured it. But how confident are you that everything works as expected, especially after new changes? This is where testing comes in! In this guide, we’ll make your Spring Boot applications monkey-proof by diving into both unit and integration testing. By the end, you’ll be writing tests that ensure your code is robust, reliable, and ready for the wild.
🚩 Prerequisites
- Java Development Kit (JDK) 17+
- Maven or Gradle
- A basic Spring Boot REST project (e.g., from How to set up your first Spring Boot project).
- Familiarity with JUnit 5.
1️⃣ Why Test Your APIs?
Tests aren’t just for bug-hunting; they’re your safety net:
- Catch Bugs Early: Find issues before they hit production.
- Refactoring Confidence: Change code without fear of breaking existing features.
- Documentation: Tests show how your code is *supposed* to be used.
- Maintainability: A well-tested codebase is easier to understand and extend.
2️⃣ Setting Up for Testing
Dependencies
Spring Boot includes spring-boot-starter-test
by default, which brings in JUnit 5, Mockito, AssertJ, and more. Ensure your pom.xml
or build.gradle
has it:
<!-- pom.xml (usually already present) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Project Structure
Keep your tests in src/test/java
, mirroring your main package structure (e.g., com.example.demo.service.MyServiceTest
for com.example.demo.service.MyService
).
3️⃣ Unit Testing: Isolated Logic
Unit tests focus on small, isolated parts of your code (e.g., a single method in a service). We use Mockito to “mock” dependencies so we’re only testing *our* code, not its collaborators.
Example: Service Test
Let’s say you have a BananaService
that interacts with a BananaRepository
:
// src/main/java/com/example/demo/service/BananaService.java
package com.example.demo.service;
import com.example.demo.model.Banana;
import com.example.demo.repository.BananaRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class BananaService {
private final BananaRepository bananaRepository;
public BananaService(BananaRepository bananaRepository) {
this.bananaRepository = bananaRepository;
}
public Optional<Banana> getBananaById(Long id) {
return bananaRepository.findById(id);
}
public Banana saveBanana(Banana banana) {
// Imagine some business logic here
if (banana.getRipeness() < 1) {
throw new IllegalArgumentException("Banana too green!");
}
return bananaRepository.save(banana);
}
}
Now, test it:
// src/test/java/com/example/demo/service/BananaServiceTest.java
package com.example.demo.service;
import com.example.demo.model.Banana;
import com.example.demo.repository.BananaRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) // Enable Mockito for JUnit 5
class BananaServiceTest {
@Mock // Create a mock for BananaRepository
private BananaRepository bananaRepository;
@InjectMocks // Inject mocks into BananaService
private BananaService bananaService;
private Banana ripeBanana;
private Banana greenBanana;
@BeforeEach
void setUp() {
ripeBanana = new Banana("Cavendish", 4);
ripeBanana.setId(1L);
greenBanana = new Banana("Plantain", 0); // Too green!
}
@Test
void getBananaById_shouldReturnBanana_whenFound() {
when(bananaRepository.findById(1L)).thenReturn(Optional.of(ripeBanana));
Optional<Banana> found = bananaService.getBananaById(1L);
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Cavendish");
}
@Test
void getBananaById_shouldReturnEmpty_whenNotFound() {
when(bananaRepository.findById(2L)).thenReturn(Optional.empty());
Optional<Banana> found = bananaService.getBananaById(2L);
assertThat(found).isEmpty();
}
@Test
void saveBanana_shouldSaveAndReturnBanana() {
when(bananaRepository.save(any(Banana.class))).thenReturn(ripeBanana);
Banana saved = bananaService.saveBanana(ripeBanana);
assertThat(saved).isNotNull();
assertThat(saved.getName()).isEqualTo("Cavendish");
}
@Test
void saveBanana_shouldThrowException_whenRipenessTooLow() {
assertThrows(IllegalArgumentException.class, () -> {
bananaService.saveBanana(greenBanana);
});
}
}
@Mock
: Creates a mock instance ofBananaRepository
.@InjectMocks
: Injects the mockedBananaRepository
intoBananaService
.when(...).thenReturn(...)
: Defines the behavior of the mocked methods.
4️⃣ Integration Testing: The Full Stack
Integration tests boot up a portion (or all) of your Spring application context, verifying how different components (controllers, services, repositories) interact with each other and potentially a real database.
@SpringBootTest
Basics
@SpringBootTest
loads your full application context. Combine it with @AutoConfigureMockMvc
to test your web layer without a running server.
// src/test/java/com/example/demo/controller/BananaControllerIntegrationTest.java
package com.example.demo.controller;
import com.example.demo.model.Banana;
import com.example.demo.repository.BananaRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest // Loads the full Spring application context
@AutoConfigureMockMvc // Configures MockMvc for web layer testing
@ActiveProfiles("test") // Use a dedicated test profile if you have one
class BananaControllerIntegrationTest {
@Autowired
private MockMvc mockMvc; // Used to send HTTP requests to the controller
@Autowired
private BananaRepository bananaRepository; // To set up test data
@Autowired
private ObjectMapper objectMapper; // For converting objects to JSON
@BeforeEach
void setUp() {
bananaRepository.deleteAll(); // Clean up database before each test
}
@Test
void createBanana_shouldReturnCreatedBanana() throws Exception {
Banana newBanana = new Banana("Gros Michel", 5);
mockMvc.perform(post("/api/bananas")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newBanana)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Gros Michel"))
.andExpect(jsonPath("$.ripeness").value(5));
assertThat(bananaRepository.findAll()).hasSize(1);
}
@Test
void getBananaById_shouldReturnBanana_whenExists() throws Exception {
Banana savedBanana = bananaRepository.save(new Banana("Lady Finger", 3));
mockMvc.perform(get("/api/bananas/{id}", savedBanana.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Lady Finger"));
}
@Test
void getBananaById_shouldReturnNotFound_whenNotExists() throws Exception {
mockMvc.perform(get("/api/bananas/{id}", 999L))
.andExpect(status().isNotFound());
}
@Test
void getAllBananas_shouldReturnEmptyList_initially() throws Exception {
mockMvc.perform(get("/api/bananas"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void deleteBanana_shouldReturnNoContent() throws Exception {
Banana savedBanana = bananaRepository.save(new Banana("Red Dacca", 2));
mockMvc.perform(delete("/api/bananas/{id}", savedBanana.getId()))
.andExpect(status().isNoContent());
assertThat(bananaRepository.findById(savedBanana.getId())).isEmpty();
}
}
MockMvc
: Simulates HTTP requests without deploying a full server.jsonPath("$").value(...)
: Asserts values in the JSON response body.@ActiveProfiles("test")
: Ensures your test uses a dedicated configuration (e.g., in-memory H2 for tests).
5️⃣ Web Layer Testing: @WebMvcTest
If you only want to test your controller and its direct dependencies (without loading the entire service/repository layers), @WebMvcTest
is perfect. It’s faster as it loads a much smaller context.
Example: Controller Test with Mocked Service
// src/test/java/com/example/demo/controller/BananaControllerWebLayerTest.java
package com.example.demo.controller;
import com.example.demo.model.Banana;
import com.example.demo.service.BananaService;
import com.fasterxml.jackson.databind.ObjectMapper;
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 java.util.Collections;
import java.util.Optional;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(BananaController.class) // Test only BananaController and its web components
class BananaControllerWebLayerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // Create a mock for BananaService, not the real one
private BananaService bananaService;
@Autowired
private ObjectMapper objectMapper;
@Test
void getAllBananas_shouldReturnListOfBananas() throws Exception {
Banana banana = new Banana("Fugla", 3);
banana.setId(1L);
when(bananaService.getAllBananas()).thenReturn(Collections.singletonList(banana));
mockMvc.perform(get("/api/bananas"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].name", is("Fugla")));
}
@Test
void createBanana_shouldReturnCreatedBanana() throws Exception {
Banana newBanana = new Banana("Apple Banana", 4);
newBanana.setId(1L);
when(bananaService.saveBanana(any(Banana.class))).thenReturn(newBanana);
mockMvc.perform(post("/api/bananas")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newBanana)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name", is("Apple Banana")));
}
@Test
void getBananaById_shouldReturnBanana() throws Exception {
Banana banana = new Banana("Blue Java", 2);
banana.setId(1L);
when(bananaService.getBananaById(1L)).thenReturn(Optional.of(banana));
mockMvc.perform(get("/api/bananas/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is("Blue Java")));
}
}
@WebMvcTest(BananaController.class)
: Focuses on the web layer. OnlyBananaController
and its Spring MVC infrastructure are loaded.@MockBean
: Used to provide mock instances of dependencies (likeBananaService
) thatBananaController
relies on.
💡 Monkey-Proof Tips
- Test Pyramid: Aim for many fast unit tests, fewer integration tests, and even fewer end-to-end tests.
- Clear Assertions: Use AssertJ or Hamcrest for readable and expressive assertions (e.g.,
assertThat(list).hasSize(2)
). - Test Data Setup: For integration tests, use
@BeforeEach
to set up or clean up test data to ensure test isolation. - Testcontainers: For integration tests involving real databases or message queues, consider Testcontainers to spin up disposable instances programmatically.
- Performance: Integration tests are slower than unit tests. Only use
@SpringBootTest
when you genuinely need the full context.
🚀 Challenge
- Extend Unit Tests: Add more unit tests to
BananaServiceTest
to cover edge cases, such as updating a banana with invalid ripeness, or trying to retrieve a banana by a null ID. - Add a New Endpoint: Create a new GET endpoint
/api/bananas/search?name=...
inBananaController
that takes a partial name and returns matching bananas. Then, write both an@WebMvcTest
and an@SpringBootTest
for this new endpoint. - Implement Validation Error Testing: Add an integration test that attempts to create a
Banana
with invalid data (e.g., empty name, ripeness below 1). Assert that the API returns a 400 Bad Request status and includes appropriate error messages in the JSON response.
👏 You’ve now equipped yourself with the tools to write robust unit and integration tests for your Spring Boot APIs! Your code is no longer a wild jungle; it’s a meticulously checked banana farm. Keep testing, and keep building high-quality Java applications!