Modern Integration Testing in Java with Testcontainers (Spring Boot Example)

I’ve lost count of how many times tests passed on my laptop but failed in CI. Most of the time, the culprit was mocked dependencies that didn’t behave like the real thing — or a slow, fragile test environment. Testcontainers solves this problem in a surprisingly elegant way.
In this post, I will demonstrate how to utilize real Docker containers to run your integration tests using Java. This is also my first blog post, so I could not think of a better place to share my thoughts about Testcontainers.
As a software engineer with a passion for building robust software systems, I have always been fascinated by discovering new tools that improve the developers' experience. I recently learned how powerful Testcontainers can be, and I wanted to document that journey.
What is integration testing?
Integration testing is a software testing method that focuses on verifying the interactions between different software components or modules to ensure they work together as expected.
To understand the above definition in more detail, let's give an example. Imagine I have a backend system that books a padel court. To keep it simple, the backend system will have just one HTTP endpoint to book the court (it does not involve any payment logic)
The rest endpoint will look like this
POST /v1/padel
{
courtId: <courtId>
startTime: <startTime>
endTime: <endTime>
}
Rest endpoint
and it involves the following steps
- Read the request body
- Check if the court is already booked
- If booked, it returns a 403 forbidden HTTP status code along with the error in the response body.
- If it is free, Add a new record that marks the court as booked and returns a 201 HTTP status code and the created record.
If you look at the above logic, you will notice that it involves storing and querying from a database, regardless of the type of database that you are using, which means that we have two dependencies or components in our system: the backend system and the database.
Testing the interactions between these two components or dependencies is called ... Integration testing.
This is a simple example, as we just have two components in our system, but your system might entail several components, for example, a database, another backend system, and queues, which will make the integration testing more complex.
How can we test the above example?
As a continuation of the above example (padel court booking), let's think of how we can test these interactions between the backend system and the database.
Using mocks
To simulate the database response, we need a way to bypass the real database connection and simulate the response of the database query based on the request body.
In the Spring framework in Java, it will look like this
BookingRepository localMockRepository = Mockito
.mock(BookingRepository.class);
Mockito
.when(localMockRepository.save())
.thenReturn(new Booking(
125, // id
"court1", // court id
"paid", // payment status
LocalDateTime.now(), // start time
LocalDateTime.now().plusHours(1.5) // end time
)
);
As you see localMockRepository
represents the interaction with the database, therefore, we faked its response to simulate the database response
This approach has some limitations
- It does not run the actual database query that is responsible for getting the data, which means if there is any semantic or syntax error in the database query, the error will not be caught.
- This approach tends to be a unit test, so this test can be described as a unit test because it does not involve any testing for components interactions.
- A lot of test cases will pass while in fact the system has some bugs because the interaction is being faked by mocking the response.
Using a real environment
To implement proper integration testing for this example, we need to set up a testing environment where we can run our test cases.
For our padel court example, we need
- A special profile for integration testing in your framework, so that we can configure the application to point to the integration testing environment
- A dedicated database instance for running the integration tests.
Now, with this setup, when you invoke the actual endpoint v1/padel
in your test, it will go through the entire flow, and the database query will be executed in the dedicated database instance
The Problem with Real Environments
So then, what’s the issue if we’ve already set things up properly, just like we explained above, using a real environment?
The core problem lies in how painful it is to work with a real environment when it comes to integration testing. Let’s walk through some of the downsides.
- There’s typically only one environment available for running integration tests, and it’s usually reserved for the CI/CD pipeline.
- You need to make sure your dependencies are left in a clean state after every run — otherwise, the next run might break due to leftover or corrupted data. (Say, you insert some records at the beginning of your tests — you’ll have to clean those up at the end, otherwise the next run for the entire test suite will fail.)
- Since we’re limited to a single shared environment, running these tests locally becomes tricky. A local run could interfere with an existing one, so now you’re forced to spin up the same setup on your machine just to work on or maintain the tests.
- These tests can’t be bundled with unit tests either — they need a separate profile since they rely on external test dependencies (like a test DB).
- And finally, a real environment means overhead — it has to be maintained and kept in sync with production. If not, you risk flaky or misleading test results.
This is where Testcontainers comes into play.
What is Testcontainers?
Testcontainers is an open-source framework that simplifies integration testing by providing lightweight, disposable Docker containers for dependencies like databases, message queues, or web services.
It allows developers to test against real, reproducible environments instead of relying on mocks or in-memory services. This leads to more accurate and reliable integration tests that closely resemble production environments.
So, in short, it uses Docker to spin up the integration testing environment directly from the codebase, making it much easier to run integration tests in a consistent and automated way.
Let's implement it
You can find the complete source code for this example on GitHub:
Padel Court Booking – Full Code Repository
Setting Up Testcontainers in Spring
Now let's see how we can use Testcontainers in Spring Framework in Java using the padel court example.
So now let's see the code for which we need to create an integration test
Booking.java
package com.padelbooking.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.time.Duration;
@Entity
@Table(name = "bookings")
public class Booking {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "court_id", nullable = false)
private String courtId;
@Column(name = "payment_status", nullable = false)
private String paymentStatus;
@Column(name = "booking_duration", nullable = false)
private Integer bookingDuration;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "start_time", nullable = false)
private LocalDateTime startTime;
@Column(name = "end_time", nullable = false)
private LocalDateTime endTime;
// Constructors
public Booking() {
this.createdAt = LocalDateTime.now();
}
public Booking(String courtId, LocalDateTime startTime, LocalDateTime endTime) {
this.courtId = courtId;
this.createdAt = LocalDateTime.now();
this.startTime = startTime;
this.endTime = endTime;
// legacy fields kept for schema compatibility
this.paymentStatus = "unknown";
this.bookingDuration = (int) Duration.between(startTime, endTime).toMinutes();
}
// Getters and Setters
}
Booking.java
package com.padelbooking.controller;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.padelbooking.entity.Booking;
import com.padelbooking.model.BookingRequest;
import com.padelbooking.service.BookingService;
import java.util.List;
@RestController
@RequestMapping("/v1")
public class BookingController {
private final BookingService bookingService;
public BookingController(BookingService bookingService) {
this.bookingService = bookingService;
}
@PostMapping("/padel")
public ResponseEntity<?> bookPadelCourt(@RequestBody BookingRequest bookingRequest) {
try {
// 1. Read request body (Spring does this)
// 2. Service checks conflicts
Booking booking = bookingService.createBooking(bookingRequest);
// 4. Return 201 with created record
return ResponseEntity.status(HttpStatus.CREATED).body(booking);
} catch (IllegalArgumentException e) {
// Invalid input like missing/invalid times
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("{\"error\": \"" + e.getMessage() + "\"}");
} catch (RuntimeException e) {
// Court is already booked - return 403 Forbidden
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("{\"error\": \"" + e.getMessage() + "\"}");
}
}
@GetMapping("/bookings")
public ResponseEntity<List<Booking>> getAllBookings() {
return ResponseEntity.ok(bookingService.getAllBookings());
}
}
BookingController.java
package com.padelbooking.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import com.padelbooking.entity.Booking;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface BookingRepository extends JpaRepository<Booking, Long> {
@Query("SELECT b FROM Booking b WHERE b.courtId = :courtId AND " +
"((b.startTime <= :endTime AND b.endTime >= :startTime))")
List<Booking> findOverlappingBookings(@Param("courtId") String courtId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
List<Booking> findByCourtId(String courtId);
}
BookingRepository.java
Now let's configure the test containers in Spring
First, let's add the required dependencies. Using Maven, you can add the following to dependencyManagement
section in your pom.xml
:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.21.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
and then use dependencies without specifying a version:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
Also, since we are going to test a REST endpoint, we will use the RestAssured library, which is great for validating and testing REST services
Writing the First Integration Test
Now we have the Testcontainers available in our codebase, therefore let's start to build the first integration test
As an organizational measure, I’ll create a base class AbstractIntegrationTest.java
for our integration tests that will:
- Use
@SpringBootTest
to bootstrap the Spring environment and start a local server, allowing our tests to call REST endpoints directly without manually creating or launching a server for the target service. - Leverage Testcontainers to spin up all required dependencies for the service under test (in our case, a PostgreSQL database). This is achieved using @Testcontainers and @Container annotations from Testcontainers Java library.
- Set an active profile for integration tests
like that, every time you create a test class, you do not need to redo all this setup again, which will also improve the readability of the test code and reduce the boilerplate
It will look like this
package com.padelbooking;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.junit.jupiter.api.BeforeEach;
import io.restassured.RestAssured;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(
classes = PadelCourtBookingApplication.class,
webEnvironment = WebEnvironment.RANDOM_PORT
)
@ActiveProfiles("integration-test")
@Testcontainers
abstract class AbstractIntegrationTest {
@LocalServerPort
private int port;
@BeforeEach
void setupRestAssured() {
RestAssured.baseURI = "http://localhost";
RestAssured.port = port;
}
@Container
@SuppressWarnings("resource")
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:14.18"))
.withUsername("padel")
.withPassword("padel")
.withDatabaseName("padel_booking");
@DynamicPropertySource
static void datasourceProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
AbstractIntegrationTest.java
If you notice, we provide Spring with the database details via the @DynamicPropertySource
annotation. This way, the Spring instance created during the test run is linked to the PostgreSQL database started by Testcontainers in the same run.
It’s also worth noting that in the base class, we initialize RestAssured before each test to capture the port assigned by Spring to the service instance created during the test run.
And finally, let's write our test code
package com.padelbooking;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import io.restassured.http.ContentType;
public class BookingControllerIntegrationTest extends AbstractIntegrationTest {
@Test
void testCreateBooking() {
String payload = "{" +
"\"courtId\":\"court-it-1\"," +
"\"startTime\":\"2030-01-01T10:00:00\"," +
"\"endTime\":\"2030-01-01T11:00:00\"" +
"}";
given()
.contentType(ContentType.JSON)
.body(payload)
.when()
.post("/v1/padel")
.then()
.statusCode(201)
.body("id", notNullValue())
.body("courtId", equalTo("court-it-1"))
.body("startTime", startsWith("2030-01-01T10:00:00"))
.body("endTime", startsWith("2030-01-01T11:00:00"));
}
}
BookingControllerIntegrationTest.java
Simply, you need to create a test class like this and inherit from the base class AbstractIntegrationTests.java
In the above test class, we tested the most happy scenario where we sent a correct payload to the api, and we asserted that the api response code must be 201 and the rest of the fields are correct as expected.
Look how beautiful and easy the integration test is now, with Testcontainers , it feels like you are writing a unit test.
⚠️ Things to Keep in Mind:
Testcontainers works great most of the time, but there are a few things to keep in mind.
- Needs Docker – If Docker isn’t running on your machine or CI server, the tests won’t start.
- Takes some time to start – Spinning up containers isn’t instant. It’s fine for integration tests, but not something you’d use for quick unit tests.
- Uses resources – Each container takes some CPU and memory. Running many at once can slow things down.
- Needs internet for images – The first time you pull a container image, it can be slow, especially on a bad connection.
- Extra setup in CI – Some CI tools need special setup to run Docker.
Knowing these limits early will save you from headaches later and help you set up your tests and CI in the right way.
Final Thoughts
Testcontainers sits nicely between slow, messy “real environment” tests and fake, mock-heavy unit tests. It lets you test with the same tools you use in production — without having to set up and clean up everything yourself.
From my experience:
- Realistic tests are worth a little extra time.
- Throwaway environments help avoid the “works on my machine” issue.
- A good test setup makes the whole team more confident and speeds up releases.
Whether you’re testing PostgreSQL, Kafka, or even a browser with Selenium, Testcontainers makes it easy to add real-world testing to your workflow.
Testcontainers is one of those tools I wish had existed a long time ago. It makes certain types of testing much easier, and I think it solves problems many of us have quietly accepted for years.
This post is part of my ongoing journey exploring better ways to build and test software. In future posts, I’ll share thoughts on system design, developer tools, and real-world engineering lessons — all with the goal of making our work more reliable and enjoyable.
You can reach me on LinkedIn, or leave a comment below if you’d like to share your thoughts.