In this post, we’ll cover Integrations testing for the Spring WebFlux application using Testcontainers.
|· Prerequisites
· Overview
∘ What is Testcontainers?
∘ Why use Testcontainers?
· Let’s code
∘ Create a Spring reactive application
∘ Add Testcontainer to project
· Testing
· Conclusion
· References
Prerequisites
This is the list of all the prerequisites:
- Spring Boot 3+
- Maven 3.6.+
- Java 17 or later
- MongoDB instance installed
- Your favorite IDE (IntelliJ IDEA, Eclipse, NetBeans, VS Code)
- A Docker environment supported by Testcontainers
Overview
What is Testcontainers?
Testcontainers is an open-source framework for providing throwaway, lightweight instances of databases, message brokers, web browsers, or just about anything that can run in a Docker container.
Testcontainers solves these problems by running your application dependencies such as databases, message brokers, etc. in Docker containers and helps executing reliable and repeatable tests by talking to those real services and providing a programmatic API for your test code.
Why use Testcontainers?
- It’s open-source and free to use.
- It helps to create a production-like environment during the testing phase.
- Testcontainers libraries exist for many other popular programming languages and runtime environments such as Java, .NET, Python, Go, Node.js, Rust, Ruby, etc.
- Easy integration with Spring and Spring Boot.
Let’s code
Create a Spring reactive application
We’ll start by creating a simple REST API project with Spring Webflux and MongoDB.

Create Data Model
First, let us start with creating a document class Book.java.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(collection = "book")
public class Book {
@Id
private String id;
private String title;
private int page;
private String isbn;
private String description;
private double price;
private LocalDate publicationDate;
private String language;
}
Create a Spring Data repository
Now that we have a domain object, we can define a repository interface that uses it, as follows:
@Repository
public interface BookRepository extends ReactiveMongoRepository<Book, String> {
}
Business service layer
Let’s create a service that uses BookRepository to implement CRUD Operations and custom finder methods.
@Service
@Slf4j
public class BookServiceImpl implements BookService {
private final BookRepository bookRepository;
public BookServiceImpl(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Override
public Flux<Book> getAllBook() {
log.info("Find all books");
return bookRepository.findAll();
}
@Override
public Mono<Book> addBook(Book book) {
log.info("addBook : {} ", book);
return bookRepository.save(book)
.log();
}
@Override
public Mono<Book> getBookById(String id) {
log.info("find book by id: {} ", id);
return bookRepository.findById(id)
.switchIfEmpty(Mono.defer(() -> Mono.error(new DataNotFoundException("Book id not found"))));
}
@Override
public Mono<Book> updateBook(Book book, String id) {
log.info("Update book: {} ", book);
return bookRepository.findById(id)
.flatMap(book1 -> {
book1.setTitle(book.getTitle());
book1.setIsbn(book.getIsbn());
book1.setLanguage(book.getLanguage());
book1.setDescription(book.getDescription());
book1.setPage(book.getPage());
book1.setPrice(book.getPrice());
book1.setPublicationDate(book.getPublicationDate());
return bookRepository.save(book1);
})
.switchIfEmpty(Mono.defer(() -> Mono.error(new DataNotFoundException("Book id not found"))));
}
@Override
public Mono<Void> deleteBookById(String id) {
log.info("delete book by id: {} ", id);
return bookRepository.deleteById(id);
}
}
Create a REST API endpoint
Finally, we create a controller that provides APIs for creating, retrieving, updating, deleting, and finding books.
@RestController
@RequestMapping("/api")
@Slf4j
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/book")
public Flux<Book> getAllBooks() {
return bookService.getAllBook();
}
@GetMapping("/book/{id}")
public Mono<Book> getBookById(@PathVariable("id") String id) {
return bookService.getBookById(id);
}
@PostMapping("/book")
@ResponseStatus(HttpStatus.CREATED)
public Mono<Book> addBook(@RequestBody @Valid Book book) {
return bookService.addBook(book);
}
@PutMapping("/book/{id}")
@ResponseStatus(HttpStatus.OK)
public Mono<Book> updateBook(@RequestBody Book book, @PathVariable String id) {
return bookService.updateBook(book, id);
}
@DeleteMapping("/book/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteBookById(@PathVariable String id){
return bookService.deleteBookById(id);
}
}
Under the src/main/resources folder, we will add the MongoDB credentials
spring:
data:
mongodb:
host: 127.0.0.1
port: 27017
database: testcontainerdb
username: user
password: bk2rF
Replace the credentials with yours.
Congratulations! Now we have a REST API with Spring webflux and MongoDB.

Add Testcontainer to project
The second step in this story is to integrate test containers to perform integration tests in our project.
We have to include the following testcontainers dependencies in the pom.xml:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<scope>test</scope>
</dependency>
Then, we will create an abstract class to be used by all integration test classes.
@SpringBootTest(classes = {SpringReactiveTestcontainersApplication.class})
@AutoConfigureWebTestClient
@ActiveProfiles("integrationtest")
@Testcontainers
public abstract class AbstractIntegrationTest {
@Autowired
public WebTestClient webTestClient;
private static final MongoDBContainer mongoDBContainer;
static {
mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6.0.1"));
mongoDBContainer.start();
}
@DynamicPropertySource
public static void propertyOverride(DynamicPropertyRegistry registry) {
registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl);
}
}
Jupiter integration is provided using the @Testcontainers annotation.
Since Spring Boot 3.1, we can use the @ServiceConnection annotation to establish a connection to a remote service such that the specified connection details take precedence over any connections configured using configuration properties.
The above code can be written simply with @ServiceConnection annotation, as follows.
@SpringBootTest(classes = {SpringReactiveTestcontainersApplication.class})
@AutoConfigureWebTestClient
@ActiveProfiles("integrationtest")
@Testcontainers
public abstract class AbstractIntegrationTest {
@Autowired
public WebTestClient webTestClient;
@Container
@ServiceConnection
static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6.0.1"));
}
It is also necessary to add spring-boot-testcontainers dependency to use ServiceConnection.
<!-- @ServiceConnection support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
Now all integration test classes can extend from AbstractIntegrationTestclass. Here’s the full implementation for BookControllerIT.class
class BookControllerIT extends AbstractIntegrationTest {
@Autowired
private BookRepository bookRepository;
private final static String BASE_CONTROLLER_ENDPOINT = "/api/book";
@BeforeEach
void setUp() {
bookRepository.deleteAll().subscribe();
}
@Test
void testGetAllBooks200() {
var bookDocument = buildBookObj();
bookRepository.save(bookDocument).subscribe();
webTestClient.get()
.uri(BASE_CONTROLLER_ENDPOINT)
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentType(MediaType.APPLICATION_JSON)
.expectBodyList(Book.class)
.hasSize(1)
.consumeWith(response -> {
assertThat(response.getResponseBody()).isNotNull();
assertThat(response.getResponseBody().get(0).getId()).isNotNull();
assertThat(response.getResponseBody().get(0).getTitle()).isEqualTo(bookDocument.getTitle());
assertThat(response.getResponseBody().get(0).getDescription()).isEqualTo(bookDocument.getDescription());
assertThat(response.getResponseBody().get(0).getIsbn()).isEqualTo(bookDocument.getIsbn());
assertThat(response.getResponseBody().get(0).getPage()).isEqualTo(bookDocument.getPage());
assertThat(response.getResponseBody().get(0).getPrice()).isEqualTo(bookDocument.getPrice());
assertThat(response.getResponseBody().get(0).getLanguage()).isEqualTo(bookDocument.getLanguage());
assertThat(response.getResponseBody().get(0).getPublicationDate()).isEqualTo(bookDocument.getPublicationDate());
});
}
@Test
void testGetBookById200() {
var bookDocument = buildBookObj();
bookDocument.setId(new ObjectId().toString());
bookRepository.save(bookDocument).subscribe();
webTestClient.get()
.uri(MessageFormat.format("{0}/{1}", BASE_CONTROLLER_ENDPOINT, bookDocument.getId()))
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentType(MediaType.APPLICATION_JSON)
.expectBody(Book.class)
.consumeWith(response -> {
assertThat(response.getResponseBody()).isNotNull();
assertThat(response.getResponseBody().getId()).isNotNull();
assertThat(response.getResponseBody().getTitle()).isEqualTo(bookDocument.getTitle());
assertThat(response.getResponseBody().getDescription()).isEqualTo(bookDocument.getDescription());
assertThat(response.getResponseBody().getIsbn()).isEqualTo(bookDocument.getIsbn());
assertThat(response.getResponseBody().getPage()).isEqualTo(bookDocument.getPage());
assertThat(response.getResponseBody().getPrice()).isEqualTo(bookDocument.getPrice());
assertThat(response.getResponseBody().getLanguage()).isEqualTo(bookDocument.getLanguage());
assertThat(response.getResponseBody().getPublicationDate()).isEqualTo(bookDocument.getPublicationDate());
});
}
@Test
void testGetBookById404() {
var id = new ObjectId().toString();
webTestClient.get()
.uri(MessageFormat.format("{0}/{1}", BASE_CONTROLLER_ENDPOINT, id))
.exchange()
.expectStatus()
.isNotFound()
.expectHeader()
.contentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE)
.expectBody()
.jsonPath("$.instance").isNotEmpty()
.jsonPath("$.type").isEqualTo("about:blank")
.jsonPath("$.title").isEqualTo(HttpStatus.NOT_FOUND.getReasonPhrase())
.jsonPath("$.status").isEqualTo(HttpStatus.NOT_FOUND.value())
.jsonPath("$.detail").isEqualTo("Book id not found");
}
@Test
void testAddBook() {
var bookRequest = buildBookObj();
webTestClient.post()
.uri(BASE_CONTROLLER_ENDPOINT)
.body(Mono.just(bookRequest), Book.class)
.exchange()
.expectStatus()
.isCreated()
.expectHeader()
.contentType(MediaType.APPLICATION_JSON)
.expectBody(Book.class)
.consumeWith(response -> {
assertThat(response.getResponseBody()).isNotNull();
assertThat(response.getResponseBody().getId()).isNotNull();
assertThat(response.getResponseBody().getTitle()).isEqualTo(bookRequest.getTitle());
assertThat(response.getResponseBody().getDescription()).isEqualTo(bookRequest.getDescription());
assertThat(response.getResponseBody().getIsbn()).isEqualTo(bookRequest.getIsbn());
assertThat(response.getResponseBody().getPage()).isEqualTo(bookRequest.getPage());
assertThat(response.getResponseBody().getPrice()).isEqualTo(bookRequest.getPrice());
assertThat(response.getResponseBody().getLanguage()).isEqualTo(bookRequest.getLanguage());
assertThat(response.getResponseBody().getPublicationDate()).isEqualTo(bookRequest.getPublicationDate());
});
}
@Test
void testUpdateBook200() {
var bookDocument = buildBookObj();
var bookRequest = buildBookObj();
bookRequest.setPrice(500);
bookRequest.setPage(250);
bookRequest.setIsbn("78polkj");
bookRequest.setTitle("update book");
bookRequest.setDescription("update new book desc");
bookDocument.setId(new ObjectId().toString());
bookRepository.save(bookDocument).subscribe();
webTestClient.put()
.uri(MessageFormat.format("{0}/{1}", BASE_CONTROLLER_ENDPOINT, bookDocument.getId()))
.body(Mono.just(bookRequest), Book.class)
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentType(MediaType.APPLICATION_JSON)
.expectBody(Book.class)
.consumeWith(response -> {
assertThat(response.getResponseBody()).isNotNull();
assertThat(response.getResponseBody().getId()).isNotNull();
assertThat(response.getResponseBody().getTitle()).isEqualTo(bookRequest.getTitle());
assertThat(response.getResponseBody().getDescription()).isEqualTo(bookRequest.getDescription());
assertThat(response.getResponseBody().getIsbn()).isEqualTo(bookRequest.getIsbn());
assertThat(response.getResponseBody().getPage()).isEqualTo(bookRequest.getPage());
assertThat(response.getResponseBody().getPrice()).isEqualTo(bookRequest.getPrice());
assertThat(response.getResponseBody().getLanguage()).isEqualTo(bookRequest.getLanguage());
assertThat(response.getResponseBody().getPublicationDate()).isEqualTo(bookRequest.getPublicationDate());
});
}
@Test
void testUpdateBook404() {
var bookRequest = buildBookObj();
var id = new ObjectId().toString();
webTestClient.put()
.uri(MessageFormat.format("{0}/{1}", BASE_CONTROLLER_ENDPOINT, id))
.body(Mono.just(bookRequest), Book.class)
.exchange()
.expectStatus()
.isNotFound()
.expectHeader()
.contentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE)
.expectBody()
.jsonPath("$.instance").isNotEmpty()
.jsonPath("$.type").isEqualTo("about:blank")
.jsonPath("$.title").isEqualTo(HttpStatus.NOT_FOUND.getReasonPhrase())
.jsonPath("$.status").isEqualTo(HttpStatus.NOT_FOUND.value())
.jsonPath("$.detail").isEqualTo("Book id not found");
}
@Test
void testDeleteBookById() {
var bookDocument = buildBookObj();
bookDocument.setId(new ObjectId().toString());
bookRepository.save(bookDocument).subscribe();
webTestClient.delete()
.uri(MessageFormat.format("{0}/{1}", BASE_CONTROLLER_ENDPOINT, bookDocument.getId()))
.exchange()
.expectStatus()
.isNoContent()
.expectBody();
}
private Book buildBookObj(){
return Book.builder()
.title("title")
.description("book desc")
.isbn("aswdc142")
.page(101)
.price(50)
.language("en")
.publicationDate(LocalDate.of(2024, 12, 12))
.build();
}
}
Testing
Let’s run all the integration tests to see the results.


As we see we have 2 containers mongo:6.0.1 and testcontainers/ryuk:0.6.0 created during execution.
While starting the required containers, Testcontainers attaches a set of labels to the created resources (containers, volumes, networks, etc) and Ryuk automatically performs resource cleanup by matching those labels. This works reliably even when the test process exits abnormally (e.g. sending a SIGKILL).
Conclusion
Well done !!. This post taught us how to test integration in a Spring reactive application using Testcontainers.
The complete source code is available on GitHub.
You can reach out to me and follow me on Medium, Twitter, GitHub, Linkedln
Thanks for reading!