Testing Spring Boot Applications: Unit, Integration, and System Tests

Illustration for Testing Spring Boot Applications: Unit, Integration, and System Tests
By Last updated:

In modern software development, testing is not optional—it’s a necessity. For Spring Boot applications, ensuring that components, services, and entire systems work correctly across different environments is vital. Testing helps catch bugs early, guarantees maintainability, and builds confidence in CI/CD pipelines.

In this tutorial, we’ll explore how to structure and execute unit tests, integration tests, and system tests in Spring Boot applications using industry-standard tools such as JUnit 5, Mockito, and Testcontainers.


Understanding Testing Levels in Spring Boot

1. Unit Tests

  • Focus on small, isolated pieces of code (e.g., services, utility classes).
  • Run fast and should form the majority of your test suite.
  • Tools: JUnit 5 + Mockito.

2. Integration Tests

  • Verify that multiple Spring components work together.
  • Example: Testing a Repository with a real database.
  • Tools: Spring Boot Test + Testcontainers.

3. System Tests (End-to-End)

  • Validate the entire Spring Boot application in an environment similar to production.
  • Example: REST API tests with database + message broker.
  • Tools: Testcontainers, WireMock, RestAssured.

Setting Up Dependencies

Maven

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>

Writing Unit Tests with JUnit 5 and Mockito

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void testFindUserById() {
        User mockUser = new User(1L, "Alice");
        when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));

        User result = userService.findUserById(1L);

        assertEquals("Alice", result.getName());
        verify(userRepository, times(1)).findById(1L);
    }
}

🔑 Key Point: Unit tests should run without Spring Context for speed.


Integration Testing with Spring Boot and Testcontainers

@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @Autowired
    private UserRepository userRepository;

    @Test
    void testSaveUser() {
        User user = new User(null, "Bob");
        User saved = userRepository.save(user);
        assertNotNull(saved.getId());
    }
}

🔑 Key Point: Testcontainers ensures that every test has a clean, isolated environment.


System Testing with Spring Boot

For end-to-end API testing, we combine Spring Boot with RestAssured and Testcontainers.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserApiSystemTest {

    @LocalServerPort
    int port;

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");

    @Test
    void testCreateUserApi() {
        given()
            .baseUri("http://localhost:" + port)
            .contentType("application/json")
            .body("{"name": "Charlie"}")
        .when()
            .post("/users")
        .then()
            .statusCode(200)
            .body("name", equalTo("Charlie"));
    }
}

Best Practices

  1. Follow the testing pyramid – unit tests > integration tests > system tests.
  2. Use Mockito to mock dependencies and isolate logic.
  3. Leverage Testcontainers for real DBs, Kafka, RabbitMQ.
  4. Ensure fast feedback loops – don’t overload CI pipelines with slow system tests.
  5. Write readable, maintainable, and deterministic tests.

Version Tracker

  • JUnit 4 → JUnit 5 introduced @ExtendWith, parameterized tests, dynamic tests.
  • Mockito added support for mocking final and static methods.
  • Testcontainers expanded to support databases, message brokers, Kubernetes, LocalStack, and Docker Compose.

Conclusion & Key Takeaways

  • Unit tests validate individual components.
  • Integration tests ensure multiple Spring Boot beans work together.
  • System tests confirm the application works end-to-end.
  • Mockito + JUnit 5 + Testcontainers is a powerful trio for building robust test suites.

FAQ

1. What’s the difference between unit and integration tests?
Unit tests check isolated classes; integration tests verify collaboration between components.

2. Why should I use Testcontainers for integration testing?
Because it provides real, disposable environments (databases, brokers, services).

3. Can I mock static methods with Mockito?
Yes, since Mockito 3.4+, static methods can be mocked with mockStatic.

4. How can I use Testcontainers in CI/CD pipelines?
They work seamlessly with Jenkins, GitHub Actions, and GitLab CI by leveraging Docker.

5. What’s the difference between TDD and BDD?
TDD focuses on implementation correctness, BDD emphasizes behavior and collaboration.

6. How do I handle flaky tests?
Use retries, isolate environments with Testcontainers, and ensure deterministic data.

7. Can I use Testcontainers with legacy Spring apps?
Yes, they integrate easily as long as Docker is available.

8. What tools can I use for system tests?
RestAssured, WireMock, Pact, or even Selenium for UI testing.

9. How do I measure test coverage?
Integrate JaCoCo with Maven/Gradle for reporting.

10. Is it necessary to write all three levels of tests?
Yes, for production-grade systems, a mix ensures coverage, speed, and confidence.