Introduction to Spring Boot Application Testing for Beginners – A Practical Guide (Part 3)

To make the whole thing more practical,  I have prepared a very simplified example application based on Spring Boot. So you can follow every step of the series hands-on and test it directly yourself.

Welcome to the third and final part of the blog series. Although Part 1 and Part 2 are not essential for understanding this article, they provide a solid foundation and practical examples for testing the web and service layers.

As explained in the previous articles, we use slice tests specifically to test isolated layers of our application. They do not replace integration tests, but complement them in a meaningful way by expanding our test coverage in a targeted and efficient manner.

Focus of this section: Testing the repository layer

The repository class forms the interface between the Spring Boot application and the underlying database – and is therefore an essential component of the business logic. For this reason, this layer should also be secured by tests.

A central example in this article is the findAllByTitle method. This returns all posts paginated that:

  • contain the transferred search term in the title (case insensitive)
  • were created within a defined LocalDateTime window

The method uses the annotation @Query to define its own JPQL query. Alternatively, we could also let Spring Data JPA generate queries from method names independently – see the official documentation. As we return a page, a countQuery must also be specified. This counts the total hits for the pagination – otherwise Spring would not be able to calculate the page size correctly.

Note: The JPA repository is completely sufficient for simple filter and search operations. For more complex queries or full-text searches, the use of external tools such as OpenSearch would be my choice.

It should also be noted that JpaRepository is not used directly here, but rather BaseJpaRepository from the Hypersistence Utils Library. This repository interface was developed to avoid so-called repository anti-pattern. You can find out more about this in Vlad Mihalcea’s blog post and his Github Repository.

@Repository
public interface PostRepository extends BaseJpaRepository<Post, UUID>,
    ListPagingAndSortingRepository<Post, UUID> {

  @Query(value = """
      SELECT p
      FROM Post p
      WHERE (LOWER(p.title) LIKE LOWER(CONCAT('%', :title, '%')))
      AND (p.createdAt >= :from)
      AND (p.createdAt <= :to)
      ORDER BY p.createdAt DESC
      """,
      countQuery = """
          SELECT COUNT(p)
          FROM Post p
          WHERE (LOWER(p.title) LIKE LOWER(CONCAT('%', :title, '%')))
          AND (p.createdAt >= :from)
          AND (p.createdAt <= :to)
          """)
  Page<Post> findAllByTitle(
      @Param("title") String title,
      @Param("from") LocalDateTime from,
      @Param("to") LocalDateTime to,
      Pageable pageable);

// ... other Code ...
}

Repository tests: What to look out for?

So how do we test this method in a meaningful and practical way?

Here are a few basic recommendations:

  1. Do not test directly against production environments – not even against QA or Dev (in the first run).
  2. Tests should be executable locally – ideally even independent of the network connection or infrastructure.
  3. Minimise dependencies – the tests should be easily reproducible on every developer computer.

Our solution of choice ist Testcontainers. Testcontainers offers a way to run tests with real, containerised databases. Instead of accessing external database instances, we start a Docker instance with a defined database image for each test run. The big advantage: the database is always consistent and independent of the host system. This means that the test environment always remains the same – reproducible, stable and quickly ready for use.

Structure of the test class

Before we create our test class, we need a small preparatory measure in the test directory. We need to define our own start class (TestMain) so that Testcontainers works correctly in combination with Spring Boot:

public class TestMain {
  public static void main(String[] args) {
       SpringApplication
          .from(Main::main)
          .with(TestcontainersConfiguration.class)
          .run(args);
  }
}

Annotations of the test class

In the following, I will explain the most important annotations that our test class requires:

  • @DataJpaTest: This annotation defines the slice test for database access. It does not load the entire Spring Context, but only the components relevant for JPA.
  • @Testcontainers: Activates the use of test containers in the test. Docker containers can thus be started automatically when the test is started.
  • @AutoConfigureTestDatabase(replace = Replace.NONE): @DataJpaTest implicitly includes an auto configuration for in-memory databases. However, since we use an external database via test containers, this annotation prevents Spring from forcing its own (non-configured) data source – otherwise an error message would be displayed.
  • @EnableJpaRepositories: This annotation is only necessary if – as in my case – you are not using the standard JpaRepository but, for example, the BaseJpaRepository from Hypersistence Utils. The repository configuration is not loaded automatically in slice tests, so it must be specified manually.
  • @TestInstance(TestInstance.Lifecycle.PER_CLASS): Enables the use of non-static methods with @BeforeAll, as already explained in the second part of the series.
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
@EnableJpaRepositories(
    value = "net.fungiloid*",
    repositoryBaseClass = io.hypersistence.utils.spring.repository.BaseJpaRepositoryImpl.class
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PostServiceTest {
  @Autowired
  PostRepository postRepository;

  @Autowired
  UserRepository userRepository;
  ...
}

Since @DataJpaTest loads part of the Spring context, we can simply inject our repositories via @Autowired. Spring recognises these and provides them automatically in the test context.

Database container with Testcontainer

  @Container
  @ServiceConnection
  static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
  • @Container: Identifies the resource as a test container that is automatically started at the beginning of the test and shut down again at the end.
  • @ServiceConnection: This annotation ensures the automatic connection of the Spring Boot Application Context with the database in the container.

Optionally, connection properties can also be set manually – the annotation @DynamicPropertySource can be used for this. An example of this can be found in the official test container documentation. (Equally helpful: the modules overview contains many practical examples for various technologies)

Test the connection

@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
@EnableJpaRepositories(
    value = "net.fungiloid*",
    repositoryBaseClass = io.hypersistence.utils.spring.repository.BaseJpaRepositoryImpl.class
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PostRepositoryTest {

  @Autowired
  PostRepository postRepository;

  @Autowired
  UserRepository userRepository;

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

  @Test
  void connectionEstablished() {
    assertThat(postgres.isCreated()).isTrue();
  }
}

Setup: Preparation of the test data

Before we turn our attention to the actual test cases, let’s take a look at the setup – the preparatory steps that ensure that each test runs under consistent conditions.

  1. creation of a dummy user

As the post-model expects a user as author, we create a dummy user once before starting all tests. This is done in the method annotated with @BeforeAll:

@BeforeAll
void setup() {
  user = userRepository.persist(
      new User()
          .setDisplayName("test-user")
          .setKey("test-key")
          .setEmail("test.hall@gmx.de")
          .setFirstName("firstname")
          .setLastName("lastname"));
}

This user serves as the creator of all posts that are used in the tests.

  1. cleaning up and creating new test data before each test

To ensure that all tests run independently of each other and always start with the same initial data, we delete existing posts at the start of each test and create new ones:

@BeforeEach
void clearPosts() {
    postRepository.deleteAllByIdInBatch(postIds);
    postIds = populateDateRangePosts(LocalDateTime.now());
    entityManager.flush();
}

Insert: Dealing with the createdAt timestamp

The createdAt field in the post entity stores the creation date of a post and is provided with the annotation @CreationTimestamp:

public class Post implements Taggable, Categorizable {
  // ... more code
  
  @CreationTimestamp
  @Column(name = "created_at", nullable = false, updatable = false)
  private LocalDateTime createdAt;
  
  // ... more code
  
}

This annotation ensures that the current timestamp is automatically set when a new Post object is persisted. However, this is a hindrance for our tests, as we want to specifically check whether the repository method filters correctly according to time periods. To do this, we need defined, controllable values for createdAt.

To tackle this problem, we set the timestamp directly in the database using a native query – despite the fact that the field is actually unchangeable (updatable = false). In this way, we can ensure that every test works with the same time-stamped data.

private List<UUID> populateDateRangePosts(LocalDateTime now) {
    Post post1 = new Post();
    post1.setTitle("First Post");
    post1.setCreator(user);
    post1 = postRepository.persist(post1);
    updateCreatedAt(post1, now.minusDays(3));
    
    Post post2 = new Post();
    post2.setTitle("Second Post");
    post2.setCreator(user);
    post2 = postRepository.persist(post2);
    updateCreatedAt(post2, now.minusDays(2));
    
    Post post3 = new Post();
    post3.setTitle("Third Post");
    post3.setCreator(user);
    post3 = postRepository.persist(post3);
    updateCreatedAt(post3, now.minusDays(1));
    
    return List.of(post1.getId(), post2.getId(), post3.getId());
}

private void updateCreatedAt(Post post, LocalDateTime newCreatedAt) {
    entityManager.createNativeQuery("UPDATE post SET created_at = ?1 WHERE id = ?2")
        .setParameter(1, newCreatedAt)
        .setParameter(2, post.getId())
        .executeUpdate();
    entityManager.flush();
    entityManager.clear();
}

The test class therefore looks like this:

@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
@EnableJpaRepositories(
    value = "net.fungiloid*",
    repositoryBaseClass = io.hypersistence.utils.spring.repository.BaseJpaRepositoryImpl.class
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class PostRepositoryTest {
  @Autowired
  PostRepository postRepository;
  @Autowired
  UserRepository userRepository;
  @Container
  @ServiceConnection
  static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

  @Autowired
  EntityManager entityManager;

  User user;
  List<UUID> postIds = List.of();

  @BeforeAll
  void setup() {
    user = userRepository.persist(
        new User()
            .setDisplayName("test-user")
            .setKey("test-key")
            .setEmail("test.hall@gmx.de")
            .setFirstName("firstname")
            .setLastName("lastname"));
  }

  @BeforeEach
  void clearPosts() {
    postRepository.deleteAllByIdInBatch(postIds);
    postIds = populateDateRangePosts(LocalDateTime.now());
    entityManager.flush();
  }

  @Test
  void connectionEstablished() {
    assertThat(postgres.isCreated()).isTrue();
  }  

  private List<UUID> populateDateRangePosts(LocalDateTime now) { ... }

  private void updateCreatedAt(Post post, LocalDateTime newCreatedAt) { ... }
}

Testcases

Once we have completed the configuration for slice tests and test containers, we can now specifically test the repository logic – specifically the findAllByTitle method, which filters by title and also takes a time period into account.

Goal of the test

The repository method should:

  • return posts whose titles contain a specific string (case insensitive),
  • only consider results in the specified time period,
  • paginate the results and sort by createdAt descending order.

Test case: Filtering by title

This test checks whether the repository method filters correctly for a specific title – regardless of capitalisation. As only one post has exactly this title, we expect exactly one result. (getEffectiveDateRange(null, null) returns an array of LocalDateTime ranging from 1960 to now)

@Test
@DisplayName("Filter posts by title: Only matching posts are returned")
void shouldReturnPost_WhenTitleMatchesExactly() {
    Pageable pageable = PageRequest.of(0, 10);
    Page<Post> result = postRepository.findAllByTitle(
        "First Post",
        getEffectiveDateRange(null, null)[0],
        getEffectiveDateRange(null, null)[1],
        pageable);
    
    assertThat(result.getTotalElements()).isEqualTo(1);
    Post found = result.getContent().get(0);
    assertThat(found.getTitle()).containsIgnoringCase("First Post");
}

Test case: Combination of title & time filter with sorting

Here we test:

  • Whether only posts in the defined time window (2 days back to today) are taken into account.
  • Whether the sorting according to createdAt DESC works correctly.

Two hits are expected (“Third Post” and “Second Post”), with “Third Post” being the most recent.

@Test
@DisplayName("Filter posts by title and date range: Correct posts are returned in sorted order")
void shouldReturnPostsInCorrectOrder_WhenFilteringByTitleAndDateRange() {
    LocalDateTime now = LocalDateTime.now();
    Pageable pageable = PageRequest.of(0, 10);
    LocalDateTime from = now.minusDays(2).toLocalDate().atStartOfDay();
    Page<Post> result = postRepository.findAllByTitle("Post", from, now, pageable);
    
    assertThat(result.getTotalElements()).isEqualTo(2);
    List<Post> posts = result.getContent();
    assertThat(posts.get(0).getCreatedAt()).isAfter(posts.get(1).getCreatedAt());
    assertThat(posts.get(0).getTitle()).isEqualTo("Third Post");
    assertThat(posts.get(1).getTitle()).isEqualTo("Second Post");
}

Test case: No hit due to unsuitable time period

In this case, the specified time period is outside the creation time of all existing posts.

@Test
@DisplayName("No posts are found due to non-matching date range")
void shouldReturnNoPosts_WhenDateRangeDoesNotMatchAnyPost() {
    LocalDateTime now = LocalDateTime.now();
    Pageable pageable = PageRequest.of(0, 10);
    LocalDateTime from = now.minusDays(5).toLocalDate().atStartOfDay();
    LocalDateTime to = now.minusDays(4).toLocalDate().atStartOfDay();
    Page<Post> result = postRepository.findAllByTitle("Post", from, to, pageable);
    
    assertThat(result.getTotalElements()).isEqualTo(0);
}

Testcase: No hit due to invalid title

In this case, the specified time period is outside the creation time of all existing posts.

@Test
@DisplayName("No posts are found due to invalid title filter")
void shouldReturnNoPosts_WhenTitleDoesNotMatchAnyPost() {
    LocalDateTime now = LocalDateTime.now();
    Pageable pageable = PageRequest.of(0, 10);
    LocalDateTime from = now.minusDays(2).toLocalDate().atStartOfDay();
    Page<Post> result = postRepository.findAllByTitle("NoValidPostTitle", from, now, pageable);
    
    assertThat(result.getTotalElements()).isEqualTo(0);
}

Conclusion

These tests demonstrate how slice tests and test containers can be used to specifically validate the repository layer – with a high level of control over the test data, complete isolation and a reproducible environment.

The advantages:

  • Clear delimitation of the tested layer
  • No overhead due to the complete Spring Context
  • Realistic database environment due to test containers

If you find a mistake, have suggestions or simply want to give feedback on my blog – I’m always happy to hear from you! Feel free to write to me, I’m open to any feedback. 😊
You can contact me at: blog.giuseppe.clinaz@gmail.com or via LinkedIn.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *