Spring – [ Testing ]

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:

LevelGoalTools
UnitTest business logicJUnit Mockito
SliceTest a specific layer@WebMvcTest @DataJpaTest @Testcontainers
IntegrationTest 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.