Testing is one of the most critical aspects of building reliable applications with Spring Boot.
However, many developers limit themselves to basic unit tests and miss the bigger picture of validating an application end-to-end.
We’ll walk through a complete testing journey from simple unit tests to full integration testing.
It is for those who want to understand how modern Spring applications are tested in real-world scenarios.
1. Application Structure Overview
Before diving into testing, let us have a look at the structure of the example application being tested.
The project follows a standard layered architecture commonly used in Spring Boot applications:
Controller -> Service -> Repository -> Database
Each layer has a clear responsibility, which makes the application easier to maintain and test.
1.1 Package Structure
The project structure looks like this:
com.example.demo
├── controller
│ └── UserController.java
├── service
│ └── UserService.java
├── repository
│ └── UserRepository.java
├── entity
│ └── User.java
└── config
└── SecurityConfig.java
1.2 Layers Explained
1.2.1 Controller Layer
The controller is responsible for handling HTTP requests and returning responses.
package com.example.demo.controller;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
public UserController(UserService service) {
this.service = service;
}
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return service.getUser(id);
}
@PostMapping
public User createUser(@RequestBody User user) {
return service.createUser(user);
}
}
Responsibilities:
- Expose REST endpoints
- Handle request/response mapping
- Delegate logic to the service layer
1.2.2 Service Layer
The service contains the business logic of the application.
package com.example.demo.service;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository repo;
public UserService(UserRepository repo) {
this.repo = repo;
}
public User getUser(Long id) {
return repo.findById(id).orElseThrow();
}
public User createUser(User user) {
return repo.save(user);
}
}
Responsibilities:
- Implement business rules
- Coordinate between layers
- Keep controllers thin
1.2.3 Repository Layer
The repository handles database access using Spring Data JPA.
package com.example.demo.repository;
import com.example.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
Responsibilities:
- Perform CRUD operations
- Abstract database interactions
- Provide query methods
1.2.4 Entity Layer
The entity represents the database model.
package com.example.demo.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
Responsibilities:
- Define database structure
- Map Java objects to database tables
- Include validation rules
1.2.5 Security Configuration (Optional but Important)
Spring Security is used to protect endpoints.
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.core.userdetails.User;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.headers(headers -> headers.frameOptions(frame -> frame.disable())) // for H2 console
.authorizeHttpRequests(auth -> auth
.requestMatchers("/h2-console/**").permitAll()
.requestMatchers("/users/**").hasRole("USER")
// .requestMatchers("/users/**").permitAll()
.anyRequest().authenticated())
.httpBasic()
.and()
.build();
}
@Bean
public UserDetailsService users() {
var user = User
.withUsername("user")
.password("{noop}password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
Responsibilities:
- Define access rules
- Enable authentication
- Protect endpoints
1.3 The Big Picture of Testing
A well-tested Spring Boot application typically follows a layered testing strategy:
Unit Tests -> Slice Tests -> Integration Tests
Each level serves a different purpose:
| Level | Goal | Tools |
|---|---|---|
| Unit | Test business logic | JUnit Mockito |
| Slice | Test a specific layer | @WebMvcTest @DataJpaTest @Testcontainers |
| Integration | Test full system | @SpringBootTest |
2. Unit Testing with JUnit and Mockito
Unit testing focuses on isolated components, typically a single class, without loading the Spring context.
package com.example.demo.service;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Optional;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class UserServiceTest {
@Mock
UserRepository repo;
@InjectMocks
UserService service;
@Test
void testGetUser() {
User user = new User();
user.setName("john");
when(repo.findById(1L)).thenReturn(Optional.of(user));
assertEquals("john", service.getUser(1L).getName());
}
}
Why this matters ?
- Tests run extremely fast
- No dependency on Spring
- Failures are easy to diagnose
Unit tests are ideal for verifying business rules and logic, but they do not guarantee that your application works as a whole.
3. Slice Testing
Slice tests load only a portion of the Spring context, allowing you to test specific layers with framework support while keeping tests lightweight.
3.1 Controller Testing with @WebMvcTest
package com.example.demo.controller;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
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.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
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.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class)
@Import(com.example.demo.config.SecurityConfig.class)
class UserControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
UserService service;
// ❌ No authentication
@Test
void shouldReturnUnauthorized() throws Exception {
mockMvc.perform(get("/users/1"))
.andExpect(status().isUnauthorized());
}
// ❌ Wrong role
@Test
@WithMockUser(roles = "ADMIN")
void shouldReturnForbiddenForWrongRole() throws Exception {
mockMvc.perform(get("/users/1"))
.andExpect(status().isForbidden());
}
// ✅ Correct role
@Test
@WithMockUser(roles = "USER")
void shouldReturnUser() throws Exception {
User user = new User();
user.setName("john");
when(service.getUser(1L)).thenReturn(user);
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("john"));
}
@Test
@WithMockUser(roles = "USER")
void shouldCreateUser() throws Exception {
String json = """
{
"name": "john",
"email": "john@mail.com"
}
""";
User createdUser = new User();
createdUser.setId(1L);
createdUser.setName("john");
createdUser.setEmail("john@mail.com");
when(service.createUser(any(User.class))).thenReturn(createdUser);
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.name").value("john"))
.andExpect(jsonPath("$.email").value("john@mail.com"));
}
}
What happens here ?
- Only the web layer is loaded
- The service layer is mocked
- HTTP requests are simulated via MockMvc
Without starting the full application, this allows to validate:
- Request mapping
- JSON serialization
- HTTP status codes
3.2 Repository Testing with @DataJpaTest
package com.example.demo.repository;
import com.example.demo.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class UserRepositoryTest {
@Autowired
UserRepository userRepository;
@Test
void shouldSaveAndFindUser() {
User user = new User();
user.setName("john");
user.setEmail("john@mail.com");
User saved = userRepository.save(user);
Optional<User> found = userRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("john");
}
@Test
void shouldFindByEmail() {
User user = new User();
user.setName("john");
user.setEmail("john@mail.com");
userRepository.save(user);
Optional<User> found = userRepository.findByEmail("john@mail.com");
assertThat(found).isPresent();
}
}
What this verifies ?
- Entity mapping
- JPA behavior
- Database interactions
By default, this uses an in-memory database, which makes tests fast and isolated, but not fully representative of production environments.
Instead of relying on an in-memory database, we can also use a database running in a container.
This approach provides a production-like environment while keeping tests reproducible and isolated.
package com.example.demo.repository;
import com.example.demo.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@Testcontainers
class UserRepositoryTest2 {
@Autowired
UserRepository userRepository;
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void shouldSaveAndFindUser() {
User user = new User();
user.setName("john");
user.setEmail("john@mail.com");
User saved = userRepository.save(user);
Optional<User> found = userRepository.findById(saved.getId());
assertThat(found).isPresent();
}
@Test
void shouldFindByEmail() {
User user = new User();
user.setName("john");
user.setEmail("john@mail.com");
userRepository.save(user);
Optional<User> found = userRepository.findByEmail("john@mail.com");
assertThat(found).isPresent();
}
}
4. Full Integration Testing
Integration test represents a complete application lifecycle, ensuring that all layers interact correctly.
MockMvc -> Controller -> Service -> Repository -> DataBase
package com.example.demo;
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.security.test.context.support.WithMockUser;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import com.jayway.jsonpath.JsonPath;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class UserIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
MockMvc mockMvc;
// ❌ No authentication
@Test
void shouldReturnUnauthorized() throws Exception {
mockMvc.perform(get("/users/1"))
.andExpect(status().isUnauthorized());
}
// ❌ Wrong role
@Test
@WithMockUser(roles = "ADMIN")
void shouldReturnForbidden() throws Exception {
mockMvc.perform(get("/users/1"))
.andExpect(status().isForbidden());
}
// ✅ Full flow test
@Test
@WithMockUser(roles = "USER")
void shouldReturnUserFromDatabase() throws Exception {
String json = """
{
"name": "john",
"email": "john@mail.com"
}
""";
// Create user via POST endpoint
String responseBody = mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
// Extract ID from response
int userId = JsonPath.read(responseBody, "$.id");
// Retrieve user via GET endpoint
mockMvc.perform(get("/users/" + userId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("john"))
.andExpect(jsonPath("$.email").value("john@mail.com"));
}
}
Why Full Integration Testing Matters ?
- Verifies real application behavior
- Detects issues across layers
- Ensures database compatibility
- Validates security rules
- Builds strong confidence before deployment
5. Conclusion
A modern Spring Boot application should not rely on a single type of test.
Instead, it should combine multiple testing strategies to build confidence at every level.
- Unit tests validate logic in isolation
- Slice tests validate individual layers
- Integration tests validate the entire system
- Using a real database increases reliability
- Security and validation must be part of testing
By progressing from unit tests to full integration tests, we ensure that the application is not only correct in isolation but also robust in real-world scenarios.