
As software grows and evolves, automated testing shifts from a luxury to an absolute necessity. Unit tests shine at ensuring each piece of the puzzle functions independently, but they can’t guarantee that the whole picture comes together perfectly. So, how do we make sure everything runs smoothly without manually testing every feature after every update? Enter integration tests — your secret weapon for making sure all components work in harmony, so you can deploy with confidence, knowing your application is rock-solid from end to end.
Introduction
In a recent project, we built a REST API behind an API Gateway, using Kafka for event-driven communication, PostgreSQL for persistent storage, and AWS Lambda for asynchronous operations. Given the distributed nature of the architecture, we integrated Testcontainers into our testing strategy to ensure reliable, realistic integration testing.
By spinning up real PostgreSQL and Kafka containers during tests, we were able to validate microservice interactions in an environment that closely mirrors production. These tests run automatically in our CI pipeline via GitHub Actions for every commit, eliminating the need for shared or preconfigured environments. This setup ensures fast, consistent feedback loops and higher confidence in service behavior.
The practical impact has been clear: fewer bugs reach production, development and QA cycles are faster, and developers are more confident in the safety of their changes. Onboarding has also improved, as new team members can run complete test suites locally without manual setup.
This approach reflects modern engineering trends - cloud-native development, shift-left testing, and self-contained environments - and positions us to build more resilient and scalable systems with greater efficiency.
Why Use Testcontainers?
The Problem:
Running integration tests often means ensuring that the entire infrastructure — databases, message brokers, and other services — is not only up and running but also pre-configured to a specific state. However, when these resources are shared across multiple users or CI pipelines, test results can become unpredictable due to issues like data corruption and configuration drift. While some developers attempt to bypass these challenges with in-memory databases, embedded services, or mocks, these solutions introduce their own set of problems. In-memory services, for instance, may lack critical features or behaved differently from their production counterparts, leading to false positives and negatives in test outcomes.
The Solution:
This is where Testcontainers comes into play. Testcontainers allows you to run your application’s dependencies, such as databases and message brokers, in isolated Docker containers, creating a consistent and reliable environment for your integration tests. By interacting with these real services instead of mock or in-memory alternatives, Testcontainers ensures that your tests are as close to the production environment as possible. This not only eliminates the unpredictability caused by shared resources but also provides a programmatic API that makes it easy to manage and control these containers within your test code. The result is more accurate, repeatable tests that give you confidence your application will behave as expected in the real world.
Create a Simple Spring Boot Application
Before we dive into writing integration tests with Testcontainers and Spring Boot, let’s start by building the application we’ll be testing. And where better to kick things off than at every Spring developer’s favorite playground — the Spring Initializr.
For our example, we’ll keep things straightforward: a simple controller that connects to a repository and stores books in a PostgreSQL database. We’ll be using Java 21, Gradle with Groovy, and the latest Spring Boot 3.3.2 — keeping it fresh and modern as of this writing.
To get our project up and running, let’s sprinkle in some magic with these must-have dependencies in your build.gradle file:
implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation "org.apache.httpcomponents.client5:httpclient5" runtimeOnly 'org.postgresql:postgresql' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:postgresql' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Here’s how we’re going to lay out our project — think of it as the blueprint for building our book-storing application:
. └── controller/ │ ├── dto/ │ │ └── BookDto.java // Book data transfer object │ ├── BookController.java // The Application entry point │ └── database │ ├── dao │ │ └── Book.java // The book database entity │ └── BookRepository.java // The book repository │ ├── build.gradle // Project configuration and settings └── application.properties // Application properties configuration
We’ll kick things off with the Book class, which will serve as our database entity. It’s a simple yet essential structure, featuring an id, author, and title — just what we need to start storing our books.
@Entity @NoArgsConstructor @AllArgsConstructor @Data @Builder public final class Book { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; @Column(nullable = false) private String title; @Column(nullable = false) private String author; }
With the Book class in place, it’s time to create the BookRepository to handle our database interactions. We’ll keep it simple by extending JpaRepository, giving us a powerful and streamlined way to manage our book data. Here’s how it will look like:
public interface BookRepository extends JpaRepository<Book, Long> { }
With our database classes in place, it’s time to configure our application. Add the following properties to your application.properties file to seamlessly integrate with our setup. For reference, the GitHub repo includes a Docker Compose file that sets up a PostgreSQL container with these connection properties:
spring.application.name=book server.port=8080 spring.datasource.url=jdbc:postgresql://localhost:5432/bookdb spring.datasource.username=bookUser spring.datasource.password=password spring.jpa.hibernate.ddl-auto=create-drop
Next up is our application’s entry point: the BookController. This will be a RestController with the /books request path. To keep things concise, I’ll show just the createBook method here, but you can check out my GitHub page for the full code. The controller is straightforward, with BookRepository as a dependency and basic validations, like checking if a book exists when fetching all books or ensuring the book has a title and author when creating a new one. Here’s how it looks:
@Slf4j @RestController @RequestMapping("/books") @RequiredArgsConstructor final class BookController { private final BookRepository bookRepository; @PostMapping ResponseEntity<Void> createBook(@RequestBody final BookDto bookDto) { try { if (!StringUtils.hasText(bookDto.getTitle()) || !StringUtils.hasText(bookDto.getAuthor())) { log.warn("Invalid book data: {}", bookDto); return ResponseEntity.badRequest().build(); } final var book = new Book(null, bookDto.getTitle(), bookDto.getAuthor()); bookRepository.save(book); log.info("Book created: {}", book); return ResponseEntity.status(HttpStatus.CREATED).build(); } catch (RuntimeException exception) { log.error("Error creating book", exception); return ResponseEntity.internalServerError().build(); } } }
As you might notice, the controller interacts with a BookDto instead of directly using the Book entity. This BookDto serves as a lightweight copy of the Book object, helping us keep the separation between the backend and frontend. Here’s what the BookDto looks like:
@AllArgsConstructor @Getter @Setter @EqualsAndHashCode @JsonInclude(JsonInclude.Include.NON_NULL) public class BookDto { private Long id; private String title; private String author; }
Writing Integration Tests with Testcontainers
Now that we’ve got our application up and running, it’s time to take things to the next level with integration tests using Testcontainers. With the Testcontainers dependency already in place, we’re all set to dive into creating robust tests that will ensure our application runs smoothly. First, make sure Docker is running, as Testcontainers relies on it to create and manage containers at runtime.
To kick things off, we’ll set up a custom PostgreSQL container. While it is possible to pull one directly from a Docker image, crafting custom classes allows us to have more control over our containers. We’ll create a singleton class that extends Testcontainers’ PostgreSQLContainer, giving us an adapted setup. Here’s what our custom container looks like:
public final class BookPostgresqlContainer extends PostgreSQLContainer<BookPostgresqlContainer> { private static final String IMAGE_VERSION = "postgres:latest"; private static BookPostgresqlContainer container; private BookPostgresqlContainer() { super(IMAGE_VERSION); } public static BookPostgresqlContainer getInstance() { if (container == null) { container = new BookPostgresqlContainer(); } return container; } @Override public void start() { super.start(); } }
With our container set up, we’re ready to dive into writing tests. Personally, I prefer to keep configuration separate from the test logic. So, we’ll start by creating a BaseIntegrationTest class to handle the setup, and then build out the CreateBookTest class that extends it. This approach keeps things clean and organized.
The BaseIntegrationTest class serves as our foundation, running a PostgreSQL container with the power of @SpringBootTest. Before any tests kick off, we’ll set up Spring’s RestTemplate to perform REST requests to our Spring Boot application. This will allow us to interact with our API just like a real user would. Additionally, we’ll start the PostgreSQLContainer, which will serve as the database for our application during testing. A key player here is the @Testcontainers annotation, which ensures that all fields annotated with @Container are managed properly — taking care of the lifecycle of our PostgreSQL container.
Next, we’ll configure our application properties to leverage the data from our container, including the username, password, and JDBC URL. This is done with the @DynamicPropertySource annotation from Spring, which allows us to dynamically inject these properties at runtime, ensuring that our application seamlessly connects to the containerized database during tests.
This setup keeps everything running smoothly and ready for action.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc @DirtiesContext @Testcontainers public class BaseIntegrationTest { @Container protected static final PostgreSQLContainer<BookPostgresqlContainer> postgreSQLContainer = BookPostgresqlContainer.getInstance(); protected static RestTemplate restTemplate; @LocalServerPort protected Integer port; @DynamicPropertySource static void registerDynamicProperties(final DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); registry.add("spring.datasource.username", postgreSQLContainer::getUsername); registry.add("spring.datasource.password", postgreSQLContainer::getPassword); registry.add("spring.datasource.driver-class-name", postgreSQLContainer::getDriverClassName); } @BeforeAll protected static void setup() { postgreSQLContainer.start(); restTemplate = new RestTemplate(); final HttpClient httpClient = HttpClientBuilder.create().build(); final HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); restTemplate.setRequestFactory(requestFactory); } @AfterAll protected static void tearDown() { postgreSQLContainer.stop(); } }
For our create-book integration tests, it’s as easy as extending the BaseIntegrationTest class to set up your configuration. Then, simply use RestTemplate to perform the requests. This approach keeps everything streamlined and your tests looking sharp. Check out the example below to see how effortlessly you can create clean and effective tests.
class CreateBookTest extends BaseIntegrationTest { private static final String AUTHOR_NAME = "Author"; private static final String BOOK_TITLE = "Title"; @Autowired private BookRepository bookRepository; @Test void createBookWithSuccess() { final var bookDto = new BookDto(null, BOOK_TITLE, AUTHOR_NAME); final var response = restTemplate.postForEntity( String.format("http://localhost:%d/books", port), bookDto, Void.class); assertEquals(HttpStatus.CREATED, response.getStatusCode()); bookRepository.findById(1L).ifPresentOrElse(book -> assertAll( () -> assertEquals(BOOK_TITLE, book.getTitle()), () -> assertEquals(AUTHOR_NAME, book.getAuthor()) ), () -> fail("Book not found")); } @Test void errorCreatingBookNoTitle() { final var bookDto = new BookDto(null, null, AUTHOR_NAME); try { restTemplate.postForEntity( String.format("http://localhost:%d/books", port), bookDto, Void.class); } catch (final HttpClientErrorException e) { assertEquals(HttpStatus.BAD_REQUEST, e.getStatusCode()); } } @Test void errorCreatingBookNoAuthor() { final var bookDto = new BookDto(null, BOOK_TITLE, null); try { restTemplate.postForEntity( String.format("http://localhost:%d/books", port), bookDto, Void.class); } catch (final HttpClientErrorException e) { assertEquals(HttpStatus.BAD_REQUEST, e.getStatusCode()); } } }
Conclusions
In this article, we’ve explored the crucial role of integration tests in ensuring our applications run smoothly from end to end. By leveraging Testcontainers, we’ve seen how to streamline the testing process, providing a clean and controlled environment for our tests. This approach not only simplifies managing dependencies but also enhances the reliability of our test results. With integration tests and Testcontainers in your toolkit, you can confidently deploy your applications, knowing that every component is carefully controlled and ready for action.
To check out the complete codebase and see everything in action, visit the GitHub repository page.