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/)