Testing has become a critical component in today’s software development world. It helps us in ensuring high quality product that provides stability and scalability. In this article, we will explore about implementing tests for Spring Boot Web application.

Static Badge Gradle Plugin Portal Version GitHub License GitHub Actions Workflow Status GitHub Repo stars

Background

Spring Boot Testing components provides great convenience for us to test our implementation. In my experience, I found projects are still relying on mocks. The reason behind it is integration tests usually takes too long and expensive.

Such opinion was true in the past. However, today with Spring’s Test component and Testcontainers, integration tests no longer being a burden. We will look into the options in implementing tests using Spring Boot for a standard REST application.

The Application

The application is rather a simple REST application which consists of Spring Data JPA, Spring Security, and Spring Web. The typical components used in most Spring Boot applications.

We will start by implementing the repository component.

Entity & Repository

Given that we have the entity User, we will implement a Repository class that extends JpaRepository.

interface UserRepository extends JpaRepository<User, Long> {}

We want to allow the users to retrieve a User by username. However, we want to hide their id information and to simplify name - instead of having first and last name, we will just return their full name. For this we will use a Projections called UserWithoutId.

interface UserRepository extends JpaRepository<User, Long> {

    @NativeQuery(
            name = "User.findByUsername",
            value = "SELECT CONCAT_WS(' ', first, last) as name,  username, status FROM users WHERE username = ?1",
            sqlResultSetMapping = "User.WithoutId"
    )
    Optional<UserWithoutId> findByUsername(String username);

}

Since we have a custom implementation in UserRepository, we will implement a test to ensure that it is behaving as expected. For this we will use @DataJpaTest which will load sufficient components for us to run a JpaRepository test.

We will implement two scenarios in UserRepositoryTests - find by username with an existing username and find by username with a non-existing username.

@Testcontainers
@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
class UserRepositoryTests {

    @Container
    @ServiceConnection
    private static final PostgreSQLContainer<?> postgresql = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest"));

    @Autowired
    private UserRepository repository;

    @Test
    @DisplayName("Given there the username rashidi.zin exists When I find by the username Then I should receive a summary of the user")
    @Sql(statements = "INSERT INTO users (id, first, last, username, status) VALUES (1, 'Rashidi', 'Zin', 'rashidi.zin', 0)")
    void findByUsername() {
        var user = repository.findByUsername("rashidi.zin");

        assertThat(user).get()
                .extracting("name", "username", "status")
                .containsExactly("Rashidi Zin", "rashidi.zin", ACTIVE);
    }

    @Test
    @DisplayName("Given there the username zaid.zin does not exist When I find by the username Then I should receive empty optional")
    void findByNonExistingUsername() {
        var user = repository.findByUsername("zaid.zin");

        assertThat(user).isEmpty();
    }

}
Annotations being used in the test above are:
  • @Testcontainers - Enabling Testcontainers support for this test

  • DataJpaTest - Load Spring Data JPA’s related components

  • @Container - Allow Testcontainers manage the lifecycle of PostgreSQLContainer

  • @ServiceConnection - Automatically assign DataSource related properties

  • @Sql - Load test data

With that, we have verified that UserRepository.findByUsername is behaving as expected. Full implementation can be found in user package. For other database types, I wrote articles on using @DataJdbcTest and using @DataMongoTest

Next, we will implement the web components.

Web Implementation

Our web components involves:
  • WebSecurityConfiguration - contains a simple HTTP Basic authentication

  • UserResource - implements a GET method to a retrieve user information and a ExceptionHandler that will return NOT_FOUND for non-existing username:

@RestController
class UserResource {

    private final UserRepository repository;

    UserResource(UserRepository repository) {
        this.repository = repository;
    }

    @GetMapping(value = "/users/{username}", produces = APPLICATION_JSON_VALUE)
    public UserWithoutId findByUsername(@PathVariable String username) {
        return repository.findByUsername(username).orElseThrow(InvalidUserException::new);
    }

    @ExceptionHandler(InvalidUserException.class)
    @ResponseStatus(NOT_FOUND)
    public void handleInvalidUserException() {}

    static class InvalidUserException extends RuntimeException {}

}

Testing with @WebMvcTest

If long-running time is a concern, @WebMvcTest would be a suitable approach as it will only load web related components. It allows us to mock any of its dependencies and arrange suitable behaviour for them. In the following implementation, we will mock (or arrange) the behaviour of UserRepository.findByUsername:

In findByUsername, we will arrange that it will return Optional containing UserWithoutId. We will expect that the response will be HTTP OK. While in findByNonExistingUsername, we arrange that it will return an empty Optional. This will lead to InvalidUserException being thrown and translated to HTTP NOT_FOUND.

@WebMvcTest(controllers = UserResource.class, includeFilters = @Filter(EnableWebSecurity.class))
class UserResourceTests {

    private static MockMvcTester mvc;

    @MockitoBean
    private UserRepository repository;

    @BeforeAll
    static void setup(@Autowired WebApplicationContext context) {
        mvc = from(context, builder -> builder.apply(springSecurity()).build());
    }

    @Test
    @WithMockUser
    @DisplayName("Given username rashidi.zin exists When when I request for the username Then the response status should be OK")
    void findByUsername() {
        var fakeUser = Optional.of(new UserWithoutId("Rashidi Zin", "rashidi.zin", ACTIVE));

        doReturn(fakeUser).when(repository).findByUsername("rashidi.zin");

        mvc
                .get().uri("/users/{username}", "rashidi.zin")
                .assertThat()
                .hasStatus(OK);

        verify(repository).findByUsername("rashidi.zin");
    }

    @Test
    @WithMockUser
    @DisplayName("Given username rashidi.zin does not exist When when I request for the username Then the response status should be NOT_FOUND")
    void findByNonExistingUsername() {
        doReturn(empty()).when(repository).findByUsername("rashidi.zin");

        mvc
                .get().uri("/users/{username}", "rashidi.zin")
                .assertThat()
                .hasStatus(NOT_FOUND);

        verify(repository).findByUsername("rashidi.zin");
    }

    @Test
    @DisplayName("Given there is no authentication When I request for the username Then the response status should be UNAUTHORIZED")
    void findByUsernameWithoutAuthentication() {
        mvc
                .get().uri("/users/{username}", "rashidi.zin")
                .assertThat().hasStatus(UNAUTHORIZED);

        verify(repository, never()).findByUsername("rashidi.zin");
    }

}
Methods and annotations used in the test above:
  • @WebMvcTest - Our test will only focus on UserResource and we will load security configuration from WebSecurityConfiguration

  • SecurityMockMvcConfigurers.springSecurity() - Enable Spring Security support for MockMvcTester

  • @WithMockUser - Mocks user authentication. Without it the response will be UNAUTHORIZED as demonstrated in findByUsernameWithoutAuthentication

  • @MockitoBean - Mocks UserRepository since we have verified that it works correctly in UserRepositoryTests

  • Mockito.verify - Verifies that UserRepository.findByUsername was either triggered (when user is authenticated) or not

Given that UserResourceTests is specifically for UserResource and only necessary components are loaded, its execution should be fast.

Testing with @SpringBootTest

@SpringBootTest, by default, will load all components. In our case, it will expect there is a running PostgreSQL and the properties are assigned. This is handled by TestcontainersConfiguration and we will import it into our test - FindByUsernameTests.

We will implement the same test scenarios as we did in UserResourceTests:

@Import(TestcontainersConfiguration.class)
@SpringBootTest(webEnvironment = RANDOM_PORT, properties = {
        "spring.jpa.hibernate.ddl-auto=create-drop",
        "spring.security.user.name=rashidi.zin",
        "spring.security.user.password=jU$7d3m0pL3a$eRe|ax"
})
@Sql(executionPhase = BEFORE_TEST_CLASS, statements = "INSERT INTO users (id, first, last, username, status) VALUES (1, 'Rashidi', 'Zin', 'rashidi.zin', 0)")
class FindByUsernameTests {

    @Autowired
    private TestRestTemplate restClient;

    @Test
    @DisplayName("Given username rashidi.zin exists When I request for the username Then response status should be OK and it should contain the summary of the user")
    void withExistingUsername() {
        var response = restClient
                .withBasicAuth("rashidi.zin", "jU$7d3m0pL3a$eRe|ax")
                .getForEntity("/users/{username}", UserWithoutId.class, "rashidi.zin");

        assertThat(response.getStatusCode()).isEqualTo(OK);

        var user = response.getBody();

        assertThat(user)
                .extracting("name", "username", "status")
                .containsExactly("Rashidi Zin", "rashidi.zin", ACTIVE);
    }

    @Test
    @DisplayName("Given username zaid.zin does not exist When I request for the username Then response status should be NOT_FOUND")
    void withNonExistingUsername() {
        var response = restClient
                .withBasicAuth("rashidi.zin", "jU$7d3m0pL3a$eRe|ax")
                .getForEntity("/users/{username}", UserWithoutId.class, "zaid.zin");

        assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND);
    }

    @Test
    @DisplayName("Given there is no authentication When I request for the username Then response status should be UNAUTHORIZED")
    void withoutAuthentication() {
        var response = restClient.getForEntity("/users/{username}", UserWithoutId.class, "rashidi.zin");

        assertThat(response.getStatusCode()).isEqualTo(UNAUTHORIZED);
    }

}
In FindByUsernameTests, we have:
  • Import PostgreSQLContainer from Testcontainers that is defined in TestcontainersConfiguration

  • Define default username and password through spring.security.user.name and spring.security.user.password

  • Insert test data prior to running the class

In withExistingUsername, we implement the same verification in UserResourceTests.findByUsername() and UserRepositoryTests.findByUsername(). The same goes to withNonExistingUsername and withoutAuthentication whereby its verification is the same as UserResourceTests.findByNonExistingUsername(), UserRepositoryTests.findByNonExistingUsername(), and UserResourceTests.findByUsernameWithoutAuthentication()

If you find this is redundant, you are right. Given that FindByUsernameTests is an end-to-end integration test class, we could rely on solely on it. As for implementations in UserResourceTests and UserRepositoryTests can be removed.

Conclusion

Wherever possible, I will always favour using @SpringBootTest as it allows me to ensure that the whole application is behaving accordingly. However, as mentioned earlier, if the @SpringBootTest class takes too long to run then I’d go with @WebMvcTest. It is less desire as the test will be affected should the production code implementation changes. For example, a refactoring.

With @SpringBootTest, I am able to implement my tests following Behaviour Driven Development easily as opposed to using @WebMvcTest as I don’t have to be concerned about the feature’s implementation.

In the end, choose the ones that provide you with the efficiency to maintain and to run your tests. Either with @SpringBootTest or the combination of @WebMvcTest and @DataJpaTest.