Test Your Spring Boot API with JUnit, Mockito and Testcontainers

This post links to: Building a Spring Boot CRUD App With Postgres from Scratch: The Complete Guide.

This guide follows on from Input Validation & Error Handling with Spring Boot.

Now that our API is functional, validated and can handle errors, it’s time to ensure it works as expected through automated testing! As a best practice, we should've done this alongside writing our application or used TDD (Test-driven development) or BDD (Behaviour-driven development). However, I think we can let this slide for this blog series, just this once. I think TDD & BDD require their own blog posts.

In this post, we’ll cover how to write unit and integration tests using JUnit. You’ll learn how to test services in isolation and write integration tests with a real PostgreSQL database using Testcontainers, and use MockMvc to test your API endpoints without needing to start a browser or use a tool like cURL or Postman.

Let's dive straight in!

Setting Up Dependencies

First things first, we're going to need to add the following dependencies to our build.grade file.

// build.gradle
dependencies {
    //other deps...

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:postgresql'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'

    // other deps...
}

Notice how these dependencies are prefixed with testImplementation. This tells our project that when we run our tests, we want to use these dependencies.

Unit Testing

Now we have added our dependencies, lets start by adding some unit tests.

Before we start writing code, what are unit tests?

In short, they test a small piece of logic (usually a method or class) in isolation. This means no Spring context, database or web server. Unit tests in Java are fast and focused using libraries like JUnit (a Java testing framework) & Mockito (a framework for mocking dependencies in tests).

An example of a unit test would be testing a service method that calculates a value or saves a record to a repository (the repository would be a mock dependency).

Unit tests are the first line of defence for capturing errors and bugs from code changes and should run frequently to validate your application still works as expected.

Here's an example unit test for our UserService:

// UserServiceTest.java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock private UserMapper userMapper;
    @Mock private UserRepository userRepository;

    private UserService underTest;

    @BeforeEach
    void setUp() {

        underTest = new UserService(userMapper, userRepository);
    }

    @Test
    void getAllUsersReturnsListOfStoredUsersFromUserRepository() {

        final var user1 = new User(1L, "user1", "user1@email.com", 20);
        final var user2 = new User(2L, "user2", "user2@email.com", 30);
        doReturn(List.of(user1, user2)).when(userRepository).findAll();

        assertThat(underTest.getAllUsers()).containsExactlyInAnyOrder(user1, user2);
    }

    //More unit tests...
}

This test checks that when we call getAllUsers() in the UserService, we see a list of stored users returned.

Let's break down some of these annotations:

There we go, we have some unit tests. To run our new UserServiceTest class we can use the following Gradle command:

./gradlew test --tests full.bearded.dev.crud.app.user.UserServiceTest

Or you can run the following to run all the tests together:

./gradlew test

Now if anything changes in the UserService, we can run these unit tests to check all the expected behaviour works the same as before.

View the repo for more user service unit tests examples.

API Integration Testing With MockMVC

We've added some unit tests which give us initial validation of whether the app is broken at a modular level. The next test we can add is a integration test slice that will test whether our API endpoints behave as expected.

The integration test slice we're going to create involves the annotation @WebMvcTest. Similar to the @DataJpaTest annotation used in this part of the blog series here, this annotation tells our test to load only certain parts of our application. For @DataJpaTest, this is the database layer, for @WebMvcTest it's the web layer including our controller endpoints. This allows us to test how our controllers handle requests and responses without bringing up the whole Spring context, making our test faster and seperated from the rest of our application.

Here's an example of a integration test slice for the UserController:

// UserControllerTest.java
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired private MockMvc mockMvc;

    @MockitoBean private UserService userService;
    @MockitoBean private UserMapper userMapper;

    @Test
    void getAllUsersReturnsListOfUserResponses() throws Exception {

        final var user1 = new User(1L, "user1", "user1@email.com", 20);
        final var user2 = new User(2L, "user2", "user2@email.com", 30);

        final var response1 = new UserResponse(1L, "user1", "user1@email.com", 20);
        final var response2 = new UserResponse(2L, "user2", "user2@email.com", 30);

        doReturn(List.of(user1, user2)).when(userService).getAllUsers();
        doReturn(response1).when(userMapper).toResponse(user1);
        doReturn(response2).when(userMapper).toResponse(user2);

        mockMvc.perform(get("/api/users"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.length()").value(2))
               .andExpect(jsonPath("$[0].name").value("user1"))
               .andExpect(jsonPath("$[1].name").value("user2"));
    }

    //More tests...
}

This test checks that when we call /api/users from our UserController, we see a list of stored users returned.

Again we can run the test using the following command:

./gradlew test --tests full.bearded.dev.crud.app.user.UserControllerTest

View the repo for more web MVC test examples for the user controller.

Full Integration Test with Testcontainers

Finally, we will add an integration test to make sure everything works together in our application!

Typically, an integration test will test how multiple layers, such as the Spring context, database, and HTTP layer work together. Full integration tests are usually slower, but more realistic for how the application would actually run and behave.

An example full integration test would be a controller + service + repository working together. We can use Testcontainers to spin up a real PostgreSQL instance for interaction with the database. This integration test could help catch misconfigurations, broken SQL queries, and other real-world issues.

Here's our integration test:

// UserIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserIntegrationTest {

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

    @DynamicPropertySource
    static void configure(final DynamicPropertyRegistry registry) {

        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired private TestRestTemplate restTemplate;

    @Test
    void shouldCreateAndFetchUser() {

        final var userCreateRequest = new UserCreateRequest("user", "user@email.com", 20);

        restTemplate.postForEntity("/api/users", userCreateRequest, UserResponse.class);

        final var userResponseEntity = restTemplate.exchange("/api/users",
                                                             HttpMethod.GET,
                                                             null,
                                                             new ParameterizedTypeReference<List<UserResponse>>() {});

        final List<UserResponse> body = userResponseEntity.getBody();

        assertThat(userResponseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);

        assertThat(body).isNotNull();
        assertThat(body.size()).isEqualTo(1);

        assertThat(body.getFirst().getName()).isEqualTo("user");
        assertThat(body.getFirst().getEmail()).isEqualTo("user@email.com");
        assertThat(body.getFirst().getAge()).isEqualTo(20);
    }

    //More tests...
}

This test will bring up a PostgreSQL instance using Testcontainers, call our POST - /api/users method to create a new user. Then call the GET - /api/users endpoint to fetch the newly created user. This simulates the equivalent of running the application and database together, and calling our API endpoints in a browser or using tools like cURL or Postman.

Now we can run the integration test using the following command:

./gradlew test --tests full.bearded.dev.crud.app.user.UserIntegrationTest

View the repo for more integration test examples.

When to Use Each Type of Test

So far we have covered two types of tests:

However, there are many types of tests we can do depending on the purpose of the test. Here are three more common types of tests you may see:

End-to-End (E2E) Tests

Simulates a complete user flow from a frontend application to a backend and database. This is usually done outside of Spring Boot tests using tools such as Cypress or Selenium.

Regression Tests

Regression tests ensure that old features still work after you’ve made changes. They're not a specific tooling rather a testing method. They're usually written after bugs are found to prevent them from coming back.

Performance Tests

As mentioned in the name, these tests will test the performance of your application such as: speed, responsiveness, resource usage, and stability. The main goal is to identify bottlenecks and ensure the system meets performance requirements. Performance testing usually consists of a broad category of tests to measure how well your system performs under various conditions.

How to Use Each Test Type

Test TypeSpeedScopeUse Case
UnitFastSmallLogic in one service (a method)
IntegrationMediumMedium-largeSaving and fetching from a real database
End-to-EndSlowFull appSimulate a user performing an action in the app
RegressionVariedAnyRetesting a fixed bug
PerformanceVariedAnyTesting how much load your app can manage

What's Next?

So to recap we:

GitHub Example

Up Next: API Documentation with Swagger!