From b24d66334461e0b7ebccf27dadf6a0e637738df1 Mon Sep 17 00:00:00 2001 From: David Karlsson <35727626+dvdksn@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:50:14 +0000 Subject: [PATCH] docs: prune specialized Testcontainers integration guides The Testcontainers collection includes nine specialized Java integration tutorials with modest readership and overlapping framework setup. Remove these guides and their links from the Testcontainers overview, retaining eleven guides covering language introductions, general techniques, representative web frameworks, and Testcontainers Cloud. --- .../testcontainers-java-aws-localstack.md | 450 ---------- .../guides/testcontainers-java-jooq-flyway.md | 610 -------------- ...estcontainers-java-keycloak-spring-boot.md | 605 -------------- .../testcontainers-java-micronaut-kafka.md | 472 ----------- .../testcontainers-java-micronaut-wiremock.md | 673 --------------- .../guides/testcontainers-java-mockserver.md | 445 ---------- content/guides/testcontainers-java-quarkus.md | 474 ----------- .../testcontainers-java-spring-boot-kafka.md | 471 ----------- .../guides/testcontainers-java-wiremock.md | 770 ------------------ content/manuals/testcontainers.md | 9 - 10 files changed, 4979 deletions(-) delete mode 100644 content/guides/testcontainers-java-aws-localstack.md delete mode 100644 content/guides/testcontainers-java-jooq-flyway.md delete mode 100644 content/guides/testcontainers-java-keycloak-spring-boot.md delete mode 100644 content/guides/testcontainers-java-micronaut-kafka.md delete mode 100644 content/guides/testcontainers-java-micronaut-wiremock.md delete mode 100644 content/guides/testcontainers-java-mockserver.md delete mode 100644 content/guides/testcontainers-java-quarkus.md delete mode 100644 content/guides/testcontainers-java-spring-boot-kafka.md delete mode 100644 content/guides/testcontainers-java-wiremock.md diff --git a/content/guides/testcontainers-java-aws-localstack.md b/content/guides/testcontainers-java-aws-localstack.md deleted file mode 100644 index fa6295b5ee..0000000000 --- a/content/guides/testcontainers-java-aws-localstack.md +++ /dev/null @@ -1,450 +0,0 @@ ---- -title: Testing AWS service integrations using LocalStack -linkTitle: AWS LocalStack -description: Learn how to test Spring Cloud AWS applications using LocalStack and Testcontainers. -keywords: testcontainers, java, spring boot, testing, aws, localstack, s3, sqs, spring cloud aws -summary: | - Learn how to create a Spring Boot application with Spring Cloud AWS, - then test S3 and SQS integrations using Testcontainers and LocalStack. -aliases: - - /guides/testcontainers-java-aws-localstack/create-project/ - - /guides/testcontainers-java-aws-localstack/run-tests/ - - /guides/testcontainers-java-aws-localstack/write-tests/ -params: - tags: [testing] - time: 25 minutes ---- - - - - -In this guide, you will learn how to: - -- Create a Spring Boot application with Spring Cloud AWS integration -- Use AWS S3 and SQS services -- Test the application using Testcontainers and LocalStack - -## Prerequisites - -- Java 17+ -- Maven or Gradle -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Spring Boot project - -### Set up the project - -Create a Spring Boot project from [Spring Initializr](https://start.spring.io) -by selecting the **Testcontainers** starter. Spring Cloud AWS starters are not -available on Spring Initializr, so you need to add them manually. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-testing-aws-service-integrations-using-localstack). - -Add the Spring Cloud AWS BOM to your dependency management and add the S3, SQS -starters as dependencies. Testcontainers provides a -[LocalStack module](https://testcontainers.com/modules/localstack/) for testing -AWS service integrations. You also need -[Awaitility](http://www.awaitility.org/) for testing asynchronous SQS -processing. - -The key dependencies in `pom.xml` are: - -```xml - - 17 - 2.0.4 - 3.0.3 - - - - - org.springframework.boot - spring-boot-starter-web - - - io.awspring.cloud - spring-cloud-aws-starter-s3 - - - io.awspring.cloud - spring-cloud-aws-starter-sqs - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.boot - spring-boot-testcontainers - test - - - org.testcontainers - testcontainers-junit-jupiter - test - - - org.testcontainers - testcontainers-localstack - test - - - org.awaitility - awaitility - test - - - - - - - io.awspring.cloud - spring-cloud-aws-dependencies - ${awspring.version} - pom - import - - - -``` - -### Create the configuration properties - -To make the SQS queue and S3 bucket names configurable, create an -`ApplicationProperties` record: - -```java -package com.testcontainers.demo; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "app") -public record ApplicationProperties(String queue, String bucket) {} -``` - -Then add `@ConfigurationPropertiesScan` to the main application class so that -Spring automatically scans for `@ConfigurationProperties`-annotated classes and -registers them as beans: - -```java -package com.testcontainers.demo; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; - -@SpringBootApplication -@ConfigurationPropertiesScan -public class Application { - - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } -} -``` - -### Implement StorageService for S3 - -Spring Cloud AWS provides higher-level abstractions like `S3Template` with -convenience methods for uploading and downloading files. Create a -`StorageService` class: - -```java -package com.testcontainers.demo; - -import io.awspring.cloud.s3.S3Template; -import java.io.IOException; -import java.io.InputStream; -import org.springframework.stereotype.Service; - -@Service -public class StorageService { - - private final S3Template s3Template; - - public StorageService(S3Template s3Template) { - this.s3Template = s3Template; - } - - public void upload(String bucketName, String key, InputStream stream) { - this.s3Template.upload(bucketName, key, stream); - } - - public InputStream download(String bucketName, String key) - throws IOException { - return this.s3Template.download(bucketName, key).getInputStream(); - } - - public String downloadAsString(String bucketName, String key) - throws IOException { - try (InputStream is = this.download(bucketName, key)) { - return new String(is.readAllBytes()); - } - } -} -``` - -### Create the SQS message model - -Create a `Message` record that represents the payload you send to the SQS -queue: - -```java -package com.testcontainers.demo; - -import java.util.UUID; - -public record Message(UUID uuid, String content) {} -``` - -### Implement the message sender - -Create `MessageSender`, which uses `SqsTemplate` to publish messages: - -```java -package com.testcontainers.demo; - -import io.awspring.cloud.sqs.operations.SqsTemplate; -import org.springframework.stereotype.Service; - -@Service -public class MessageSender { - - private final SqsTemplate sqsTemplate; - - public MessageSender(SqsTemplate sqsTemplate) { - this.sqsTemplate = sqsTemplate; - } - - public void publish(String queueName, Message message) { - sqsTemplate.send(to -> to.queue(queueName).payload(message)); - } -} -``` - -### Implement the message listener - -Create `MessageListener` with a handler method annotated with `@SqsListener`. -When a message arrives, the listener uploads the content to an S3 bucket using -the message UUID as the key: - -```java -package com.testcontainers.demo; - -import io.awspring.cloud.sqs.annotation.SqsListener; -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; -import org.springframework.stereotype.Service; - -@Service -public class MessageListener { - - private final StorageService storageService; - private final ApplicationProperties properties; - - public MessageListener( - StorageService storageService, - ApplicationProperties properties - ) { - this.storageService = storageService; - this.properties = properties; - } - - @SqsListener(queueNames = { "${app.queue}" }) - public void handle(Message message) { - String bucketName = this.properties.bucket(); - String key = message.uuid().toString(); - ByteArrayInputStream is = new ByteArrayInputStream( - message.content().getBytes(StandardCharsets.UTF_8) - ); - this.storageService.upload(bucketName, key, is); - } -} -``` - -The `${app.queue}` expression reads the queue name from application -configuration instead of hard-coding it. - -## Write tests with Testcontainers - -To test the application, you need a running LocalStack instance that emulates -the AWS S3 and SQS services. Testcontainers spins up LocalStack in a Docker -container and `@DynamicPropertySource` connects it to Spring Cloud AWS. - -### Configure the test container - -You can start a LocalStack container and configure the Spring Cloud AWS -properties to talk to it instead of actual AWS services. The properties you -need to set are: - -```properties -spring.cloud.aws.s3.endpoint=http://localhost:4566 -spring.cloud.aws.sqs.endpoint=http://localhost:4566 -spring.cloud.aws.credentials.access-key=noop -spring.cloud.aws.credentials.secret-key=noop -spring.cloud.aws.region.static=us-east-1 -``` - -For testing, use an ephemeral container that starts on a random available port -so that you can run multiple builds in CI in parallel without port conflicts. - -### Write the test - -Create `MessageListenerTest.java`: - -```java -package com.testcontainers.demo; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; -import static org.testcontainers.containers.localstack.LocalStackContainer.Service.SQS; - -import java.io.IOException; -import java.time.Duration; -import java.util.UUID; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.localstack.LocalStackContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -@SpringBootTest -@Testcontainers -class MessageListenerTest { - - @Container - static LocalStackContainer localStack = new LocalStackContainer( - DockerImageName.parse("localstack/localstack:3.0") - ); - - static final String BUCKET_NAME = UUID.randomUUID().toString(); - static final String QUEUE_NAME = UUID.randomUUID().toString(); - - @DynamicPropertySource - static void overrideProperties(DynamicPropertyRegistry registry) { - registry.add("app.bucket", () -> BUCKET_NAME); - registry.add("app.queue", () -> QUEUE_NAME); - registry.add( - "spring.cloud.aws.region.static", - () -> localStack.getRegion() - ); - registry.add( - "spring.cloud.aws.credentials.access-key", - () -> localStack.getAccessKey() - ); - registry.add( - "spring.cloud.aws.credentials.secret-key", - () -> localStack.getSecretKey() - ); - registry.add( - "spring.cloud.aws.s3.endpoint", - () -> localStack.getEndpointOverride(S3).toString() - ); - registry.add( - "spring.cloud.aws.sqs.endpoint", - () -> localStack.getEndpointOverride(SQS).toString() - ); - } - - @BeforeAll - static void beforeAll() throws IOException, InterruptedException { - localStack.execInContainer("awslocal", "s3", "mb", "s3://" + BUCKET_NAME); - localStack.execInContainer( - "awslocal", - "sqs", - "create-queue", - "--queue-name", - QUEUE_NAME - ); - } - - @Autowired - StorageService storageService; - - @Autowired - MessageSender publisher; - - @Autowired - ApplicationProperties properties; - - @Test - void shouldHandleMessageSuccessfully() { - Message message = new Message(UUID.randomUUID(), "Hello World"); - publisher.publish(properties.queue(), message); - - await() - .pollInterval(Duration.ofSeconds(2)) - .atMost(Duration.ofSeconds(10)) - .ignoreExceptions() - .untilAsserted(() -> { - String msg = storageService.downloadAsString( - properties.bucket(), - message.uuid().toString() - ); - assertThat(msg).isEqualTo("Hello World"); - }); - } -} -``` - -Here's what the test does: - -- `@SpringBootTest` starts the full Spring application context. -- The Testcontainers JUnit 5 annotations `@Testcontainers` and `@Container` - manage the lifecycle of a `LocalStackContainer` instance. -- `@DynamicPropertySource` obtains the dynamic S3 and SQS endpoint URLs, - region, access key, and secret key from the container, and registers them as - Spring Cloud AWS configuration properties. -- `@BeforeAll` creates the required SQS queue and S3 bucket using the - `awslocal` CLI tool that comes pre-installed in the LocalStack Docker image. - The `localStack.execInContainer()` API runs commands inside the container. -- `shouldHandleMessageSuccessfully()` publishes a `Message` to the SQS queue. - The listener receives the message and stores its content in the S3 bucket - with the UUID as the key. Awaitility waits up to 10 seconds for the expected - content to appear in the bucket. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -Or with Gradle: - -```console -$ ./gradlew test -``` - -You should see the LocalStack Docker container start and the test pass. After -the tests finish, the container stops and is removed automatically. - -### Summary - -LocalStack lets you develop and test AWS-based applications locally. -The Testcontainers LocalStack module makes it straightforward to write -integration tests by using ephemeral LocalStack containers that start on random -ports with no external setup required. - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [Testcontainers LocalStack module](https://java.testcontainers.org/modules/localstack/) -- [Getting started with Testcontainers for Java](https://java.testcontainers.org/quickstart/junit_5_quickstart/) -- [Spring Cloud AWS documentation](https://docs.awspring.io/spring-cloud-aws/docs/3.0.3/reference/html/index.html) diff --git a/content/guides/testcontainers-java-jooq-flyway.md b/content/guides/testcontainers-java-jooq-flyway.md deleted file mode 100644 index e6a3a954eb..0000000000 --- a/content/guides/testcontainers-java-jooq-flyway.md +++ /dev/null @@ -1,610 +0,0 @@ ---- -title: Working with jOOQ and Flyway using Testcontainers -linkTitle: jOOQ and Flyway -description: Learn how to generate jOOQ code from a database using Testcontainers and Flyway, then test your persistence layer. -keywords: testcontainers, java, testing, jooq, flyway, postgresql, spring boot, code generation -summary: | - Generate typesafe jOOQ code from a real PostgreSQL database managed by - Flyway migrations, then test repositories using Testcontainers. -aliases: - - /guides/testcontainers-java-jooq-flyway/create-project/ - - /guides/testcontainers-java-jooq-flyway/run-tests/ - - /guides/testcontainers-java-jooq-flyway/write-tests/ -params: - tags: [testing] - time: 25 minutes ---- - - - - -In this guide, you will learn how to: - -- Create a Spring Boot application with jOOQ support -- Generate jOOQ code using Testcontainers, Flyway, and a Maven plugin -- Implement basic database operations using jOOQ -- Load complex object graphs using jOOQ's MULTISET feature -- Test the jOOQ persistence layer using Testcontainers - -## Prerequisites - -- Java 17+ -- Maven -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Spring Boot project - -### Set up the project - -Create a Spring Boot project from [Spring Initializr](https://start.spring.io) -by selecting Maven as the build tool and adding the **JOOQ Access Layer**, -**Flyway Migration**, **Spring Boot DevTools**, **PostgreSQL Driver**, and -**Testcontainers** starters. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-working-with-jooq-flyway-using-testcontainers). - -jOOQ (jOOQ Object Oriented Querying) provides a fluent API for building -typesafe SQL queries. To get the full benefit of its typesafe DSL, you need -to generate Java code from your database tables, views, and other objects. - -> [!TIP] -> To learn more about how the jOOQ code generator helps, read -> [Why You Should Use jOOQ With Code Generation](https://blog.jooq.org/why-you-should-use-jooq-with-code-generation/). - -The typical process for building and testing the application with jOOQ code -generation is: - -1. Create a database instance using Testcontainers. -2. Apply Flyway database migrations. -3. Run the jOOQ code generator to produce Java code from the database objects. -4. Run integration tests. - -The -[testcontainers-jooq-codegen-maven-plugin](https://github.com/testcontainers/testcontainers-jooq-codegen-maven-plugin) -automates this as part of the Maven build. - -### Create Flyway migration scripts - -The sample application has `users`, `posts`, and `comments` tables. Create -the first migration script following the Flyway naming convention. - -Create `src/main/resources/db/migration/V1__create_tables.sql`: - -```sql -create table users -( - id bigserial not null, - name varchar not null, - email varchar not null, - created_at timestamp, - updated_at timestamp, - primary key (id), - constraint user_email_unique unique (email) -); - -create table posts -( - id bigserial not null, - title varchar not null, - content varchar not null, - created_by bigint references users (id) not null, - created_at timestamp, - updated_at timestamp, - primary key (id) -); - -create table comments -( - id bigserial not null, - name varchar not null, - content varchar not null, - post_id bigint references posts (id) not null, - created_at timestamp, - updated_at timestamp, - primary key (id) -); - -ALTER SEQUENCE users_id_seq RESTART WITH 101; -ALTER SEQUENCE posts_id_seq RESTART WITH 101; -ALTER SEQUENCE comments_id_seq RESTART WITH 101; -``` - -The sequence values restart at 101 so that you can insert sample data with -explicit primary key values for testing. - -### Configure jOOQ code generation - -Add the `testcontainers-jooq-codegen-maven-plugin` to `pom.xml`: - -```xml - - 2.0.4 - 0.0.4 - - - - - - org.testcontainers - testcontainers-jooq-codegen-maven-plugin - ${testcontainers-jooq-codegen-maven-plugin.version} - - - org.testcontainers - testcontainers-postgresql - ${testcontainers.version} - - - org.postgresql - postgresql - ${postgresql.version} - - - - - generate-jooq-sources - - generate - - generate-sources - - - POSTGRES - postgres:16-alpine - - - - filesystem:src/main/resources/db/migration - - - - - - .* - flyway_schema_history - public - - - com.testcontainers.demo.jooq - target/generated-sources/jooq - - - - - - - - - -``` - -Here's what the plugin configuration does: - -- The `/` section sets the database type to - `POSTGRES` and the Docker image to `postgres:16-alpine`. -- The `/` section points to the Flyway migration - scripts. -- The `/` section configures the package name and - output directory for the generated code. You can use any configuration - option that the official `jooq-code-generator` plugin supports. - -When you run `./mvnw clean package`, the plugin uses Testcontainers to -spin up a PostgreSQL container, applies the Flyway migrations, and generates -Java code under `target/generated-sources/jooq`. - -### Create model classes - -Create model classes to represent the data structures for various use cases. -These records hold a subset of column values from the tables. - -`User.java`: - -```java -package com.testcontainers.demo.domain; - -public record User(Long id, String name, String email) {} -``` - -`Post.java`: - -```java -package com.testcontainers.demo.domain; - -import java.time.LocalDateTime; -import java.util.List; - -public record Post( - Long id, - String title, - String content, - User createdBy, - List comments, - LocalDateTime createdAt, - LocalDateTime updatedAt -) {} -``` - -`Comment.java`: - -```java -package com.testcontainers.demo.domain; - -import java.time.LocalDateTime; - -public record Comment( - Long id, - String name, - String content, - LocalDateTime createdAt, - LocalDateTime updatedAt -) {} -``` - -### Implement repositories using jOOQ - -Create `UserRepository.java` with methods to create a user and look up a user -by email: - -```java -package com.testcontainers.demo.domain; - -import static com.testcontainers.demo.jooq.tables.Users.USERS; -import static org.jooq.Records.mapping; - -import java.time.LocalDateTime; -import java.util.Optional; -import org.jooq.DSLContext; -import org.springframework.stereotype.Repository; - -@Repository -class UserRepository { - - private final DSLContext dsl; - - UserRepository(DSLContext dsl) { - this.dsl = dsl; - } - - public User createUser(User user) { - return this.dsl.insertInto(USERS) - .set(USERS.NAME, user.name()) - .set(USERS.EMAIL, user.email()) - .set(USERS.CREATED_AT, LocalDateTime.now()) - .returningResult(USERS.ID, USERS.NAME, USERS.EMAIL) - .fetchOne(mapping(User::new)); - } - - public Optional getUserByEmail(String email) { - return this.dsl.select(USERS.ID, USERS.NAME, USERS.EMAIL) - .from(USERS) - .where(USERS.EMAIL.equalIgnoreCase(email)) - .fetchOptional(mapping(User::new)); - } -} -``` - -The jOOQ DSL looks similar to SQL but written in Java. Because the code is -generated from the database schema, it stays in sync with the database -structure and provides type safety. For example, -`where(USERS.EMAIL.equalIgnoreCase(email))` expects a `String` value. If you -pass a non-string value like `123`, you get a compiler error. - -### Fetch complex object graphs - -jOOQ shines when it comes to complex queries. The database has a many-to-one -relationship from `Post` to `User` and a one-to-many relationship from `Post` -to `Comment`. - -Create `PostRepository.java` to load a `Post` with its creator and comments -using a single query with jOOQ's MULTISET feature: - -```java -package com.testcontainers.demo.domain; - -import static com.testcontainers.demo.jooq.Tables.COMMENTS; -import static com.testcontainers.demo.jooq.tables.Posts.POSTS; -import static org.jooq.Records.mapping; -import static org.jooq.impl.DSL.multiset; -import static org.jooq.impl.DSL.row; -import static org.jooq.impl.DSL.select; - -import java.util.Optional; -import org.jooq.DSLContext; -import org.springframework.stereotype.Repository; - -@Repository -class PostRepository { - - private final DSLContext dsl; - - PostRepository(DSLContext dsl) { - this.dsl = dsl; - } - - public Optional getPostById(Long id) { - return this.dsl.select( - POSTS.ID, - POSTS.TITLE, - POSTS.CONTENT, - row(POSTS.users().ID, POSTS.users().NAME, POSTS.users().EMAIL) - .mapping(User::new) - .as("createdBy"), - multiset( - select( - COMMENTS.ID, - COMMENTS.NAME, - COMMENTS.CONTENT, - COMMENTS.CREATED_AT, - COMMENTS.UPDATED_AT - ) - .from(COMMENTS) - .where(POSTS.ID.eq(COMMENTS.POST_ID)) - ) - .as("comments") - .convertFrom(r -> r.map(mapping(Comment::new))), - POSTS.CREATED_AT, - POSTS.UPDATED_AT - ) - .from(POSTS) - .where(POSTS.ID.eq(id)) - .fetchOptional(mapping(Post::new)); - } -} -``` - -This uses jOOQ's -[nested records](https://www.jooq.org/doc/latest/manual/sql-building/column-expressions/nested-records/) -for the many-to-one `Post`-to-`User` association and -[MULTISET](https://www.jooq.org/doc/latest/manual/sql-building/column-expressions/multiset-value-constructor/) -for the one-to-many `Post`-to-`Comment` association. - -## Write tests with Testcontainers - -Before writing the tests, create an SQL script to seed test data at -`src/test/resources/test-data.sql`: - -```sql -DELETE FROM comments; -DELETE FROM posts; -DELETE FROM users; - -INSERT INTO users(id, name, email) VALUES -(1, 'Siva', 'siva@gmail.com'), -(2, 'Oleg', 'oleg@gmail.com'); - -INSERT INTO posts(id, title, content, created_by, created_at) VALUES -(1, 'Post 1 Title', 'Post 1 content', 1, CURRENT_TIMESTAMP), -(2, 'Post 2 Title', 'Post 2 content', 2, CURRENT_TIMESTAMP); - -INSERT INTO comments(id, name, content, post_id, created_at) VALUES -(1, 'Ron', 'Comment 1', 1, CURRENT_TIMESTAMP), -(2, 'James', 'Comment 2', 1, CURRENT_TIMESTAMP), -(3, 'Robert', 'Comment 3', 2, CURRENT_TIMESTAMP); -``` - -### Test with the @JooqTest slice - -The `@JooqTest` annotation loads only the persistence layer components and -auto-configures jOOQ's `DSLContext`. Use the Testcontainers special JDBC URL -to start a Postgres container. - -Create `UserRepositoryJooqTest.java`: - -```java -package com.testcontainers.demo.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.jooq.DSLContext; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jooq.JooqTest; -import org.springframework.test.context.jdbc.Sql; - -@JooqTest( - properties = { - "spring.test.database.replace=none", - "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db", - } -) -@Sql("/test-data.sql") -class UserRepositoryJooqTest { - - @Autowired - DSLContext dsl; - - UserRepository repository; - - @BeforeEach - void setUp() { - this.repository = new UserRepository(dsl); - } - - @Test - void shouldCreateUserSuccessfully() { - User user = new User(null, "John", "john@gmail.com"); - - User savedUser = repository.createUser(user); - - assertThat(savedUser.id()).isNotNull(); - assertThat(savedUser.name()).isEqualTo("John"); - assertThat(savedUser.email()).isEqualTo("john@gmail.com"); - } - - @Test - void shouldGetUserByEmail() { - User user = repository.getUserByEmail("siva@gmail.com").orElseThrow(); - - assertThat(user.id()).isEqualTo(1L); - assertThat(user.name()).isEqualTo("Siva"); - assertThat(user.email()).isEqualTo("siva@gmail.com"); - } -} -``` - -Here's what the test does: - -- `@JooqTest` loads only the persistence layer and auto-configures - `DSLContext`. -- The Testcontainers special JDBC URL - (`jdbc:tc:postgresql:16-alpine:///db`) starts a PostgreSQL container - automatically. -- Because `flyway-core` is on the classpath, Spring Boot runs the Flyway - migrations from `src/main/resources/db/migration` on startup. -- `@Sql("/test-data.sql")` loads the test data before each test. -- The `UserRepository` is instantiated manually with the injected - `DSLContext`. - -### Integration test with @SpringBootTest - -For a full integration test, use `@SpringBootTest` with the Testcontainers -`@ServiceConnection` support introduced in Spring Boot 3.1. - -Create `UserRepositoryTest.java`: - -```java -package com.testcontainers.demo.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.test.context.jdbc.Sql; -import org.testcontainers.postgresql.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -@SpringBootTest -@Sql("/test-data.sql") -@Testcontainers -class UserRepositoryTest { - - @Container - @ServiceConnection - static PostgreSQLContainer postgres = new PostgreSQLContainer( - "postgres:16-alpine" - ); - - @Autowired - UserRepository repository; - - @Test - void shouldCreateUserSuccessfully() { - User user = new User(null, "John", "john@gmail.com"); - - User savedUser = repository.createUser(user); - - assertThat(savedUser.id()).isNotNull(); - assertThat(savedUser.name()).isEqualTo("John"); - assertThat(savedUser.email()).isEqualTo("john@gmail.com"); - } - - @Test - void shouldGetUserByEmail() { - User user = repository.getUserByEmail("siva@gmail.com").orElseThrow(); - - assertThat(user.id()).isEqualTo(1L); - assertThat(user.name()).isEqualTo("Siva"); - assertThat(user.email()).isEqualTo("siva@gmail.com"); - } -} -``` - -Here's what the test does: - -- `@SpringBootTest` loads the entire application context, so - `UserRepository` is injected directly. -- `@Testcontainers` and `@Container` manage the PostgreSQL container - lifecycle. -- `@ServiceConnection` auto-configures the datasource properties from the - running container, replacing the need for `@DynamicPropertySource`. -- `@Sql("/test-data.sql")` initializes the test data. - -### Test PostRepository - -Test the `PostRepository` that fetches complex object graphs using the -Testcontainers special JDBC URL. - -Create `PostRepositoryTest.java`: - -```java -package com.testcontainers.demo.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.jdbc.Sql; - -@SpringBootTest( - properties = { - "spring.test.database.replace=none", - "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db", - } -) -@Sql("/test-data.sql") -class PostRepositoryTest { - - @Autowired - PostRepository repository; - - @Test - void shouldGetPostById() { - Post post = repository.getPostById(1L).orElseThrow(); - - assertThat(post.id()).isEqualTo(1L); - assertThat(post.title()).isEqualTo("Post 1 Title"); - assertThat(post.content()).isEqualTo("Post 1 content"); - assertThat(post.createdBy().id()).isEqualTo(1L); - assertThat(post.createdBy().name()).isEqualTo("Siva"); - assertThat(post.createdBy().email()).isEqualTo("siva@gmail.com"); - assertThat(post.comments()).hasSize(2); - } -} -``` - -This test verifies that `getPostById` loads the post along with its creator -and comments in a single query using jOOQ's MULTISET feature. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -You should see the PostgreSQL Docker container start, jOOQ code generation -complete, and all tests pass. After the tests finish, the container stops and -is removed automatically. - -### Summary - -The Testcontainers library helps you generate Java code from the database -using the jOOQ code generator and test your persistence layer against the -same type of database (PostgreSQL) that you use in production, instead of -mocks or in-memory databases. - -Because the code is always generated from the database's current state, you -can be confident that your code stays in sync with database changes. You're -free to refactor and still verify that the application works as expected. - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [jOOQ documentation](https://www.jooq.org/) -- [jOOQ code generation](https://www.jooq.org/doc/latest/manual/code-generation/) -- [Spring Boot Testcontainers support](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html) -- [Replace H2 with a real database for testing](/guides/testcontainers-java-replace-h2/) diff --git a/content/guides/testcontainers-java-keycloak-spring-boot.md b/content/guides/testcontainers-java-keycloak-spring-boot.md deleted file mode 100644 index 187f024cd3..0000000000 --- a/content/guides/testcontainers-java-keycloak-spring-boot.md +++ /dev/null @@ -1,605 +0,0 @@ ---- -title: Securing Spring Boot microservice using Keycloak and Testcontainers -linkTitle: Keycloak with Spring Boot -description: Learn how to secure a Spring Boot microservice using Keycloak and test it with the Testcontainers Keycloak module. -keywords: testcontainers, java, spring boot, testing, keycloak, security, oauth2, jwt -summary: | - Learn how to create an OAuth 2.0 Resource Server using Spring Boot, secure API - endpoints with Keycloak, and test the application using the Testcontainers Keycloak module. -aliases: - - /guides/testcontainers-java-keycloak-spring-boot/create-project/ - - /guides/testcontainers-java-keycloak-spring-boot/run-tests/ - - /guides/testcontainers-java-keycloak-spring-boot/write-tests/ -params: - tags: [testing] - time: 30 minutes ---- - - - - -In this guide, you'll learn how to: - -- Create an OAuth 2.0 Resource Server using Spring Boot -- Secure API endpoints using Keycloak -- Test the APIs using the Testcontainers Keycloak module -- Run the application locally using the Testcontainers Keycloak module - -## Prerequisites - -- Java 17+ -- Maven or Gradle -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Spring Boot project - -### Set up the project - -Create a Spring Boot project from [Spring Initializr](https://start.spring.io) -by selecting the **Spring Web**, **Validation**, **JDBC API**, -**PostgreSQL Driver**, **Spring Security**, **OAuth2 Resource Server**, and -**Testcontainers** starters. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-securing-spring-boot-microservice-using-keycloak-and-testcontainers). - -After generating the application, add the -[testcontainers-keycloak](https://github.com/dasniko/testcontainers-keycloak) -community module and [REST Assured](https://rest-assured.io/) as test -dependencies. - -The key dependencies in `pom.xml` are: - -```xml - - 17 - 2.0.4 - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-jdbc - - - org.postgresql - postgresql - runtime - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.security - spring-security-test - test - - - org.springframework.boot - spring-boot-testcontainers - test - - - org.testcontainers - testcontainers-junit-jupiter - test - - - org.testcontainers - testcontainers-postgresql - test - - - com.github.dasniko - testcontainers-keycloak - 3.4.0 - test - - - io.rest-assured - rest-assured - test - - -``` - -### Create the domain model - -Create a `Product` record that represents the domain object: - -```java -package com.testcontainers.products.domain; - -import jakarta.validation.constraints.NotEmpty; - -public record Product(Long id, @NotEmpty String title, String description) {} -``` - -### Create the repository - -Implement `ProductRepository` using Spring `JdbcClient` to interact with a -PostgreSQL database: - -```java -package com.testcontainers.products.domain; - -import java.util.List; -import org.springframework.jdbc.core.simple.JdbcClient; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; - -@Repository -public class ProductRepository { - - private final JdbcClient jdbcClient; - - public ProductRepository(JdbcClient jdbcClient) { - this.jdbcClient = jdbcClient; - } - - public List getAll() { - return jdbcClient.sql("SELECT * FROM products").query(Product.class).list(); - } - - public Product create(Product product) { - String sql = - "INSERT INTO products(title, description) VALUES (:title,:description) RETURNING id"; - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcClient - .sql(sql) - .param("title", product.title()) - .param("description", product.description()) - .update(keyHolder); - Long id = keyHolder.getKeyAs(Long.class); - return new Product(id, product.title(), product.description()); - } -} -``` - -### Add a schema creation script - -Create `src/main/resources/schema.sql` to initialize the `products` table: - -```sql -CREATE TABLE products ( - id bigserial primary key, - title varchar not null, - description text -); -``` - -Enable schema initialization in `src/main/resources/application.properties`: - -```properties -spring.sql.init.mode=always -``` - -For production applications, use a database migration tool like Flyway or -Liquibase instead. - -### Implement the API endpoints - -Create `ProductController` with endpoints to fetch all products and create a -product: - -```java -package com.testcontainers.products.api; - -import com.testcontainers.products.domain.Product; -import com.testcontainers.products.domain.ProductRepository; -import jakarta.validation.Valid; -import java.util.List; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/products") -class ProductController { - - private final ProductRepository productRepository; - - ProductController(ProductRepository productRepository) { - this.productRepository = productRepository; - } - - @GetMapping - List getAll() { - return productRepository.getAll(); - } - - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - Product createProduct(@RequestBody @Valid Product product) { - return productRepository.create(product); - } -} -``` - -### Configure OAuth 2.0 security - -Create a `SecurityConfig` class that protects the API endpoints using JWT -token-based authentication: - -```java -package com.testcontainers.products.config; - -import static org.springframework.security.config.Customizer.withDefaults; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.CorsConfigurer; -import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -class SecurityConfig { - - @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(c -> - c - .requestMatchers(HttpMethod.GET, "/api/products") - .permitAll() - .requestMatchers(HttpMethod.POST, "/api/products") - .authenticated() - .anyRequest() - .authenticated() - ) - .sessionManagement(c -> - c.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - .cors(CorsConfigurer::disable) - .csrf(CsrfConfigurer::disable) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())); - return http.build(); - } -} -``` - -This configuration: - -- Permits unauthenticated access to `GET /api/products`. -- Requires authentication for `POST /api/products` and all other endpoints. -- Configures the OAuth 2.0 Resource Server with JWT token-based authentication. -- Disables CORS and CSRF because this is a stateless API. - -Add the JWT issuer URI to `application.properties`: - -```properties -spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9090/realms/keycloaktcdemo -``` - -### Export the Keycloak realm configuration - -Before writing the tests, export a Keycloak realm configuration so that the test -environment can import it automatically. Start a temporary Keycloak instance: - -```console -$ docker run -p 9090:8080 \ - -e KEYCLOAK_ADMIN=admin \ - -e KEYCLOAK_ADMIN_PASSWORD=admin \ - quay.io/keycloak/keycloak:25 start-dev -``` - -Open `http://localhost:9090` and sign in to the Admin Console with `admin/admin`. -Then set up the realm: - -1. In the top-left corner, select the realm drop-down and create a realm named - `keycloaktcdemo`. -2. Under the `keycloaktcdemo` realm, create a client with the following - settings: - - **Client ID**: `product-service` - - **Client Authentication**: `On` - - **Authentication flow**: select only **Service accounts roles** -3. On the **Client details** screen, go to the **Credentials** tab and copy the - **Client secret** value. - -Export the realm configuration: - -```console -$ docker ps -# copy the keycloak container id - -$ docker exec -it /bin/bash - -$ /opt/keycloak/bin/kc.sh export --dir /opt/keycloak/data/import --realm keycloaktcdemo - -$ exit - -$ docker cp :/opt/keycloak/data/import/keycloaktcdemo-realm.json keycloaktcdemo-realm.json -``` - -Copy the exported `keycloaktcdemo-realm.json` file into `src/test/resources`. - -## Write tests with Testcontainers - -To test the secured API endpoints, you need a running Keycloak instance and a -PostgreSQL database, plus a started Spring context. Testcontainers spins up both -services in Docker containers and connects them to Spring through dynamic -property registration. - -### Configure the test containers - -Spring Boot's Testcontainers support lets you declare containers as beans. For -Keycloak, `@ServiceConnection` isn't available, but you can use -`DynamicPropertyRegistry` to set the JWT issuer URI dynamically. - -Create `ContainersConfig.java` under `src/test/java`: - -```java -package com.testcontainers.products; - -import dasniko.testcontainers.keycloak.KeycloakContainer; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; -import org.springframework.context.annotation.Bean; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.testcontainers.postgresql.PostgreSQLContainer; - -@TestConfiguration(proxyBeanMethods = false) -public class ContainersConfig { - - static String POSTGRES_IMAGE = "postgres:16-alpine"; - static String KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:25.0"; - static String realmImportFile = "/keycloaktcdemo-realm.json"; - static String realmName = "keycloaktcdemo"; - - @Bean - @ServiceConnection - PostgreSQLContainer postgres() { - return new PostgreSQLContainer(POSTGRES_IMAGE); - } - - @Bean - KeycloakContainer keycloak(DynamicPropertyRegistry registry) { - var keycloak = new KeycloakContainer(KEYCLOAK_IMAGE) - .withRealmImportFile(realmImportFile); - registry.add( - "spring.security.oauth2.resourceserver.jwt.issuer-uri", - () -> keycloak.getAuthServerUrl() + "/realms/" + realmName - ); - return keycloak; - } -} -``` - -This configuration: - -- Declares a `PostgreSQLContainer` bean with `@ServiceConnection`, which starts - a PostgreSQL container and automatically registers the datasource properties. -- Declares a `KeycloakContainer` bean using the `quay.io/keycloak/keycloak:25.0` - image, imports the realm configuration file, and dynamically registers the JWT - issuer URI from the Keycloak container's auth server URL. - -### Write the test - -Create `ProductControllerTests.java`: - -```java -package com.testcontainers.products.api; - -import static io.restassured.RestAssured.given; -import static io.restassured.RestAssured.when; -import static java.util.Collections.singletonList; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.testcontainers.products.ContainersConfig; -import io.restassured.RestAssured; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.context.annotation.Import; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -@SpringBootTest(webEnvironment = RANDOM_PORT) -@Import(ContainersConfig.class) -class ProductControllerTests { - - static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"; - static final String CLIENT_ID = "product-service"; - static final String CLIENT_SECRET = "jTJJqdzeCSt3DmypfHZa42vX8U9rQKZ9"; - - @LocalServerPort - private int port; - - @Autowired - OAuth2ResourceServerProperties oAuth2ResourceServerProperties; - - @BeforeEach - void setup() { - RestAssured.port = port; - } - - @Test - void shouldGetProductsWithoutAuthToken() { - when().get("/api/products").then().statusCode(200); - } - - @Test - void shouldGetUnauthorizedWhenCreateProductWithoutAuthToken() { - given() - .contentType("application/json") - .body( - """ - { - "title": "New Product", - "description": "Brand New Product" - } - """ - ) - .when() - .post("/api/products") - .then() - .statusCode(401); - } - - @Test - void shouldCreateProductWithAuthToken() { - String token = getToken(); - - given() - .header("Authorization", "Bearer " + token) - .contentType("application/json") - .body( - """ - { - "title": "New Product", - "description": "Brand New Product" - } - """ - ) - .when() - .post("/api/products") - .then() - .statusCode(201); - } - - private String getToken() { - RestTemplate restTemplate = new RestTemplate(); - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - MultiValueMap map = new LinkedMultiValueMap<>(); - map.put("grant_type", singletonList(GRANT_TYPE_CLIENT_CREDENTIALS)); - map.put("client_id", singletonList(CLIENT_ID)); - map.put("client_secret", singletonList(CLIENT_SECRET)); - - String authServerUrl = - oAuth2ResourceServerProperties.getJwt().getIssuerUri() + - "/protocol/openid-connect/token"; - - var request = new HttpEntity<>(map, httpHeaders); - KeyCloakToken token = restTemplate.postForObject( - authServerUrl, - request, - KeyCloakToken.class - ); - - assert token != null; - return token.accessToken(); - } - - record KeyCloakToken(@JsonProperty("access_token") String accessToken) {} -} -``` - -Here's what the tests cover: - -- `shouldGetProductsWithoutAuthToken()` invokes `GET /api/products` without an - `Authorization` header. Because this endpoint is configured to permit - unauthenticated access, the response status code is 200. -- `shouldGetUnauthorizedWhenCreateProductWithoutAuthToken()` invokes the secured - `POST /api/products` endpoint without an `Authorization` header and asserts - the response status code is 401 (Unauthorized). -- `shouldCreateProductWithAuthToken()` first obtains an `access_token` using the - Client Credentials flow. It then includes the token as a Bearer token in the - `Authorization` header when invoking `POST /api/products` and asserts the - response status code is 201 (Created). - -The `getToken()` helper method requests an access token from the Keycloak token -endpoint using the client ID and client secret that were configured in the -exported realm. - -### Use Testcontainers for local development - -Spring Boot's Testcontainers support also works for local development. Create -`TestApplication.java` under `src/test/java`: - -```java -package com.testcontainers.products; - -import org.springframework.boot.SpringApplication; - -public class TestApplication { - - public static void main(String[] args) { - SpringApplication - .from(Application::main) - .with(ContainersConfig.class) - .run(args); - } -} -``` - -Run `TestApplication.java` from your IDE instead of the main `Application.java`. -It starts the containers defined in `ContainersConfig` and configures the -application to use the dynamically registered properties, so you don't have to -install or configure PostgreSQL and Keycloak manually. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -Or with Gradle: - -```console -$ ./gradlew test -``` - -You should see the Keycloak and PostgreSQL Docker containers start with the -realm settings imported and the tests pass. After the tests finish, the -containers stop and are removed automatically. - -### Summary - -The Testcontainers Keycloak module lets you develop and test applications using a -real Keycloak server instead of mocks. Testing against a real OAuth 2.0 -provider that mirrors your production setup gives you more confidence in your -security configuration and token-based authentication flows. - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [Getting started with Testcontainers in a Java Spring Boot project](https://testcontainers.com/guides/testing-spring-boot-rest-api-using-testcontainers/) -- [Testcontainers Keycloak module](https://testcontainers.com/modules/keycloak/) -- [testcontainers-keycloak GitHub repository](https://github.com/dasniko/testcontainers-keycloak) -- [Spring Boot OAuth 2.0 Resource Server](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html) diff --git a/content/guides/testcontainers-java-micronaut-kafka.md b/content/guides/testcontainers-java-micronaut-kafka.md deleted file mode 100644 index 4aa9e92637..0000000000 --- a/content/guides/testcontainers-java-micronaut-kafka.md +++ /dev/null @@ -1,472 +0,0 @@ ---- -title: Testing Micronaut Kafka Listener using Testcontainers -linkTitle: Micronaut Kafka -description: Learn how to test a Micronaut Kafka listener using Testcontainers with Kafka and MySQL modules. -keywords: testcontainers, java, micronaut, testing, kafka, mysql, jpa, awaitility -summary: | - Learn how to create a Micronaut application with a Kafka listener that persists data in MySQL, - then test it using Testcontainers Kafka and MySQL modules with Awaitility. -aliases: - - /guides/testcontainers-java-micronaut-kafka/create-project/ - - /guides/testcontainers-java-micronaut-kafka/run-tests/ - - /guides/testcontainers-java-micronaut-kafka/write-tests/ -params: - tags: [testing] - time: 25 minutes ---- - - - - -In this guide, you'll learn how to: - -- Create a Micronaut application with Kafka integration -- Implement a Kafka listener and persist data in a MySQL database -- Test the Kafka listener using Testcontainers and Awaitility - -## Prerequisites - -- Java 17+ -- Maven or Gradle -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Micronaut project - -### Set up the project - -Create a Micronaut project from [Micronaut Launch](https://micronaut.io/launch) by -selecting the **kafka**, **data-jpa**, **mysql**, **awaitility**, **assertj**, and -**testcontainers** features. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-testing-micronaut-kafka-listener). - -You'll use the [Awaitility](http://www.awaitility.org/) library to assert the -expectations of an asynchronous process flow. - -The key dependencies in `pom.xml` are: - -```xml - - io.micronaut.platform - micronaut-parent - 4.1.4 - - - - io.micronaut.data - micronaut-data-hibernate-jpa - compile - - - io.micronaut.kafka - micronaut-kafka - compile - - - io.micronaut.serde - micronaut-serde-jackson - compile - - - io.micronaut.sql - micronaut-jdbc-hikari - compile - - - mysql - mysql-connector-java - runtime - - - org.awaitility - awaitility - 4.2.0 - test - - - org.testcontainers - testcontainers-junit-jupiter - test - - - org.testcontainers - testcontainers-kafka - test - - - org.testcontainers - testcontainers-mysql - test - - -``` - -The Micronaut parent POM manages the Testcontainers BOM, so you don't need to -specify versions for Testcontainers modules individually. - -### Create the JPA entity - -The application listens to a topic called `product-price-changes`. When a -message arrives, it extracts the product code and price from the event payload -and updates the price for that product in the MySQL database. - -Create `Product.java`: - -```java -package com.testcontainers.demo; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.math.BigDecimal; - -@Entity -@Table(name = "products") -public class Product { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private String code; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private BigDecimal price; - - public Product() {} - - public Product(Long id, String code, String name, BigDecimal price) { - this.id = id; - this.code = code; - this.name = name; - this.price = price; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public BigDecimal getPrice() { - return price; - } - - public void setPrice(BigDecimal price) { - this.price = price; - } -} -``` - -### Create the Micronaut Data JPA repository - -Create a repository interface for the `Product` entity with a method to find a -product by code and a method to update the price for a given product code: - -```java -package com.testcontainers.demo; - -import io.micronaut.data.annotation.Query; -import io.micronaut.data.annotation.Repository; -import io.micronaut.data.jpa.repository.JpaRepository; -import java.math.BigDecimal; -import java.util.Optional; - -@Repository -public interface ProductRepository extends JpaRepository { - - Optional findByCode(String code); - - @Query("update Product p set p.price = :price where p.code = :productCode") - void updateProductPrice(String productCode, BigDecimal price); -} -``` - -Unlike Spring Data JPA, Micronaut Data uses compile-time annotation processing -to implement repository methods, avoiding runtime reflection. - -### Create the event payload - -Create a record named `ProductPriceChangedEvent` that represents the structure -of the event payload received from the Kafka topic: - -```java -package com.testcontainers.demo; - -import io.micronaut.serde.annotation.Serdeable; -import java.math.BigDecimal; - -@Serdeable -public record ProductPriceChangedEvent(String productCode, BigDecimal price) {} -``` - -The `@Serdeable` annotation tells Micronaut Serialization that this type can be -serialized and deserialized. - -The sender and receiver agree on the following JSON format: - -```json -{ - "productCode": "P100", - "price": 25.0 -} -``` - -### Implement the Kafka listener - -Create `ProductPriceChangedEventHandler.java`, which handles messages from the -`product-price-changes` topic and updates the product price in the database: - -```java -package com.testcontainers.demo; - -import static io.micronaut.configuration.kafka.annotation.OffsetReset.EARLIEST; - -import io.micronaut.configuration.kafka.annotation.KafkaListener; -import io.micronaut.configuration.kafka.annotation.Topic; -import jakarta.inject.Singleton; -import jakarta.transaction.Transactional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Singleton -@Transactional -class ProductPriceChangedEventHandler { - - private static final Logger LOG = LoggerFactory.getLogger(ProductPriceChangedEventHandler.class); - - private final ProductRepository productRepository; - - ProductPriceChangedEventHandler(ProductRepository productRepository) { - this.productRepository = productRepository; - } - - @Topic("product-price-changes") - @KafkaListener(offsetReset = EARLIEST, groupId = "demo") - public void handle(ProductPriceChangedEvent event) { - LOG.info("Received a ProductPriceChangedEvent with productCode:{}: ", event.productCode()); - productRepository.updateProductPrice(event.productCode(), event.price()); - } -} -``` - -Key details: - -- The `@KafkaListener` annotation marks this class as a Kafka message listener. - Setting `offsetReset` to `EARLIEST` makes the listener start consuming - messages from the beginning of the partition, which is useful during testing. -- The `@Topic` annotation specifies which topic to subscribe to. -- Micronaut handles JSON deserialization of the `ProductPriceChangedEvent` - automatically using Micronaut Serialization. - -### Configure the datasource - -Add the following properties to `src/main/resources/application.properties`: - -```properties -micronaut.application.name=tc-guide-testing-micronaut-kafka-listener -datasources.default.db-type=mysql -datasources.default.dialect=MYSQL -jpa.default.properties.hibernate.hbm2ddl.auto=update -jpa.default.entity-scan.packages=com.testcontainers.demo -datasources.default.driver-class-name=com.mysql.cj.jdbc.Driver -``` - -Hibernate's `hbm2ddl.auto=update` creates and updates the database schema -automatically. For testing, you'll override this to `create-drop` in the test -properties file. - -Create `src/test/resources/application-test.properties`: - -```properties -jpa.default.properties.hibernate.hbm2ddl.auto=create-drop -``` - -## Write tests with Testcontainers - -To test the Kafka listener, you need a running Kafka broker and a MySQL -database, plus a started Micronaut application context. Testcontainers spins up -both services in Docker containers and the `TestPropertyProvider` interface -connects them to Micronaut. - -### Create a Kafka client for testing - -First, create a `@KafkaClient` interface to publish events in the test: - -```java -package com.testcontainers.demo; - -import io.micronaut.configuration.kafka.annotation.KafkaClient; -import io.micronaut.configuration.kafka.annotation.KafkaKey; -import io.micronaut.configuration.kafka.annotation.Topic; - -@KafkaClient -public interface ProductPriceChangesClient { - - @Topic("product-price-changes") - void send(@KafkaKey String productCode, ProductPriceChangedEvent event); -} -``` - -Key details: - -- The `@KafkaClient` annotation designates this interface as a Kafka producer. -- The `@Topic` annotation specifies the target topic. -- The `@KafkaKey` annotation marks the parameter used as the Kafka message key. - If no such parameter exists, Micronaut sends the record with a null key. - -### Write the test - -Create `ProductPriceChangedEventHandlerTest.java`: - -```java -package com.testcontainers.demo; - -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -import io.micronaut.context.annotation.Property; -import io.micronaut.core.annotation.NonNull; -import io.micronaut.test.extensions.junit5.annotation.MicronautTest; -import io.micronaut.test.support.TestPropertyProvider; -import java.math.BigDecimal; -import java.time.Duration; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.testcontainers.kafka.ConfluentKafkaContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -@MicronautTest(transactional = false) -@Property(name = "datasources.default.driver-class-name", value = "org.testcontainers.jdbc.ContainerDatabaseDriver") -@Property(name = "datasources.default.url", value = "jdbc:tc:mysql:8.0.32:///db") -@Testcontainers(disabledWithoutDocker = true) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class ProductPriceChangedEventHandlerTest implements TestPropertyProvider { - - @Container - static final ConfluentKafkaContainer kafka = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.8.0"); - - @Override - public @NonNull Map getProperties() { - if (!kafka.isRunning()) { - kafka.start(); - } - return Collections.singletonMap("kafka.bootstrap.servers", kafka.getBootstrapServers()); - } - - @Test - void shouldHandleProductPriceChangedEvent( - ProductPriceChangesClient productPriceChangesClient, ProductRepository productRepository) { - Product product = new Product(null, "P100", "Product One", BigDecimal.TEN); - Long id = productRepository.save(product).getId(); - - ProductPriceChangedEvent event = new ProductPriceChangedEvent("P100", new BigDecimal("14.50")); - - productPriceChangesClient.send(event.productCode(), event); - - await().pollInterval(Duration.ofSeconds(3)).atMost(10, SECONDS).untilAsserted(() -> { - Optional optionalProduct = productRepository.findByCode("P100"); - assertThat(optionalProduct).isPresent(); - assertThat(optionalProduct.get().getCode()).isEqualTo("P100"); - assertThat(optionalProduct.get().getPrice()).isEqualTo(new BigDecimal("14.50")); - }); - - productRepository.deleteById(id); - } -} -``` - -Here's what the test does: - -- `@MicronautTest` initializes the Micronaut application context and the - embedded server. Setting `transactional` to `false` prevents each test method - from running inside a rolled-back transaction, which is necessary because the - Kafka listener processes messages in a separate thread. -- The `@Property` annotations override the datasource driver and URL to use the - Testcontainers special JDBC URL (`jdbc:tc:mysql:8.0.32:///db`). This spins up - a MySQL container and configures it as the datasource automatically. -- `@Testcontainers` and `@Container` manage the Kafka container lifecycle. - The `TestPropertyProvider` interface registers the Kafka bootstrap servers - with Micronaut so that the producer and consumer connect to the test container. -- `@TestInstance(TestInstance.Lifecycle.PER_CLASS)` creates a single test - instance for all test methods, which is required when implementing - `TestPropertyProvider`. -- The test creates a `Product` record in the database, then sends a - `ProductPriceChangedEvent` to the `product-price-changes` topic using the - `ProductPriceChangesClient`. -- Because Kafka message processing is asynchronous, the test uses - [Awaitility](http://www.awaitility.org/) to poll every 3 seconds (up to a - maximum of 10 seconds) until the product price in the database matches the - expected value. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -Or with Gradle: - -```console -$ ./gradlew test -``` - -You should see the Kafka and MySQL Docker containers start and all tests pass. -After the tests finish, the containers stop and are removed automatically. - -### Summary - -Testing with real Kafka and MySQL instances gives you more confidence in the -correctness of your code than mocks or embedded alternatives. The -Testcontainers library manages the container lifecycle so that your integration -tests run against the same services you use in production. - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [Testing REST API integrations in Micronaut apps using WireMock](/guides/testcontainers-java-micronaut-wiremock/) -- [Testing Spring Boot Kafka Listener using Testcontainers](/guides/testcontainers-java-spring-boot-kafka/) -- [Getting started with Testcontainers in a Java Spring Boot project](https://testcontainers.com/guides/testing-spring-boot-rest-api-using-testcontainers/) -- [Awaitility](http://www.awaitility.org/) -- [Testcontainers Kafka module](https://java.testcontainers.org/modules/kafka/) -- [Testcontainers MySQL module](https://java.testcontainers.org/modules/databases/mysql/) diff --git a/content/guides/testcontainers-java-micronaut-wiremock.md b/content/guides/testcontainers-java-micronaut-wiremock.md deleted file mode 100644 index 9cd2b0b052..0000000000 --- a/content/guides/testcontainers-java-micronaut-wiremock.md +++ /dev/null @@ -1,673 +0,0 @@ ---- -title: Testing REST API integrations in Micronaut apps using WireMock -linkTitle: Micronaut WireMock -description: Learn how to test REST API integrations in a Micronaut application using the Testcontainers WireMock module. -keywords: testcontainers, java, micronaut, testing, wiremock, rest api -summary: | - Learn how to create a Micronaut application that integrates with - external REST APIs, then test those integrations using WireMock - and the Testcontainers WireMock module. -aliases: - - /guides/testcontainers-java-micronaut-wiremock/create-project/ - - /guides/testcontainers-java-micronaut-wiremock/run-tests/ - - /guides/testcontainers-java-micronaut-wiremock/write-tests/ -params: - tags: [testing] - time: 20 minutes ---- - - - - -In this guide, you'll learn how to: - -- Create a Micronaut application that talks to external REST APIs -- Test external API integrations using WireMock -- Use the Testcontainers WireMock module to run WireMock as a Docker container - -## Prerequisites - -- Java 17+ -- Maven or Gradle -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Micronaut project - -### Set up the project - -Create a Micronaut project from [Micronaut Launch](https://micronaut.io/launch) -by selecting the **http-client**, **micronaut-test-rest-assured**, and -**testcontainers** features. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-testing-rest-api-integrations-in-micronaut-apps-using-wiremock). - -After generating the project, add the **WireMock** and **Testcontainers -WireMock** libraries as test dependencies. The key dependencies in `pom.xml` -are: - -```xml - - io.micronaut.platform - micronaut-parent - 4.1.2 - - - - 17 - 4.1.2 - netty - - - - - jitpack.io - https://jitpack.io - - - - - - io.micronaut - micronaut-http-client - compile - - - io.micronaut - micronaut-http-server-netty - compile - - - io.micronaut.serde - micronaut-serde-jackson - compile - - - io.micronaut.test - micronaut-test-junit5 - test - - - io.micronaut.test - micronaut-test-rest-assured - test - - - org.testcontainers - testcontainers-junit-jupiter - test - - - org.testcontainers - testcontainers - test - - - org.wiremock - wiremock-standalone - 3.2.0 - test - - - org.wiremock.integrations.testcontainers - wiremock-testcontainers-module - 1.0-alpha-13 - test - - -``` - -This guide builds an application that manages video albums. A third-party REST -API handles photo assets. For demonstration purposes, the application uses the -publicly available [JSONPlaceholder](https://jsonplaceholder.typicode.com/) API -as a photo service. - -The application exposes a `GET /api/albums/{albumId}` endpoint that calls the -photo service to fetch photos for a given album. -[WireMock](https://wiremock.org/) is a tool for building mock APIs. -Testcontainers provides a -[WireMock module](https://testcontainers.com/modules/wiremock/) that runs -WireMock as a Docker container. - -### Create the Album and Photo models - -Create `Album.java` using Java records. Annotate both records with `@Serdeable` -to allow serialization and deserialization: - -```java -package com.testcontainers.demo; - -import io.micronaut.serde.annotation.Serdeable; -import java.util.List; - -@Serdeable -public record Album(Long albumId, List photos) {} - -@Serdeable -record Photo(Long id, String title, String url, String thumbnailUrl) {} -``` - -### Create the PhotoServiceClient - -Micronaut provides -[declarative HTTP client](https://docs.micronaut.io/latest/guide/#httpClient) -support. Create an interface with a method that fetches photos for a given album -ID: - -```java -package com.testcontainers.demo; - -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.http.client.annotation.Client; -import java.util.List; - -@Client(id = "photosapi") -interface PhotoServiceClient { - - @Get("/albums/{albumId}/photos") - List getPhotos(@PathVariable Long albumId); -} -``` - -The `@Client(id = "photosapi")` annotation ties this client to a named -configuration. Add the following property to -`src/main/resources/application.properties` to set the base URL: - -```properties -micronaut.http.services.photosapi.url=https://jsonplaceholder.typicode.com -``` - -### Create the REST API endpoint - -Create `AlbumController.java`: - -```java -package com.testcontainers.demo; - -import static io.micronaut.scheduling.TaskExecutors.BLOCKING; - -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.PathVariable; -import io.micronaut.scheduling.annotation.ExecuteOn; - -@Controller("/api") -class AlbumController { - - private final PhotoServiceClient photoServiceClient; - - AlbumController(PhotoServiceClient photoServiceClient) { - this.photoServiceClient = photoServiceClient; - } - - @ExecuteOn(BLOCKING) - @Get("/albums/{albumId}") - public Album getAlbumById(@PathVariable Long albumId) { - return new Album(albumId, photoServiceClient.getPhotos(albumId)); - } -} -``` - -Here's what this controller does: - -- `@Controller("/api")` maps the controller to the `/api` path. -- Constructor injection provides a `PhotoServiceClient` bean. -- `@ExecuteOn(BLOCKING)` offloads blocking I/O to a separate thread pool so it - doesn't block the event loop. -- `@Get("/albums/{albumId}")` maps the `getAlbumById()` method to an HTTP GET - request. - -This endpoint calls the photo service for a given album ID and returns a -response like: - -```json -{ - "albumId": 1, - "photos": [ - { - "id": 51, - "title": "non sunt voluptatem placeat consequuntur rem incidunt", - "url": "https://via.placeholder.com/600/8e973b", - "thumbnailUrl": "https://via.placeholder.com/150/8e973b" - }, - { - "id": 52, - "title": "eveniet pariatur quia nobis reiciendis laboriosam ea", - "url": "https://via.placeholder.com/600/121fa4", - "thumbnailUrl": "https://via.placeholder.com/150/121fa4" - } - ] -} -``` - -## Write tests with WireMock and Testcontainers - -Mocking external API interactions at the HTTP protocol level, rather than -mocking Java methods, lets you verify marshalling and unmarshalling behavior and -simulate network issues. - -### Test with WireMock's JUnit 5 extension - -The first approach uses WireMock's `WireMockExtension` to start an in-process -WireMock server on a dynamic port. - -Create `AlbumControllerTest.java`: - -```java -package com.testcontainers.demo; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.Matchers.hasSize; - -import com.github.tomakehurst.wiremock.client.WireMock; -import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import io.micronaut.context.ApplicationContext; -import io.micronaut.http.MediaType; -import io.micronaut.runtime.server.EmbeddedServer; -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import java.util.Collections; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -class AlbumControllerTest { - - @RegisterExtension - static WireMockExtension wireMock = WireMockExtension.newInstance() - .options(wireMockConfig().dynamicPort()) - .build(); - - private Map getProperties() { - return Collections.singletonMap("micronaut.http.services.photosapi.url", wireMock.baseUrl()); - } - - @Test - void shouldGetAlbumById() { - try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) { - RestAssured.port = server.getPort(); - Long albumId = 1L; - String responseJson = - """ - [ - { - "id": 1, - "title": "accusamus beatae ad facilis cum similique qui sunt", - "url": "https://via.placeholder.com/600/92c952", - "thumbnailUrl": "https://via.placeholder.com/150/92c952" - }, - { - "id": 2, - "title": "reprehenderit est deserunt velit ipsam", - "url": "https://via.placeholder.com/600/771796", - "thumbnailUrl": "https://via.placeholder.com/150/771796" - } - ] - """; - wireMock.stubFor(WireMock.get(urlMatching("/albums/" + albumId + "/photos")) - .willReturn(aResponse() - .withHeader("Content-Type", MediaType.APPLICATION_JSON) - .withBody(responseJson))); - - given().contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(2)); - } - } - - @Test - void shouldReturnServerErrorWhenPhotoServiceCallFailed() { - try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) { - RestAssured.port = server.getPort(); - Long albumId = 2L; - wireMock.stubFor(WireMock.get(urlMatching("/albums/" + albumId + "/photos")) - .willReturn(aResponse().withStatus(500))); - - given().contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(500); - } - } -} -``` - -Here's what this test does: - -- `WireMockExtension` starts a WireMock server on a dynamic port. -- The `getProperties()` method overrides `micronaut.http.services.photosapi.url` - to point at the WireMock endpoint, so the application talks to WireMock - instead of the real photo service. -- `shouldGetAlbumById()` configures a mock response for - `/albums/{albumId}/photos`, sends a request to the application's - `/api/albums/{albumId}` endpoint, and verifies the response body. -- `shouldReturnServerErrorWhenPhotoServiceCallFailed()` configures WireMock to - return a 500 status and verifies the application propagates that error. - -### Stub using JSON mapping files - -Instead of stubbing with the WireMock Java API, you can use JSON mapping-based -configuration. - -Create `src/test/resources/wiremock/mappings/get-album-photos.json`: - -```json -{ - "mappings": [ - { - "request": { - "method": "GET", - "urlPattern": "/albums/([0-9]+)/photos" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "bodyFileName": "album-photos-resp-200.json" - } - }, - { - "request": { - "method": "GET", - "urlPattern": "/albums/2/photos" - }, - "response": { - "status": 500, - "headers": { - "Content-Type": "application/json" - } - } - }, - { - "request": { - "method": "GET", - "urlPattern": "/albums/3/photos" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "jsonBody": [] - } - } - ] -} -``` - -Create `src/test/resources/wiremock/__files/album-photos-resp-200.json`: - -```json -[ - { - "id": 1, - "title": "accusamus beatae ad facilis cum similique qui sunt", - "url": "https://via.placeholder.com/600/92c952", - "thumbnailUrl": "https://via.placeholder.com/150/92c952" - }, - { - "id": 2, - "title": "reprehenderit est deserunt velit ipsam", - "url": "https://via.placeholder.com/600/771796", - "thumbnailUrl": "https://via.placeholder.com/150/771796" - } -] -``` - -Then initialize WireMock to load stub mappings from these files: - -```java -@RegisterExtension -static WireMockExtension wireMock = WireMockExtension.newInstance() - .options( - wireMockConfig() - .dynamicPort() - .usingFilesUnderClasspath("wiremock") - ) - .build(); -``` - -With mapping files-based stubbing in place, write tests without needing -programmatic stubs: - -```java -@Test -void shouldGetAlbumById() { - Long albumId = 1L; - try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) { - RestAssured.port = server.getPort(); - - given().contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(2)); - } -} -``` - -### Use the Testcontainers WireMock module - -The [Testcontainers WireMock module](https://testcontainers.com/modules/wiremock/) -provisions a WireMock server as a standalone container within your tests, based -on [WireMock Docker](https://github.com/wiremock/wiremock-docker). - -Create `src/test/resources/mocks-config.json` with the stub mappings: - -```json -{ - "mappings": [ - { - "request": { - "method": "GET", - "urlPattern": "/albums/([0-9]+)/photos" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "bodyFileName": "album-photos-response.json" - } - }, - { - "request": { - "method": "GET", - "urlPattern": "/albums/2/photos" - }, - "response": { - "status": 500, - "headers": { - "Content-Type": "application/json" - } - } - }, - { - "request": { - "method": "GET", - "urlPattern": "/albums/3/photos" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "jsonBody": [] - } - } - ] -} -``` - -Create `src/test/resources/album-photos-response.json`: - -```json -[ - { - "id": 1, - "title": "accusamus beatae ad facilis cum similique qui sunt", - "url": "https://via.placeholder.com/600/92c952", - "thumbnailUrl": "https://via.placeholder.com/150/92c952" - }, - { - "id": 2, - "title": "reprehenderit est deserunt velit ipsam", - "url": "https://via.placeholder.com/600/771796", - "thumbnailUrl": "https://via.placeholder.com/150/771796" - } -] -``` - -Create `AlbumControllerTestcontainersTests.java`: - -```java -package com.testcontainers.demo; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.nullValue; - -import io.micronaut.context.ApplicationContext; -import io.micronaut.core.annotation.NonNull; -import io.micronaut.runtime.server.EmbeddedServer; -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import java.util.Collections; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.wiremock.integrations.testcontainers.WireMockContainer; - -@Testcontainers(disabledWithoutDocker = true) -class AlbumControllerTestcontainersTests { - - @Container - static WireMockContainer wiremockServer = new WireMockContainer("wiremock/wiremock:2.35.0") - .withMappingFromResource("mocks-config.json") - .withFileFromResource("album-photos-response.json"); - - @NonNull public Map getProperties() { - return Collections.singletonMap("micronaut.http.services.photosapi.url", wiremockServer.getBaseUrl()); - } - - @Test - void shouldGetAlbumById() { - Long albumId = 1L; - try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) { - RestAssured.port = server.getPort(); - - given().contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(2)); - } - } - - @Test - void shouldReturnServerErrorWhenPhotoServiceCallFailed() { - Long albumId = 2L; - try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) { - RestAssured.port = server.getPort(); - given().contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(500); - } - } - - @Test - void shouldReturnEmptyPhotos() { - Long albumId = 3L; - try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, getProperties())) { - RestAssured.port = server.getPort(); - given().contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", nullValue()); - } - } -} -``` - -Here's what this test does: - -- `@Testcontainers` and `@Container` annotations start a `WireMockContainer` - using the `wiremock/wiremock:2.35.0` Docker image. -- `withMappingFromResource("mocks-config.json")` loads stub mappings from the - classpath resource. -- `withFileFromResource("album-photos-response.json")` makes the response body - file available to WireMock. -- `getProperties()` overrides the photo service URL to point at the WireMock - container's base URL. -- `shouldGetAlbumById()` verifies that the application returns the expected - album with two photos. -- `shouldReturnServerErrorWhenPhotoServiceCallFailed()` verifies that a 500 - from the photo service propagates to the caller. -- `shouldReturnEmptyPhotos()` verifies the application handles an empty photo - list. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -Or with Gradle: - -```console -$ ./gradlew test -``` - -You should see the WireMock Docker container start in the console output. It -acts as the photo service, serving mock responses based on the configured -expectations. All tests should pass. - -### Summary - -You built a Micronaut application that integrates with an external REST API -using declarative HTTP clients, then tested that integration using WireMock and -the Testcontainers WireMock module. Testing at the HTTP protocol level instead -of mocking Java methods lets you catch serialization issues and simulate -realistic failure scenarios. - -> [!TIP] -> Testcontainers WireMock modules are available for Go and Python as well. - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [Testcontainers WireMock module](https://testcontainers.com/modules/wiremock/) -- [WireMock documentation](https://wiremock.org/docs/) -- [Testcontainers JUnit 5 quickstart](https://java.testcontainers.org/quickstart/junit_5_quickstart/) -- [Testing REST API integrations in Spring Boot using WireMock](/guides/testcontainers-java-wiremock/) diff --git a/content/guides/testcontainers-java-mockserver.md b/content/guides/testcontainers-java-mockserver.md deleted file mode 100644 index fe8a48c137..0000000000 --- a/content/guides/testcontainers-java-mockserver.md +++ /dev/null @@ -1,445 +0,0 @@ ---- -title: Testing REST API integrations using MockServer -linkTitle: MockServer -description: Learn how to test REST API integrations in a Spring Boot application using the Testcontainers MockServer module. -keywords: testcontainers, java, spring boot, testing, mockserver, rest api, rest assured -summary: | - Learn how to create a Spring Boot application that integrates with - external REST APIs, then test those integrations using Testcontainers - and MockServer. -aliases: - - /guides/testcontainers-java-mockserver/create-project/ - - /guides/testcontainers-java-mockserver/run-tests/ - - /guides/testcontainers-java-mockserver/write-tests/ -params: - tags: [testing] - time: 20 minutes ---- - - - - -In this guide, you will learn how to: - -- Create a Spring Boot application that talks to external REST APIs -- Test external API integrations using the Testcontainers MockServer module - -## Prerequisites - -- Java 17+ -- Maven or Gradle -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Spring Boot project - -### Set up the project - -Create a Spring Boot project from [Spring Initializr](https://start.spring.io) -by selecting the **Spring Web**, **Spring Reactive Web**, and **Testcontainers** -starters. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-testing-rest-api-integrations-using-mockserver). - -After generating the project, add the **REST Assured** and **MockServer** -libraries as test dependencies. The key dependencies in `pom.xml` are: - -```xml - - 17 - 2.0.4 - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-webflux - - - org.springframework.boot - spring-boot-starter-test - test - - - org.testcontainers - testcontainers-junit-jupiter - test - - - org.testcontainers - testcontainers-mockserver - test - - - org.mock-server - mockserver-netty - 5.15.0 - test - - - io.rest-assured - rest-assured - test - - -``` - -Using the Testcontainers BOM (Bill of Materials) is recommended so that you -don't have to repeat the version for every Testcontainers module dependency. - -This guide builds an application that manages video albums. A third-party REST -API handles photo assets. For demonstration purposes, the application uses the -publicly available [JSONPlaceholder](https://jsonplaceholder.typicode.com/) API -as a photo service. - -The application exposes a `GET /api/albums/{albumId}` endpoint that calls the -photo service to fetch photos for a given album. -[MockServer](https://www.mock-server.com/) is a library for mocking HTTP-based -services. Testcontainers provides a -[MockServer module](https://java.testcontainers.org/modules/mockserver/) that -runs MockServer as a Docker container. - -### Create the Album and Photo models - -Create `Album.java` using Java records: - -```java -package com.testcontainers.demo; - -import java.util.List; - -public record Album(Long albumId, List photos) {} - -record Photo(Long id, String title, String url, String thumbnailUrl) {} -``` - -### Create the PhotoServiceClient interface - -Spring Framework 6 introduced -[declarative HTTP client support](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-http-interface). -Create an interface with a method that fetches photos for a given album ID: - -```java -package com.testcontainers.demo; - -import java.util.List; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.service.annotation.GetExchange; - -interface PhotoServiceClient { - @GetExchange("/albums/{albumId}/photos") - List getPhotos(@PathVariable Long albumId); -} -``` - -### Register PhotoServiceClient as a bean - -To generate a runtime implementation of `PhotoServiceClient`, register it as a -Spring bean using `HttpServiceProxyFactory`. The factory requires an -`HttpClientAdapter` implementation. Spring Boot provides `WebClientAdapter` as -part of the `spring-webflux` library: - -```java -package com.testcontainers.demo; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.support.WebClientAdapter; -import org.springframework.web.service.invoker.HttpServiceProxyFactory; - -@Configuration -public class AppConfig { - - @Bean - public PhotoServiceClient photoServiceClient( - @Value("${photos.api.base-url}") String photosApiBaseUrl - ) { - WebClient client = WebClient.builder().baseUrl(photosApiBaseUrl).build(); - HttpServiceProxyFactory factory = HttpServiceProxyFactory - .builder(WebClientAdapter.forClient(client)) - .build(); - return factory.createClient(PhotoServiceClient.class); - } -} -``` - -The photo service base URL is externalized as a configuration property. Add the -following entry to `src/main/resources/application.properties`: - -```properties -photos.api.base-url=https://jsonplaceholder.typicode.com -``` - -### Create the REST API endpoint - -Create `AlbumController.java`: - -```java -package com.testcontainers.demo; - -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.reactive.function.client.WebClientResponseException; - -@RestController -@RequestMapping("/api") -class AlbumController { - - private static final Logger logger = LoggerFactory.getLogger( - AlbumController.class - ); - - private final PhotoServiceClient photoServiceClient; - - AlbumController(PhotoServiceClient photoServiceClient) { - this.photoServiceClient = photoServiceClient; - } - - @GetMapping("/albums/{albumId}") - public ResponseEntity getAlbumById(@PathVariable Long albumId) { - try { - List photos = photoServiceClient.getPhotos(albumId); - return ResponseEntity.ok(new Album(albumId, photos)); - } catch (WebClientResponseException e) { - logger.error("Failed to get photos", e); - return new ResponseEntity<>(e.getStatusCode()); - } - } -} -``` - -This endpoint calls the photo service for a given album ID and returns a -response like: - -```json -{ - "albumId": 1, - "photos": [ - { - "id": 51, - "title": "non sunt voluptatem placeat consequuntur rem incidunt", - "url": "https://via.placeholder.com/600/8e973b", - "thumbnailUrl": "https://via.placeholder.com/150/8e973b" - }, - { - "id": 52, - "title": "eveniet pariatur quia nobis reiciendis laboriosam ea", - "url": "https://via.placeholder.com/600/121fa4", - "thumbnailUrl": "https://via.placeholder.com/150/121fa4" - } - ] -} -``` - -## Write tests with Testcontainers MockServer - -Mocking external API interactions at the HTTP protocol level, rather than -mocking Java methods, lets you verify marshalling and unmarshalling behavior and -simulate network issues. - -Testcontainers provides a MockServer module that starts a -[MockServer](https://www.mock-server.com/) instance inside a Docker container. -You can then use `MockServerClient` to configure mock expectations. - -### Write the test - -Create `AlbumControllerTest.java`: - -```java -package com.testcontainers.demo; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.Matchers.hasSize; -import static org.mockserver.model.HttpRequest.request; -import static org.mockserver.model.HttpResponse.response; -import static org.mockserver.model.JsonBody.json; - -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockserver.client.MockServerClient; -import org.mockserver.model.Header; -import org.mockserver.verify.VerificationTimes; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.mockserver.MockServerContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Testcontainers -class AlbumControllerTest { - - @LocalServerPort - private Integer port; - - @Container - static MockServerContainer mockServerContainer = - new MockServerContainer("mockserver/mockserver:5.15.0"); - - static MockServerClient mockServerClient; - - @DynamicPropertySource - static void overrideProperties(DynamicPropertyRegistry registry) { - mockServerClient = - new MockServerClient( - mockServerContainer.getHost(), - mockServerContainer.getServerPort() - ); - registry.add("photos.api.base-url", mockServerContainer::getEndpoint); - } - - @BeforeEach - void setUp() { - RestAssured.port = port; - mockServerClient.reset(); - } - - @Test - void shouldGetAlbumById() { - Long albumId = 1L; - - mockServerClient - .when( - request().withMethod("GET").withPath("/albums/" + albumId + "/photos") - ) - .respond( - response() - .withStatusCode(200) - .withHeaders( - new Header("Content-Type", "application/json; charset=utf-8") - ) - .withBody( - json( - """ - [ - { - "id": 1, - "title": "accusamus beatae ad facilis cum similique qui sunt", - "url": "https://via.placeholder.com/600/92c952", - "thumbnailUrl": "https://via.placeholder.com/150/92c952" - }, - { - "id": 2, - "title": "reprehenderit est deserunt velit ipsam", - "url": "https://via.placeholder.com/600/771796", - "thumbnailUrl": "https://via.placeholder.com/150/771796" - } - ] - """ - ) - ) - ); - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(2)); - - verifyMockServerRequest("GET", "/albums/" + albumId + "/photos", 1); - } - - @Test - void shouldReturn404StatusWhenAlbumNotFound() { - Long albumId = 1L; - mockServerClient - .when( - request().withMethod("GET").withPath("/albums/" + albumId + "/photos") - ) - .respond(response().withStatusCode(404)); - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(404); - - verifyMockServerRequest("GET", "/albums/" + albumId + "/photos", 1); - } - - private void verifyMockServerRequest(String method, String path, int times) { - mockServerClient.verify( - request().withMethod(method).withPath(path), - VerificationTimes.exactly(times) - ); - } -} -``` - -Here's what the test does: - -- `@SpringBootTest` starts the full application on a random port. -- The `@Testcontainers` and `@Container` annotations start a - `MockServerContainer` and create a `MockServerClient` connected to it. -- `@DynamicPropertySource` overrides `photos.api.base-url` to point at the - MockServer endpoint, so the application talks to MockServer instead of the - real photo service. -- `@BeforeEach` resets the `MockServerClient` before every test so that - expectations from one test don't affect another. -- `shouldGetAlbumById()` configures a mock response for - `/albums/{albumId}/photos`, sends a request to the application's - `/api/albums/{albumId}` endpoint, and verifies the response body. It also - uses `mockServerClient.verify()` to confirm that the expected API call - reached MockServer. -- `shouldReturn404StatusWhenAlbumNotFound()` configures MockServer to return a - 404 status and verifies the application propagates that status to the caller. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -Or with Gradle: - -```console -$ ./gradlew test -``` - -You should see the MockServer Docker container start in the console output. It -acts as the photo service, serving mock responses based on the configured -expectations. All tests should pass. - -### Summary - -You built a Spring Boot application that integrates with an external REST API -using declarative HTTP clients, then tested that integration using the -Testcontainers MockServer module. Testing at the HTTP protocol level instead of -mocking Java methods lets you catch serialization issues and simulate realistic -failure scenarios. - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [Testcontainers MockServer module](https://java.testcontainers.org/modules/mockserver/) -- [MockServer documentation](https://www.mock-server.com/) -- [Testcontainers JUnit 5 quickstart](https://java.testcontainers.org/quickstart/junit_5_quickstart/) diff --git a/content/guides/testcontainers-java-quarkus.md b/content/guides/testcontainers-java-quarkus.md deleted file mode 100644 index 71b1447de4..0000000000 --- a/content/guides/testcontainers-java-quarkus.md +++ /dev/null @@ -1,474 +0,0 @@ ---- -title: Testing Quarkus applications with Testcontainers -linkTitle: Quarkus -description: Learn how to test a Quarkus REST API using Testcontainers with PostgreSQL, Hibernate ORM with Panache, and REST Assured. -keywords: testcontainers, java, quarkus, testing, postgresql, rest api, rest assured, panache, dev services -summary: | - Learn how to create a Quarkus REST API with Hibernate ORM with Panache and PostgreSQL, - then test it using Quarkus Dev Services, Testcontainers, and REST Assured. -aliases: - - /guides/testcontainers-java-quarkus/create-project/ - - /guides/testcontainers-java-quarkus/run-tests/ - - /guides/testcontainers-java-quarkus/write-tests/ -params: - tags: [testing] - time: 25 minutes ---- - - - - -In this guide, you'll learn how to: - -- Create a Quarkus application with REST API endpoints -- Use Hibernate ORM with Panache and PostgreSQL for persistence -- Test the REST API using Quarkus Dev Services, which uses Testcontainers behind - the scenes -- Test with services not supported by Dev Services using - `QuarkusTestResourceLifecycleManager` - -## Prerequisites - -- Java 17+ -- Maven or Gradle -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Quarkus project - -### Set up the project - -Create a Quarkus project from [code.quarkus.io](https://code.quarkus.io/) by -selecting the **RESTEasy Classic**, **RESTEasy Classic Jackson**, -**Hibernate Validator**, **Hibernate ORM with Panache**, **JDBC Driver - -PostgreSQL**, and **Flyway** extensions. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-testcontainers-in-quarkus-applications). - -The key dependencies in `pom.xml` are: - -```xml - - 3.22.3 - - - - io.quarkus - quarkus-hibernate-orm-panache - - - io.quarkus - quarkus-flyway - - - io.quarkus - quarkus-hibernate-validator - - - io.quarkus - quarkus-resteasy - - - io.quarkus - quarkus-resteasy-jackson - - - io.quarkus - quarkus-jdbc-postgresql - - - io.quarkus - quarkus-junit5 - test - - - io.rest-assured - rest-assured - test - - -``` - -### Create the JPA entity - -Hibernate ORM with Panache supports the Active Record pattern and the -Repository pattern to simplify JPA usage. This guide uses the Active Record -pattern. - -Create `Customer.java` by extending `PanacheEntity`. This gives the entity -built-in persistence methods such as `persist()`, `listAll()`, and -`findById()`. - -```java -package com.testcontainers.demo; - -import io.quarkus.hibernate.orm.panache.PanacheEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "customers") -public class Customer extends PanacheEntity { - - @Column(nullable = false) - public String name; - - @Column(nullable = false, unique = true) - public String email; - - public Customer() {} - - public Customer(Long id, String name, String email) { - this.id = id; - this.name = name; - this.email = email; - } -} -``` - -### Create the CustomerService CDI bean - -Create a `CustomerService` class annotated with `@ApplicationScoped` and -`@Transactional` to handle persistence operations: - -```java -package com.testcontainers.demo; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.transaction.Transactional; -import java.util.List; - -@ApplicationScoped -@Transactional -public class CustomerService { - - public List getAll() { - return Customer.listAll(); - } - - public Customer create(Customer customer) { - customer.persist(); - return customer; - } -} -``` - -### Add the Flyway database migration script - -Create `src/main/resources/db/migration/V1__init_database.sql`: - -```sql -create sequence customers_seq start with 1 increment by 50; - -create table customers -( - id bigint DEFAULT nextval('customers_seq') not null, - name varchar not null, - email varchar not null, - primary key (id) -); - -insert into customers(name, email) -values ('john', 'john@mail.com'), - ('rambo', 'rambo@mail.com'); -``` - -Enable Flyway migrations in `src/main/resources/application.properties`: - -```properties -quarkus.flyway.migrate-at-start=true -``` - -### Create the REST API endpoints - -Create `CustomerResource.java` with endpoints for fetching all customers and -creating a customer: - -```java -package com.testcontainers.demo; - -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import java.util.List; - -@Path("/api/customers") -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -public class CustomerResource { - private final CustomerService customerService; - - public CustomerResource(CustomerService customerService) { - this.customerService = customerService; - } - - @GET - public List getAllCustomers() { - return customerService.getAll(); - } - - @POST - public Response createCustomer(Customer customer) { - var savedCustomer = customerService.create(customer); - return Response.status(Response.Status.CREATED).entity(savedCustomer).build(); - } -} -``` - -## Write tests with Testcontainers - -### Quarkus Dev Services - -Quarkus Dev Services automatically provisions unconfigured services in -development and test mode. When you include an extension and don't configure it, -Quarkus starts the relevant service using -[Testcontainers](https://www.testcontainers.org/) behind the scenes and wires -the application to use that service. - -> [!NOTE] -> Dev Services requires a -> [supported Docker environment](https://www.testcontainers.org/supported_docker_environment/). - -Quarkus Dev Services supports most commonly used services like SQL databases, -Kafka, RabbitMQ, Redis, and MongoDB. For more information, see the -[Quarkus Dev Services guide](https://quarkus.io/guides/dev-services). - -### Write tests for the API endpoints - -Test the `GET /api/customers` and `POST /api/customers` endpoints using REST -Assured. The `io.rest-assured:rest-assured` library was already added as a test -dependency when you generated the project. - -Create `CustomerResourceTest.java` and annotate it with `@QuarkusTest`. This -bootstraps the application along with the required services using Dev Services. -Because you haven't configured datasource properties, Dev Services automatically -starts a PostgreSQL database using Testcontainers. - -```java -package com.testcontainers.demo; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; -import static org.junit.jupiter.api.Assertions.assertFalse; - -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.common.mapper.TypeRef; -import io.restassured.http.ContentType; -import java.util.List; -import org.junit.jupiter.api.Test; - -@QuarkusTest -class CustomerResourceTest { - - @Test - void shouldGetAllCustomers() { - List customers = given().when() - .get("/api/customers") - .then() - .statusCode(200) - .extract() - .as(new TypeRef<>() {}); - assertFalse(customers.isEmpty()); - } - - @Test - void shouldCreateCustomerSuccessfully() { - Customer customer = new Customer(null, "John", "john@gmail.com"); - given().contentType(ContentType.JSON) - .body(customer) - .when() - .post("/api/customers") - .then() - .statusCode(201) - .body("name", is("John")) - .body("email", is("john@gmail.com")); - } -} -``` - -Here's what the test does: - -- `@QuarkusTest` starts the full Quarkus application with Dev Services enabled. -- Dev Services starts a PostgreSQL container using Testcontainers and configures - the datasource automatically. -- `shouldGetAllCustomers()` calls `GET /api/customers` and verifies that seeded - data from the Flyway migration is returned. -- `shouldCreateCustomerSuccessfully()` sends a `POST /api/customers` request and - verifies the response contains the created customer data. - -### Customize test configuration - -By default, the Quarkus test instance starts on port 8081 and uses a -`postgres:14` Docker image. Customize both by adding these properties to -`src/main/resources/application.properties`: - -```properties -quarkus.http.test-port=0 -quarkus.datasource.devservices.image-name=postgres:15.2-alpine -``` - -Setting `quarkus.http.test-port=0` starts the application on a random available -port, avoiding port conflicts. The `devservices.image-name` property lets you -pin the PostgreSQL image to a specific version that matches production. - -### Test with services not supported by Dev Services - -Your application might use a service that Dev Services doesn't support out of -the box. In that case, use `QuarkusTestResourceLifecycleManager` to start the -service before the Quarkus application starts for testing. - -For example, suppose the application uses CockroachDB. First, add the -CockroachDB Testcontainers module dependency: - -```xml - - org.testcontainers - cockroachdb - test - -``` - -Create a `CockroachDBTestResource` that implements -`QuarkusTestResourceLifecycleManager`: - -```java -package com.testcontainers.demo; - -import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; -import java.util.HashMap; -import java.util.Map; -import org.testcontainers.containers.CockroachContainer; - -public class CockroachDBTestResource implements QuarkusTestResourceLifecycleManager { - - CockroachContainer cockroachdb; - - @Override - public Map start() { - cockroachdb = new CockroachContainer("cockroachdb/cockroach:v22.2.0"); - cockroachdb.start(); - Map conf = new HashMap<>(); - conf.put("quarkus.datasource.jdbc.url", cockroachdb.getJdbcUrl()); - conf.put("quarkus.datasource.username", cockroachdb.getUsername()); - conf.put("quarkus.datasource.password", cockroachdb.getPassword()); - return conf; - } - - @Override - public void stop() { - cockroachdb.stop(); - } -} -``` - -Use the `CockroachDBTestResource` with `@QuarkusTestResource` in a test class: - -```java -package com.testcontainers.demo; - -import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertFalse; - -import io.quarkus.test.common.QuarkusTestResource; -import io.quarkus.test.junit.QuarkusTest; -import io.restassured.common.mapper.TypeRef; -import java.util.List; -import org.junit.jupiter.api.Test; - -@QuarkusTest -@QuarkusTestResource(value = CockroachDBTestResource.class, restrictToAnnotatedClass = true) -class CockroachDBTest { - - @Test - void shouldGetAllCustomers() { - List customers = given().when() - .get("/api/customers") - .then() - .statusCode(200) - .extract() - .as(new TypeRef<>() {}); - assertFalse(customers.isEmpty()); - } -} -``` - -The `restrictToAnnotatedClass = true` attribute ensures the CockroachDB -container only starts when running this specific test class, rather than being -activated for all tests. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -Or with Gradle: - -```console -$ ./gradlew test -``` - -You should see the PostgreSQL Docker container start and all tests pass. After -the tests finish, the container stops and is removed automatically. - -### Run the application locally - -Quarkus Dev Services automatically provisions unconfigured services in -development mode. Start the Quarkus application in dev mode: - -```console -$ ./mvnw compile quarkus:dev -``` - -Or with Gradle: - -```console -$ ./gradlew quarkusDev -``` - -Dev Services starts a PostgreSQL container automatically. If you're running a -PostgreSQL database on your system and want to use that instead, configure the -datasource properties in `src/main/resources/application.properties`: - -```properties -quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/postgres -quarkus.datasource.username=postgres -quarkus.datasource.password=postgres -``` - -When these properties are set explicitly, Dev Services doesn't provision the -database container and instead connects to the configured database. - -### Summary - -Quarkus Dev Services improves the developer experience by automatically -provisioning the required services using Testcontainers during development and -testing. This guide covered: - -- Building a REST API using JAX-RS with Hibernate ORM with Panache -- Testing API endpoints using REST Assured with Dev Services handling database - provisioning -- Using `QuarkusTestResourceLifecycleManager` for services not supported by Dev - Services -- Running the application locally with Dev Services - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [Quarkus Dev Services overview](https://quarkus.io/guides/dev-services) -- [Quarkus testing guide](https://quarkus.io/guides/getting-started-testing) -- [Testcontainers Postgres module](https://java.testcontainers.org/modules/databases/postgres/) diff --git a/content/guides/testcontainers-java-spring-boot-kafka.md b/content/guides/testcontainers-java-spring-boot-kafka.md deleted file mode 100644 index 203db79898..0000000000 --- a/content/guides/testcontainers-java-spring-boot-kafka.md +++ /dev/null @@ -1,471 +0,0 @@ ---- -title: Testing Spring Boot Kafka Listener using Testcontainers -linkTitle: Spring Boot Kafka -description: Learn how to test a Spring Boot Kafka listener using Testcontainers with Kafka and MySQL modules. -keywords: testcontainers, java, spring boot, testing, kafka, mysql, jpa, awaitility -summary: | - Learn how to create a Spring Boot application with a Kafka listener that persists data in MySQL, - then test it using Testcontainers Kafka and MySQL modules with Awaitility. -aliases: - - /guides/testcontainers-java-spring-boot-kafka/create-project/ - - /guides/testcontainers-java-spring-boot-kafka/run-tests/ - - /guides/testcontainers-java-spring-boot-kafka/write-tests/ -params: - tags: [testing] - time: 25 minutes ---- - - - - -In this guide, you will learn how to: - -- Create a Spring Boot application with Kafka integration -- Implement a Kafka listener and persist data in a MySQL database -- Test the Kafka listener using Testcontainers and Awaitility - -## Prerequisites - -- Java 17+ -- Maven or Gradle -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Spring Boot project - -### Set up the project - -Create a Spring Boot project from [Spring Initializr](https://start.spring.io) -by selecting the **Spring for Apache Kafka**, **Spring Data JPA**, **MySQL Driver**, -and **Testcontainers** starters. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-testing-spring-boot-kafka-listener). - -After generating the application, add the Awaitility library as a test -dependency. You'll use it later to assert the expectations of an asynchronous -process flow. - -The key dependencies in `pom.xml` are: - -```xml - - 17 - 2.0.4 - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.kafka - spring-kafka - - - com.mysql - mysql-connector-j - runtime - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.kafka - spring-kafka-test - test - - - org.testcontainers - testcontainers-junit-jupiter - test - - - org.testcontainers - testcontainers-kafka - test - - - org.testcontainers - testcontainers-mysql - test - - - org.awaitility - awaitility - test - - -``` - -Using the Testcontainers BOM (Bill of Materials) is recommended so that you -don't have to repeat the version for every Testcontainers module dependency. - -### Create the JPA entity - -The application listens to a topic called `product-price-changes`. When a -message arrives, it extracts the product code and price from the event payload -and updates the price for that product in the MySQL database. - -Create `Product.java`: - -```java -package com.testcontainers.demo; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.math.BigDecimal; - -@Entity -@Table(name = "products") -class Product { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private String code; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private BigDecimal price; - - public Product() {} - - public Product(Long id, String code, String name, BigDecimal price) { - this.id = id; - this.code = code; - this.name = name; - this.price = price; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public BigDecimal getPrice() { - return price; - } - - public void setPrice(BigDecimal price) { - this.price = price; - } -} -``` - -### Create the Spring Data JPA repository - -Create a repository interface for the `Product` entity with a method to find a -product by code and a method to update the price for a given product code: - -```java -package com.testcontainers.demo; - -import java.math.BigDecimal; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -interface ProductRepository extends JpaRepository { - Optional findByCode(String code); - - @Modifying - @Query("update Product p set p.price = :price where p.code = :productCode") - void updateProductPrice( - @Param("productCode") String productCode, - @Param("price") BigDecimal price - ); -} -``` - -### Add a schema creation script - -Because the application doesn't use an in-memory database, you need to create -the MySQL tables. The recommended approach for production is a migration tool -like Flyway or Liquibase, but for this guide the built-in Spring Boot schema -initialization is sufficient. - -Create `src/main/resources/schema.sql`: - -```sql -create table products ( - id int NOT NULL AUTO_INCREMENT, - code varchar(255) not null, - name varchar(255) not null, - price numeric(5,2) not null, - PRIMARY KEY (id), - UNIQUE (code) -); -``` - -Enable schema initialization in `src/main/resources/application.properties`: - -```properties -spring.sql.init.mode=always -``` - -### Create the event payload - -Create a record named `ProductPriceChangedEvent` that represents the structure -of the event payload received from the Kafka topic: - -```java -package com.testcontainers.demo; - -import java.math.BigDecimal; - -record ProductPriceChangedEvent(String productCode, BigDecimal price) {} -``` - -The sender and receiver agree on the following JSON format: - -```json -{ - "productCode": "P100", - "price": 25.0 -} -``` - -### Implement the Kafka listener - -Create `ProductPriceChangedEventHandler.java`, which handles messages from the -`product-price-changes` topic and updates the product price in the database: - -```java -package com.testcontainers.demo; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@Transactional -class ProductPriceChangedEventHandler { - - private static final Logger log = LoggerFactory.getLogger( - ProductPriceChangedEventHandler.class - ); - - private final ProductRepository productRepository; - - ProductPriceChangedEventHandler(ProductRepository productRepository) { - this.productRepository = productRepository; - } - - @KafkaListener(topics = "product-price-changes", groupId = "demo") - public void handle(ProductPriceChangedEvent event) { - log.info( - "Received a ProductPriceChangedEvent with productCode:{}: ", - event.productCode() - ); - productRepository.updateProductPrice(event.productCode(), event.price()); - } -} -``` - -The `@KafkaListener` annotation specifies the topic name to listen to. Spring -Kafka handles serialization and deserialization based on the properties -configured in `application.properties`. - -### Configure Kafka serialization - -Add the following Kafka properties to -`src/main/resources/application.properties`: - -```properties -######## Kafka Configuration ######### -spring.kafka.bootstrap-servers=localhost:9092 -spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer -spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer - -spring.kafka.consumer.group-id=demo -spring.kafka.consumer.auto-offset-reset=latest -spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer -spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer -spring.kafka.consumer.properties.spring.json.trusted.packages=com.testcontainers.demo -``` - -The `productCode` key is (de)serialized using `StringSerializer`/`StringDeserializer`, -and the `ProductPriceChangedEvent` value is (de)serialized using -`JsonSerializer`/`JsonDeserializer`. - -## Write tests with Testcontainers - -To test the Kafka listener, you need a running Kafka broker and a MySQL -database, plus a started Spring context. Testcontainers spins up both services -in Docker containers and `@DynamicPropertySource` connects them to Spring. - -### Write the test - -Create `ProductPriceChangedEventHandlerTest.java`: - -```java -package com.testcontainers.demo; - -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -import java.math.BigDecimal; -import java.time.Duration; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.context.TestPropertySource; -import org.testcontainers.kafka.ConfluentKafkaContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -@SpringBootTest -@TestPropertySource( - properties = { - "spring.kafka.consumer.auto-offset-reset=earliest", - "spring.datasource.url=jdbc:tc:mysql:8.0.32:///db", - } -) -@Testcontainers -class ProductPriceChangedEventHandlerTest { - - @Container - static final ConfluentKafkaContainer kafka = - new ConfluentKafkaContainer("confluentinc/cp-kafka:7.8.0"); - - @DynamicPropertySource - static void overrideProperties(DynamicPropertyRegistry registry) { - registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers); - } - - @Autowired - private KafkaTemplate kafkaTemplate; - - @Autowired - private ProductRepository productRepository; - - @BeforeEach - void setUp() { - Product product = new Product(null, "P100", "Product One", BigDecimal.TEN); - productRepository.save(product); - } - - @Test - void shouldHandleProductPriceChangedEvent() { - ProductPriceChangedEvent event = new ProductPriceChangedEvent( - "P100", - new BigDecimal("14.50") - ); - - kafkaTemplate.send("product-price-changes", event.productCode(), event); - - await() - .pollInterval(Duration.ofSeconds(3)) - .atMost(10, SECONDS) - .untilAsserted(() -> { - Optional optionalProduct = productRepository.findByCode( - "P100" - ); - assertThat(optionalProduct).isPresent(); - assertThat(optionalProduct.get().getCode()).isEqualTo("P100"); - assertThat(optionalProduct.get().getPrice()) - .isEqualTo(new BigDecimal("14.50")); - }); - } -} -``` - -Here's what the test does: - -- `@SpringBootTest` starts the full Spring application context. -- The Testcontainers special JDBC URL (`jdbc:tc:mysql:8.0.32:///db`) in - `@TestPropertySource` spins up a MySQL container and configures it as the - datasource automatically. -- `@Testcontainers` and `@Container` manage the lifecycle of the Kafka - container. `@DynamicPropertySource` registers the Kafka bootstrap servers - with Spring so that the producer and consumer connect to the test container. -- `@BeforeEach` creates a `Product` record in the database before each test. -- The test sends a `ProductPriceChangedEvent` to the `product-price-changes` - topic using `KafkaTemplate`. Spring Boot converts the object to JSON using - `JsonSerializer`. -- Because Kafka message processing is asynchronous, the test uses - [Awaitility](http://www.awaitility.org/) to poll every 3 seconds (up to a - maximum of 10 seconds) until the product price in the database matches the - expected value. -- The property `spring.kafka.consumer.auto-offset-reset` is set to `earliest` - so that the listener consumes messages even if they're sent to the topic - before the listener is ready. This setting is helpful when running tests. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -Or with Gradle: - -```console -$ ./gradlew test -``` - -You should see the Kafka and MySQL Docker containers start and all tests pass. -After the tests finish, the containers stop and are removed automatically. - -### Summary - -Testing with real Kafka and MySQL instances gives you more confidence in the -correctness of your code than mocks or embedded alternatives. The -Testcontainers library manages the container lifecycle so that your integration -tests run against the same services you use in production. - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [Getting started with Testcontainers in a Java Spring Boot project](https://testcontainers.com/guides/testing-spring-boot-rest-api-using-testcontainers/) -- [The simplest way to replace H2 with a real database for testing](https://testcontainers.com/guides/replace-h2-with-real-database-for-testing/) -- [Awaitility](http://www.awaitility.org/) -- [Testcontainers Kafka module](https://java.testcontainers.org/modules/kafka/) -- [Testcontainers MySQL module](https://java.testcontainers.org/modules/databases/mysql/) diff --git a/content/guides/testcontainers-java-wiremock.md b/content/guides/testcontainers-java-wiremock.md deleted file mode 100644 index 536683ddc4..0000000000 --- a/content/guides/testcontainers-java-wiremock.md +++ /dev/null @@ -1,770 +0,0 @@ ---- -title: Testing REST API integrations using WireMock -linkTitle: WireMock -description: Learn how to test REST API integrations in a Spring Boot application using the Testcontainers WireMock module. -keywords: testcontainers, java, spring boot, testing, wiremock, rest api, rest assured -summary: | - Learn how to create a Spring Boot application that integrates with - external REST APIs, then test those integrations using Testcontainers - and WireMock. -aliases: - - /guides/testcontainers-java-wiremock/create-project/ - - /guides/testcontainers-java-wiremock/run-tests/ - - /guides/testcontainers-java-wiremock/write-tests/ -params: - tags: [testing] - time: 20 minutes ---- - - - - -In this guide, you'll learn how to: - -- Create a Spring Boot application that talks to external REST APIs -- Test external API integrations using WireMock with both the JUnit 5 extension - and the Testcontainers WireMock module - -## Prerequisites - -- Java 17+ -- Maven or Gradle -- A Docker environment supported by Testcontainers - -> [!NOTE] -> If you're new to Testcontainers, visit the -> [Testcontainers overview](https://testcontainers.com/getting-started/) to learn more about -> Testcontainers and the benefits of using it. - -## Create the Spring Boot project - -### Set up the project - -Create a Spring Boot project from [Spring Initializr](https://start.spring.io) -by selecting the **Spring Web** and **Testcontainers** starters. - -Alternatively, clone the -[guide repository](https://github.com/testcontainers/tc-guide-testing-rest-api-integrations-using-wiremock). - -After generating the project, add the **REST Assured**, **WireMock**, and -**WireMock Testcontainers module** libraries as test dependencies. The key -dependencies in `pom.xml` are: - -```xml - - 17 - 2.0.4 - 1.0-alpha-13 - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-test - test - - - org.testcontainers - testcontainers-junit-jupiter - test - - - org.wiremock - wiremock-standalone - 3.6.0 - test - - - org.wiremock.integrations.testcontainers - wiremock-testcontainers-module - ${wiremock-testcontainers.version} - test - - - io.rest-assured - rest-assured - test - - -``` - -Using the Testcontainers BOM (Bill of Materials) is recommended so that you -don't have to repeat the version for every Testcontainers module dependency. - -This guide builds an application that manages video albums. A third-party REST -API handles photo assets. For demonstration purposes, the application uses the -publicly available [JSONPlaceholder](https://jsonplaceholder.typicode.com/) API -as a photo service. - -The application exposes a `GET /api/albums/{albumId}` endpoint that calls the -photo service to fetch photos for a given album. -[WireMock](https://wiremock.org/) is a tool for building mock APIs. -Testcontainers provides a -[WireMock module](https://testcontainers.com/modules/wiremock/) that runs -WireMock as a Docker container. - -### Create the Album and Photo models - -Create `Album.java` using Java records: - -```java -package com.testcontainers.demo; - -import java.util.List; - -public record Album(Long albumId, List photos) {} - -record Photo(Long id, String title, String url, String thumbnailUrl) {} -``` - -### Create the PhotoServiceClient - -Create `PhotoServiceClient.java`, which uses `RestTemplate` to fetch photos for -a given album ID: - -```java -package com.testcontainers.demo; - -import java.util.List; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -@Service -class PhotoServiceClient { - - private final String baseUrl; - private final RestTemplate restTemplate; - - PhotoServiceClient( - @Value("${photos.api.base-url}") String baseUrl, - RestTemplateBuilder builder - ) { - this.baseUrl = baseUrl; - this.restTemplate = builder.build(); - } - - List getPhotos(Long albumId) { - String url = baseUrl + "/albums/{albumId}/photos"; - ResponseEntity> response = restTemplate.exchange( - url, - HttpMethod.GET, - null, - new ParameterizedTypeReference<>() {}, - albumId - ); - return response.getBody(); - } -} -``` - -The photo service base URL is externalized as a configuration property. Add the -following entry to `src/main/resources/application.properties`: - -```properties -photos.api.base-url=https://jsonplaceholder.typicode.com -``` - -### Create the REST API endpoint - -Create `AlbumController.java`: - -```java -package com.testcontainers.demo; - -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.client.RestClientResponseException; - -@RestController -@RequestMapping("/api") -class AlbumController { - - private static final Logger logger = LoggerFactory.getLogger( - AlbumController.class - ); - - private final PhotoServiceClient photoServiceClient; - - AlbumController(PhotoServiceClient photoServiceClient) { - this.photoServiceClient = photoServiceClient; - } - - @GetMapping("/albums/{albumId}") - public ResponseEntity getAlbumById(@PathVariable Long albumId) { - try { - List photos = photoServiceClient.getPhotos(albumId); - return ResponseEntity.ok(new Album(albumId, photos)); - } catch (RestClientResponseException e) { - logger.error("Failed to get photos", e); - return new ResponseEntity<>(e.getStatusCode()); - } - } -} -``` - -This endpoint calls the photo service for a given album ID and returns a -response like: - -```json -{ - "albumId": 1, - "photos": [ - { - "id": 51, - "title": "non sunt voluptatem placeat consequuntur rem incidunt", - "url": "https://via.placeholder.com/600/8e973b", - "thumbnailUrl": "https://via.placeholder.com/150/8e973b" - }, - { - "id": 52, - "title": "eveniet pariatur quia nobis reiciendis laboriosam ea", - "url": "https://via.placeholder.com/600/121fa4", - "thumbnailUrl": "https://via.placeholder.com/150/121fa4" - } - ] -} -``` - -## Write tests with WireMock and Testcontainers - -Mocking external API interactions at the HTTP protocol level, rather than -mocking Java methods, lets you verify marshalling and unmarshalling behavior and -simulate network issues. - -### Test using WireMock JUnit 5 extension - -WireMock provides a JUnit 5 extension that starts an in-process WireMock server. -You can configure stub responses using the WireMock Java API. - -Create `AlbumControllerTest.java`: - -```java -package com.testcontainers.demo; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.Matchers.hasSize; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; - -import com.github.tomakehurst.wiremock.client.WireMock; -import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.http.MediaType; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; - -@SpringBootTest(webEnvironment = RANDOM_PORT) -class AlbumControllerTest { - - @LocalServerPort - private Integer port; - - @RegisterExtension - static WireMockExtension wireMock = WireMockExtension - .newInstance() - .options(wireMockConfig().dynamicPort()) - .build(); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("photos.api.base-url", wireMock::baseUrl); - } - - @BeforeEach - void setUp() { - RestAssured.port = port; - } - - @Test - void shouldGetAlbumById() { - Long albumId = 1L; - - wireMock.stubFor( - WireMock - .get(urlMatching("/albums/" + albumId + "/photos")) - .willReturn( - aResponse() - .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) - .withBody( - """ - [ - { - "id": 1, - "title": "accusamus beatae ad facilis cum similique qui sunt", - "url": "https://via.placeholder.com/600/92c952", - "thumbnailUrl": "https://via.placeholder.com/150/92c952" - }, - { - "id": 2, - "title": "reprehenderit est deserunt velit ipsam", - "url": "https://via.placeholder.com/600/771796", - "thumbnailUrl": "https://via.placeholder.com/150/771796" - } - ] - """ - ) - ) - ); - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(2)); - } - - @Test - void shouldReturnServerErrorWhenPhotoServiceCallFailed() { - Long albumId = 2L; - wireMock.stubFor( - WireMock - .get(urlMatching("/albums/" + albumId + "/photos")) - .willReturn(aResponse().withStatus(500)) - ); - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(500); - } -} -``` - -Here's what the test does: - -- `@SpringBootTest` starts the full application on a random port. -- `@RegisterExtension` creates a `WireMockExtension` that starts WireMock on a - dynamic port. -- `@DynamicPropertySource` overrides `photos.api.base-url` to point at the - WireMock endpoint, so the application talks to WireMock instead of the real - photo service. -- `shouldGetAlbumById()` configures a stub response for - `/albums/{albumId}/photos`, sends a request to the application's - `/api/albums/{albumId}` endpoint, and verifies the response body. -- `shouldReturnServerErrorWhenPhotoServiceCallFailed()` configures WireMock to - return a 500 status and verifies that the application propagates that status to - the caller. - -### Stub using JSON mapping files - -Instead of using the WireMock Java API, you can configure stubs with JSON -mapping files. Create -`src/test/resources/wiremock/mappings/get-album-photos.json`: - -```json -{ - "mappings": [ - { - "request": { - "method": "GET", - "urlPattern": "/albums/([0-9]+)/photos" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "bodyFileName": "album-photos-resp-200.json" - } - }, - { - "request": { - "method": "GET", - "urlPattern": "/albums/2/photos" - }, - "response": { - "status": 500, - "headers": { - "Content-Type": "application/json" - } - } - }, - { - "request": { - "method": "GET", - "urlPattern": "/albums/3/photos" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "jsonBody": [] - } - } - ] -} -``` - -Create the response body file at -`src/test/resources/wiremock/__files/album-photos-resp-200.json`: - -```json -[ - { - "id": 1, - "title": "accusamus beatae ad facilis cum similique qui sunt", - "url": "https://via.placeholder.com/600/92c952", - "thumbnailUrl": "https://via.placeholder.com/150/92c952" - }, - { - "id": 2, - "title": "reprehenderit est deserunt velit ipsam", - "url": "https://via.placeholder.com/600/771796", - "thumbnailUrl": "https://via.placeholder.com/150/771796" - } -] -``` - -Initialize WireMock to load stubs from the mapping files: - -```java -@RegisterExtension -static WireMockExtension wireMockServer = WireMockExtension - .newInstance() - .options( - wireMockConfig().dynamicPort().usingFilesUnderClasspath("wiremock") - ) - .build(); -``` - -With mapping-based stubs in place, create -`AlbumControllerWireMockMappingTests.java`: - -```java -package com.testcontainers.demo; - -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.Matchers.hasSize; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; - -import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; - -@SpringBootTest(webEnvironment = RANDOM_PORT) -class AlbumControllerWireMockMappingTests { - - @LocalServerPort - private Integer port; - - @RegisterExtension - static WireMockExtension wireMockServer = WireMockExtension - .newInstance() - .options( - wireMockConfig().dynamicPort().usingFilesUnderClasspath("wiremock") - ) - .build(); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("photos.api.base-url", wireMockServer::baseUrl); - } - - @BeforeEach - void setUp() { - RestAssured.port = port; - } - - @Test - void shouldGetAlbumById() { - Long albumId = 1L; - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(2)); - } - - @Test - void shouldReturnServerErrorWhenPhotoServiceCallFailed() { - Long albumId = 2L; - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(500); - } - - @Test - void shouldReturnEmptyPhotos() { - Long albumId = 3L; - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(0)); - } -} -``` - -The tests don't need inline stub definitions because WireMock loads the mappings -automatically from the classpath. - -### Test using the Testcontainers WireMock module - -The [Testcontainers WireMock module](https://testcontainers.com/modules/wiremock/) -provisions WireMock as a standalone Docker container, based on -[WireMock Docker](https://github.com/wiremock/wiremock-docker). This approach is -useful when you want complete isolation between the test JVM and the mock server. - -Create a mock configuration file at -`src/test/resources/com/testcontainers/demo/AlbumControllerTestcontainersTests/mocks-config.json`: - -```json -{ - "mappings": [ - { - "request": { - "method": "GET", - "urlPattern": "/albums/([0-9]+)/photos" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "bodyFileName": "album-photos-response.json" - } - }, - { - "request": { - "method": "GET", - "urlPattern": "/albums/2/photos" - }, - "response": { - "status": 500, - "headers": { - "Content-Type": "application/json" - } - } - }, - { - "request": { - "method": "GET", - "urlPattern": "/albums/3/photos" - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json" - }, - "jsonBody": [] - } - } - ] -} -``` - -Create the response body file at -`src/test/resources/com/testcontainers/demo/AlbumControllerTestcontainersTests/album-photos-response.json`: - -```json -[ - { - "id": 1, - "title": "accusamus beatae ad facilis cum similique qui sunt", - "url": "https://via.placeholder.com/600/92c952", - "thumbnailUrl": "https://via.placeholder.com/150/92c952" - }, - { - "id": 2, - "title": "reprehenderit est deserunt velit ipsam", - "url": "https://via.placeholder.com/600/771796", - "thumbnailUrl": "https://via.placeholder.com/150/771796" - } -] -``` - -Create `AlbumControllerTestcontainersTests.java`: - -```java -package com.testcontainers.demo; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.Matchers.hasSize; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; - -import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.wiremock.integrations.testcontainers.WireMockContainer; - -@SpringBootTest(webEnvironment = RANDOM_PORT) -@Testcontainers -class AlbumControllerTestcontainersTests { - - @LocalServerPort - private Integer port; - - @Container - static WireMockContainer wiremockServer = new WireMockContainer( - "wiremock/wiremock:3.6.0" - ) - .withMapping( - "photos-by-album", - AlbumControllerTestcontainersTests.class, - "mocks-config.json" - ) - .withFileFromResource( - "album-photos-response.json", - AlbumControllerTestcontainersTests.class, - "album-photos-response.json" - ); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("photos.api.base-url", wiremockServer::getBaseUrl); - } - - @BeforeEach - void setUp() { - RestAssured.port = port; - } - - @Test - void shouldGetAlbumById() { - Long albumId = 1L; - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(2)); - } - - @Test - void shouldReturnServerErrorWhenPhotoServiceCallFailed() { - Long albumId = 2L; - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(500); - } - - @Test - void shouldReturnEmptyPhotos() { - Long albumId = 3L; - - given() - .contentType(ContentType.JSON) - .when() - .get("/api/albums/{albumId}", albumId) - .then() - .statusCode(200) - .body("albumId", is(albumId.intValue())) - .body("photos", hasSize(0)); - } -} -``` - -Here's what the test does: - -- The `@Testcontainers` and `@Container` annotations start a - `WireMockContainer` using the `wiremock/wiremock:3.6.0` Docker image. -- `withMapping()` loads stub mappings from `mocks-config.json`, and - `withFileFromResource()` loads the response body file. -- `@DynamicPropertySource` overrides `photos.api.base-url` to point at the - WireMock container's base URL. -- The tests don't contain inline stub definitions because WireMock loads them - from the JSON configuration files. - -## Run tests and next steps - -### Run the tests - -```console -$ ./mvnw test -``` - -Or with Gradle: - -```console -$ ./gradlew test -``` - -You should see the WireMock Docker container start in the console output. It -acts as the photo service, serving mock responses based on the configured -expectations. All tests should pass. - -### Summary - -You built a Spring Boot application that integrates with an external REST API, -then tested that integration using three different approaches: - -- WireMock JUnit 5 extension with inline stubs -- WireMock JUnit 5 extension with JSON mapping files -- Testcontainers WireMock module running WireMock in a Docker container - -Testing at the HTTP protocol level instead of mocking Java methods lets you -catch serialization issues and simulate realistic failure scenarios. - -To learn more about Testcontainers, visit the -[Testcontainers overview](https://testcontainers.com/getting-started/). - -### Further reading - -- [Testcontainers WireMock module](https://testcontainers.com/modules/wiremock/) -- [WireMock documentation](https://wiremock.org/docs/) -- [Testcontainers JUnit 5 quickstart](https://java.testcontainers.org/quickstart/junit_5_quickstart/) diff --git a/content/manuals/testcontainers.md b/content/manuals/testcontainers.md index 3107688a04..0f69fecf18 100644 --- a/content/manuals/testcontainers.md +++ b/content/manuals/testcontainers.md @@ -74,12 +74,3 @@ with different languages and popular frameworks: - [Replace H2 with a real database for testing](/guides/testcontainers-java-replace-h2/) - [Configuration of services running in a container](/guides/testcontainers-java-service-configuration/) - [Testing an ASP.NET Core web app](/guides/testcontainers-dotnet-aspnet-core/) -- [Testing Spring Boot Kafka Listener](/guides/testcontainers-java-spring-boot-kafka/) -- [Testing REST API integrations using MockServer](/guides/testcontainers-java-mockserver/) -- [Testing AWS service integrations using LocalStack](/guides/testcontainers-java-aws-localstack/) -- [Testing Quarkus applications with Testcontainers](/guides/testcontainers-java-quarkus/) -- [Working with jOOQ and Flyway using Testcontainers](/guides/testcontainers-java-jooq-flyway/) -- [Testing REST API integrations using WireMock](/guides/testcontainers-java-wiremock/) -- [Securing Spring Boot with Keycloak and Testcontainers](/guides/testcontainers-java-keycloak-spring-boot/) -- [Testing Micronaut REST API with WireMock](/guides/testcontainers-java-micronaut-wiremock/) -- [Testing Micronaut Kafka Listener](/guides/testcontainers-java-micronaut-kafka/)