Spring Boot + DataJpaTest + Testcontainers: Testing JPA Repositories

Introduction

The @DataJpaTest annotation in Spring Boot is commonly used for testing JPA repositories in isolation. By default, it sets up an in-memory H2 database, but what if we want to test with a real database such as PostgreSQL without installing it locally?

This is where Testcontainers comes in.

What is Testcontainers?

Testcontainers is a Java library that allows running lightweight, disposable containers for databases, message queues, and other dependencies. It is particularly useful for integration testing as it provides:

Real database testing without requiring local installation.
Fresh database instances for each test execution.
Automatic cleanup of containers after tests.
Consistent testing environments across different machines.

Project Setup

To get started, create a Spring Boot project with the following dependencies:

  • Spring Data JPA – For database interaction.
  • PostgreSQL Driver – To connect to a PostgreSQL database.
  • Spring Boot Starter Test – Includes JUnit, AssertJ, and Mockito.
  • Testcontainers – To run a PostgreSQL database in a Docker container.

Spring Boot Testing Libraries

The spring-boot-starter-test dependency provides several useful testing tools:

  • JUnit – The core testing framework.
  • Spring Test & Spring Boot Test – Utilities for integration and context-based testing.
  • AssertJ – Fluent assertion library.
  • Mockito – A mocking framework for unit tests.
  • Testcontainers – Allows running a real PostgreSQL database in a container.

Implementation

1. pom.xml Configuration

To use Testcontainers with PostgreSQL, add the necessary dependencies to pom.xml.

File: pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>tech.devblueprint</groupId>
    <artifactId>datajpatest-testcontainers-spring-boot-testing-example</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>datajpatest-testcontainers-spring-boot-testing-example</name>
    <description>Demo project for Spring Boot</description>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</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>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

2. Database Configuration

By default, Spring Boot expects a database connection in application.yaml. However, since we are using Testcontainers, we don’t need a static database URL. Instead, we dynamically provide connection properties in the test class.

3. Entity Definition

File: src/main/java/.../entity/Device.java

package tech.devblueprint.datajpatest_spring_boot_testing_example.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "devices")
public class Device {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    
    private String type;

    private Double price;

    public Device() {
    }

    public Device(String name, String type, Double price) {
        this.name = name;
        this.type = type;
        this.price = price;
    }

    // Getters и Setters

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
         this.id = id;
    }

    public String getName() {
         return name;
    }

    public void setName(String name) {
         this.name = name;
    }

    public String getType() {
         return type;
    }

    public void setType(String type) {
         this.type = type;
    }

    public Double getPrice() {
         return price;
    }

    public void setPrice(Double price) {
         this.price = price;
    }
}

4. Repository Interface

File: src/main/java/.../repository/DeviceRepository.java

package tech.devblueprint.datajpatest_spring_boot_testing_example.repository;

import tech.devblueprint.datajpatest_spring_boot_testing_example.entity.Device;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;

public interface DeviceRepository extends JpaRepository<Device, Long> {

    // Derived Query
    List<Device> findByType(String type);

    // JPQL-Query
    @Query("FROM Device d WHERE d.price > ?1")
    List<Device> findDevicesByPriceGreaterThan(Double price);

    // Native SQL-Query
    @Query(value = "SELECT * FROM devices WHERE name = :name", nativeQuery = true)
    Optional<Device> findByName(String name);
}

Writing Tests with Testcontainers

Using a PostgreSQL Container in Tests

With Testcontainers, we can dynamically start a PostgreSQL container and register its connection details for Spring Boot.

File: src/test/java/.../repository/TestcontainersDeviceRepositoryTests.java

package tech.devblueprint.datajpatest_testcontainers_spring_boot_testing_example.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.jdbc.Sql;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import tech.devblueprint.datajpatest_testcontainers_spring_boot_testing_example.entity.Device;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

@Testcontainers
@DataJpaTest
@Sql({"/test-device-schema.sql"}) // Execute the schema creation script
public class TestcontainersDeviceRepositoryTests {

    // Start PostgreSQL container using Testcontainers
    @Container
    public static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres:16.3")
            .withDatabaseName("testdb")
            .withUsername("postgres")
            .withPassword("root");

    // Dynamically register properties so that Spring Boot uses the container's JDBC URL and credentials
    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
        registry.add("spring.datasource.username", postgresContainer::getUsername);
        registry.add("spring.datasource.password", postgresContainer::getPassword);
    }

    @Autowired
    private DeviceRepository deviceRepository;

    // Test using Testcontainers to verify that the repository works with the PostgreSQL container
    @Test
    @Sql({"/test-device-data.sql"}) // Execute the test data insertion script
    public void testFindByName_UsingTestcontainers() {
        Optional<Device> deviceOpt = deviceRepository.findByName("iPhone 13");
        assertThat(deviceOpt).isPresent();
        Device device = deviceOpt.get();
        assertThat(device.getName()).isEqualTo("iPhone 13");
        assertThat(device.getType()).isEqualTo("Smartphone");
        assertThat(device.getPrice()).isEqualTo(999.99);
    }
}

Key Annotations Explained:

  • @Testcontainers – Enables Testcontainers integration.
  • @Container – Defines a PostgreSQL container that runs for the duration of the test class.
  • @DynamicPropertySource – Registers the container’s JDBC URL and credentials dynamically.
  • @DataJpaTest – Loads only JPA-related components for testing.
  • @Sql("/test-device-schema.sql") – Runs the schema creation script before tests.
  • @Sql("/test-device-data.sql") – Loads test data before each test.

Understanding Transactions in @DataJpaTest

By default, tests annotated with @DataJpaTest run in transactions and automatically roll back at the end of each test. This ensures that each test starts with a clean database state.

However, if you need to disable transaction management, use @Transactional(propagation = Propagation.NOT_SUPPORTED), as shown below:

@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public class StudentRepositoryTests {

    @Autowired
    private StudentRepository studentRepository;

    @Test
    void findByName_ReturnsTheStudent() {
        // Test logic...
    }
}

This is useful when testing database operations that require commits, such as stored procedures or triggers.

Example SQL Scripts

SQL scripts should be placed in src/test/resources so that Spring Boot can automatically load them during test execution.

Schema Definition

File: src/test/resources/test-device-schema.sql

CREATE TABLE devices
(
    id    INT AUTO_INCREMENT PRIMARY KEY,
    name  VARCHAR(255) NOT NULL,
    type  VARCHAR(100),
    price DOUBLE
);

Test Data Insertion

File: src/test/resources/test-device-data.sql

INSERT INTO devices (id, name, type, price)
VALUES (1, 'iPhone 13', 'Smartphone', 999.99),
       (2, 'Galaxy S22', 'Smartphone', 899.99),
       (3, 'Dell XPS 13', 'Laptop', 1199.99),
       (4, 'MacBook Pro', 'Laptop', 1999.99);

Conclusion

In this guide, we explored how to use Testcontainers with @DataJpaTest to test JPA repositories with a real PostgreSQL database.