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:
- Do not test directly against production environments – not even against QA or Dev (in the first run).
- Tests should be executable locally – ideally even independent of the network connection or infrastructure.
- 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.
- 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.
- 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
Leave a Reply