diff --git a/.claude/skills/testcontainers-guides-migrator/SKILL.md b/.claude/skills/testcontainers-guides-migrator/SKILL.md index 45c13a0aa17..2be7e74daad 100644 --- a/.claude/skills/testcontainers-guides-migrator/SKILL.md +++ b/.claude/skills/testcontainers-guides-migrator/SKILL.md @@ -52,7 +52,7 @@ These are the 21 guides from testcontainers.com/guides/ and their source repos: | 20 | Getting started for Python | tc-guide-getting-started-with-testcontainers-for-python | python | getting-started | | 21 | Keycloak with Spring Boot | tc-guide-securing-spring-boot-microservice-using-keycloak-and-testcontainers | java | keycloak-spring-boot | -Already migrated: **#13 (Go getting-started)**, **#20 (Python getting-started)** +Already migrated: **#2 (Java getting-started)**, **#13 (Go getting-started)**, **#20 (Python getting-started)** ## Step 0: Pre-flight @@ -137,8 +137,9 @@ For each language, check the cloned repo's existing code, then update to the lat - No `TearDownSuite()` needed if `CleanupContainer` is registered in the helper - Go version prerequisite: 1.25+ -**Java** (testcontainers-java): -- Check the latest BOM version at https://java.testcontainers.org/ +**Java** (testcontainers-java 2.0.4): +- Artifacts renamed in 2.x: `org.testcontainers:postgresql` → `org.testcontainers:testcontainers-postgresql` +- Check the latest version at https://java.testcontainers.org/ - Use `@Testcontainers` and `@Container` annotations for JUnit 5 lifecycle - Prefer module-specific containers (e.g. `PostgreSQLContainer`) over `GenericContainer` - Use `@DynamicPropertySource` for Spring Boot integration @@ -246,17 +247,90 @@ If compilation fails, fix the code and update the guide markdown to match. ### 6d: Run tests in a container with Docker socket mounted -Run tests in the same kind of container, but **mount the Docker socket** so Testcontainers can create sibling containers: +Run tests in the same kind of container, but **mount the Docker socket** so Testcontainers can create sibling containers. + +#### macOS Docker Desktop workarounds + +When running on macOS with Docker Desktop, these environment variables and flags are **required**: + +- **`TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal`** — On macOS, containers can't reach sibling containers via the Docker bridge IP (`172.17.0.x`). This tells Testcontainers (including Ryuk) to connect via `host.docker.internal` instead. **Do NOT disable Ryuk** — it is a core Testcontainers feature and the guides must demonstrate proper usage. +- **`docker-java.properties`** with `api.version=1.47` — Docker Desktop's minimum API version is 1.44, but docker-java defaults to 1.24. Create this file in the project root and mount it to `/root/.docker-java.properties` inside Java containers. +- **`-Dspotless.check.skip=true`** — The Spotless Maven plugin in the source repos is incompatible with JDK 21. Skip it since it's a code formatter, not part of the test. +- **`-Dmicronaut.test.resources.enabled=false`** — Micronaut's Test Resources service starts a separate process that can't connect to Docker from inside a container. The guide tests use Testcontainers directly, not Test Resources. Only needed for Micronaut guides. +#### Java guide test command + +```bash +# Create docker-java.properties in the project root +echo "api.version=1.47" > /{REPO_NAME}/docker-java.properties + +docker run --rm \ + -v "/{REPO_NAME}":/app \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "/{REPO_NAME}/docker-java.properties":/root/.docker-java.properties \ + -e DOCKER_HOST=unix:///var/run/docker.sock \ + -e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal \ + -w /app \ + maven:3.9-eclipse-temurin-21 \ + mvn -B test -Dspotless.check.skip=true -Dspotless.apply.skip=true +``` + +For Quarkus guides, use `maven:3.9-eclipse-temurin-17` instead (Quarkus 3.22.3 compiles for Java 17). + +#### Go guide test command + +```bash +docker run --rm \ + -v "/{REPO_NAME}":/app \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e DOCKER_HOST=unix:///var/run/docker.sock \ + -e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal \ + -w /app \ + golang:1.25-alpine \ + sh -c "apk add --no-cache gcc musl-dev && go test -v -count=1 ./..." +``` + +#### Python guide test command ```bash docker run --rm \ -v "/{REPO_NAME}":/app \ -v /var/run/docker.sock:/var/run/docker.sock \ - -w /app \ - sh -c "" + -e DOCKER_HOST=unix:///var/run/docker.sock \ + -e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal \ + -w /app \ + python:3.13-slim \ + sh -c "pip install -r requirements.txt && python -m pytest" ``` -The key is `-v /var/run/docker.sock:/var/run/docker.sock` — this lets Testcontainers inside the container talk to the host's Docker daemon and create sibling containers. +#### .NET guide test command + +```bash +docker run --rm \ + -v "/{REPO_NAME}":/app \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e DOCKER_HOST=unix:///var/run/docker.sock \ + -e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal \ + -w /app \ + mcr.microsoft.com/dotnet/sdk:9.0 \ + dotnet test +``` + +#### Node.js guide test command + +```bash +docker run --rm \ + -v "/{REPO_NAME}":/app \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e DOCKER_HOST=unix:///var/run/docker.sock \ + -e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal \ + -w /app \ + node:22-alpine \ + sh -c "npm install && npm test" +``` + +#### Important: run tests sequentially + +Run guide tests **one at a time**. Running multiple concurrent DinD or sibling-container tests can overwhelm Docker Desktop's containerd store and cause `meta.db: input/output error` corruption, requiring a Docker Desktop restart. ### 6e: Fix until green @@ -274,11 +348,23 @@ If any test fails, debug and fix the code in both the temporary project AND the ## Step 8: Validate +**IMPORTANT**: Run ALL validation locally before committing. Vale checks run on CI and will block the PR if they fail — fixing after push wastes CI cycles and review time. + 1. `npx prettier --write content/guides/testcontainers-{LANG}-{GUIDE_ID}/` 2. `npx prettier --write content/manuals/testcontainers.md` -3. `docker buildx bake lint` — must pass -4. `docker buildx bake vale` — check `tmp/vale.out` for errors in new files - - Spelling errors for tech terms: add to `_vale/config/vocabularies/Docker/accept.txt` +3. `docker buildx bake lint` — must pass with no errors +4. `docker buildx bake vale` — then check for errors in the new files: + ```bash + grep -A2 "testcontainers-{LANG}-{GUIDE_ID}" tmp/vale.out + ``` + Fix ALL errors before proceeding. Common issues: + - **Vale.Spelling**: tech terms (library names, tools) not in the dictionary → add to `_vale/config/vocabularies/Docker/accept.txt` (alphabetical order) + - **Vale.Terms**: wrong casing (e.g. "python" → "Python") → fix in the markdown. Watch for package names like `testcontainers-python` triggering false positives — rephrase to "Testcontainers for Python" in prose. + - **Docker.Avoid**: hedge words like "very", "simply" → reword + - **Docker.We**: first-person plural → rewrite to "you" or imperative + - Info-level suggestions (e.g. "VS Code" → "versus") are not blocking but review them + + Re-run `docker buildx bake vale` after fixes until no errors remain in the new files. 5. Verify in local dev server (`HUGO_PORT=1314 docker compose watch`): - Guide appears when filtering by its language - Guide appears when filtering by `Testing with Docker` tag diff --git a/_vale/config/vocabularies/Docker/accept.txt b/_vale/config/vocabularies/Docker/accept.txt index 44da687bf63..a1e71e343d8 100644 --- a/_vale/config/vocabularies/Docker/accept.txt +++ b/_vale/config/vocabularies/Docker/accept.txt @@ -8,6 +8,7 @@ exfiltration sandboxing Adreno Aleksandrov +Awaitility Amazon Anchore Apple @@ -36,6 +37,7 @@ Chrome DevTools CI CI/CD Citrix +classpath cli CLI CloudFront @@ -48,13 +50,14 @@ CouchDB Crowdstrike [Cc]ybersecurity datacenter +datasource Datadog Ddosify Debootstrap denylist deprovisioning deserialization -deserialize +deserialize[d]? Dev Dev Environments? Dex @@ -96,6 +99,7 @@ Git GitHub GitHub Actions Google +Gradle Grafana Gravatar gRPC @@ -103,6 +107,8 @@ Groq Grype HyperKit inferencing +initializer +Initializr inotify Intel Intune @@ -117,6 +123,7 @@ JetBrains JFrog JUnit Kata +Keycloak Kerberos Kiro Kitematic @@ -129,7 +136,9 @@ Laradock Laravel libseccomp Linux +Liquibase LinuxKit +logback Loggly Logstash lookup @@ -138,8 +147,10 @@ macOS macvlan Mail(chimp|gun) mfsymlinks +Micronaut Microsoft minikube +misconfiguration monorepos? musl MySQL @@ -147,6 +158,7 @@ nameserver namespaced? namespacing Neovim +Npgsql netfilter netlabel netlink @@ -222,14 +234,18 @@ Traefik Trivy Trixie Turtlesim +typesafe Ubuntu ufw umask uncaptured Uncaptured +unconfigured undeterminable Unix +unmarshalling unmanaged +upsert Visual Studio Code VMware vpnkit @@ -244,6 +260,7 @@ WireMock workdir WORKDIR Xdebug +xUnit XQuartz youki Yubikey diff --git a/content/guides/testcontainers-dotnet-aspnet-core/_index.md b/content/guides/testcontainers-dotnet-aspnet-core/_index.md new file mode 100644 index 00000000000..92480b54d16 --- /dev/null +++ b/content/guides/testcontainers-dotnet-aspnet-core/_index.md @@ -0,0 +1,36 @@ +--- +title: Testing an ASP.NET Core web app with Testcontainers +linkTitle: ASP.NET Core testing +description: Learn how to use Testcontainers for .NET to replace SQLite with a real Microsoft SQL Server in ASP.NET Core integration tests. +keywords: testcontainers, dotnet, csharp, testing, mssql, asp.net core, integration testing, entity framework +summary: | + Learn how to test an ASP.NET Core web app using Testcontainers for .NET + with a real Microsoft SQL Server instance instead of SQLite. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [c-sharp] +params: + time: 25 minutes +--- + + + +In this guide, you'll learn how to: + +- Use Testcontainers for .NET to spin up a Microsoft SQL Server container for integration tests +- Replace SQLite with a production-like database provider in ASP.NET Core tests +- Customize `WebApplicationFactory` to configure test dependencies with Testcontainers +- Manage container lifecycle with xUnit's `IAsyncLifetime` + +## Prerequisites + +- .NET 8.0+ SDK +- A code editor or IDE (Visual Studio, VS Code, Rider) +- A Docker environment supported by Testcontainers. For details, see the + [Testcontainers .NET system requirements](https://dotnet.testcontainers.org/supported_docker_environment/). + +> [!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. diff --git a/content/guides/testcontainers-dotnet-aspnet-core/create-project.md b/content/guides/testcontainers-dotnet-aspnet-core/create-project.md new file mode 100644 index 00000000000..faabab19e37 --- /dev/null +++ b/content/guides/testcontainers-dotnet-aspnet-core/create-project.md @@ -0,0 +1,242 @@ +--- +title: Set up the project +linkTitle: Create the project +description: Set up an ASP.NET Core Razor Pages project with integration test dependencies. +weight: 10 +--- + +## Background + +This guide builds on top of Microsoft's +[Integration tests in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests) +documentation. The original sample uses an in-memory SQLite database as the +backing store for integration tests. You'll replace SQLite with a real +Microsoft SQL Server instance running in a Docker container using +Testcontainers. + +You can find the original code sample in the +[dotnet/AspNetCore.Docs.Samples](https://github.com/dotnet/AspNetCore.Docs.Samples/tree/main/test/integration-tests/IntegrationTestsSample) +repository. + +## Clone the repository + +Clone the Testcontainers guide repository and change into the project +directory: + +```console +$ git clone https://github.com/testcontainers/tc-guide-testing-aspnet-core.git +$ cd tc-guide-testing-aspnet-core +``` + +## Project structure + +The solution contains two projects: + +```text +RazorPagesProject.sln +├── src/RazorPagesProject/ # ASP.NET Core Razor Pages app +└── tests/RazorPagesProject.Tests/ # xUnit integration tests +``` + +### Application project + +The application project (`src/RazorPagesProject/RazorPagesProject.csproj`) +is a Razor Pages web app that uses Entity Framework Core with SQLite as its +default database provider: + +```xml + + + + net9.0 + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + +``` + +The `ApplicationDbContext` stores `Message` entities and provides methods to +query and manage them: + +```csharp +public class ApplicationDbContext : IdentityDbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Messages { get; set; } + + public async virtual Task> GetMessagesAsync() + { + return await Messages + .OrderBy(message => message.Text) + .AsNoTracking() + .ToListAsync(); + } + + public async virtual Task AddMessageAsync(Message message) + { + await Messages.AddAsync(message); + await SaveChangesAsync(); + } + + public async virtual Task DeleteAllMessagesAsync() + { + foreach (Message message in Messages) + { + Messages.Remove(message); + } + + await SaveChangesAsync(); + } + + public async virtual Task DeleteMessageAsync(int id) + { + var message = await Messages.FindAsync(id); + + if (message != null) + { + Messages.Remove(message); + await SaveChangesAsync(); + } + } + + public void Initialize() + { + Messages.AddRange(GetSeedingMessages()); + SaveChanges(); + } + + public static List GetSeedingMessages() + { + return new List() + { + new Message(){ Text = "You're standing on my scarf." }, + new Message(){ Text = "Would you like a jelly baby?" }, + new Message(){ Text = "To the rational mind, nothing is inexplicable; only unexplained." } + }; + } +} +``` + +### Test project + +The test project (`tests/RazorPagesProject.Tests/RazorPagesProject.Tests.csproj`) +includes xUnit, the ASP.NET Core testing infrastructure, and the +Testcontainers MSSQL module: + +```xml + + + + net9.0 + enable + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + Always + + + + +``` + +The key dependencies are: + +- `Microsoft.AspNetCore.Mvc.Testing` - provides `WebApplicationFactory` for + bootstrapping the app in tests +- `Microsoft.EntityFrameworkCore.SqlServer` - the SQL Server database provider + for Entity Framework Core +- `Testcontainers.MsSql` - the Testcontainers module for Microsoft SQL Server + +### Existing SQLite-based test factory + +The original project includes a `CustomWebApplicationFactory` that replaces +the application's database with an in-memory SQLite instance: + +```csharp +public class CustomWebApplicationFactory + : WebApplicationFactory where TProgram : class +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == + typeof(DbContextOptions)); + + services.Remove(dbContextDescriptor); + + var dbConnectionDescriptor = services.SingleOrDefault( + d => d.ServiceType == + typeof(DbConnection)); + + services.Remove(dbConnectionDescriptor); + + // Create open SqliteConnection so EF won't automatically close it. + services.AddSingleton(container => + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + return connection; + }); + + services.AddDbContext((container, options) => + { + var connection = container.GetRequiredService(); + options.UseSqlite(connection); + }); + }); + + builder.UseEnvironment("Development"); + } +} +``` + +While this approach works, SQLite has behavioral differences from the database +you'd use in production. In the next section, you'll replace it with a +Testcontainers-managed Microsoft SQL Server instance. diff --git a/content/guides/testcontainers-dotnet-aspnet-core/run-tests.md b/content/guides/testcontainers-dotnet-aspnet-core/run-tests.md new file mode 100644 index 00000000000..6d23c291246 --- /dev/null +++ b/content/guides/testcontainers-dotnet-aspnet-core/run-tests.md @@ -0,0 +1,45 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run the Testcontainers-based integration tests and explore next steps. +weight: 30 +--- + +## Run the tests + +Run the tests from the solution root: + +```console +$ dotnet test ./RazorPagesProject.sln +``` + +The first run may take longer because Docker needs to pull the Microsoft SQL +Server image. On subsequent runs, the image is cached locally. + +You should see xUnit discover and run the tests, including the +`MsSqlTests.IndexPageTests` class. Testcontainers starts a SQL Server +container, the tests execute against it, and the container is stopped and +removed automatically after the tests finish. + +## Summary + +By replacing SQLite with a Testcontainers-managed Microsoft SQL Server +instance, the integration tests run against the same type of database used in +production. This approach catches database-specific issues early, such as +differences in SQL dialect, transaction behavior, or data type handling between +SQLite and SQL Server. + +The `MsSqlTests` class uses `IAsyncLifetime` to manage the container lifecycle, +and a nested `CustomWebApplicationFactory` wires the container's connection +string into the application's service configuration. You can apply this same +pattern to any database or service that Testcontainers supports. + +To learn more about Testcontainers, visit the +[Testcontainers overview](https://testcontainers.com/getting-started/). + +## Further reading + +- [Testcontainers for .NET documentation](https://dotnet.testcontainers.org/) +- [Testcontainers for .NET modules](https://dotnet.testcontainers.org/modules/) +- [Microsoft SQL Server module](https://www.nuget.org/packages/Testcontainers.MsSql) +- [Integration tests in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests) diff --git a/content/guides/testcontainers-dotnet-aspnet-core/write-tests.md b/content/guides/testcontainers-dotnet-aspnet-core/write-tests.md new file mode 100644 index 00000000000..4ab78313009 --- /dev/null +++ b/content/guides/testcontainers-dotnet-aspnet-core/write-tests.md @@ -0,0 +1,188 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Replace SQLite with a real Microsoft SQL Server using Testcontainers for .NET. +weight: 20 +--- + +The existing tests use an in-memory SQLite database. While convenient, this +doesn't match production behavior. You can replace it with a real Microsoft SQL +Server instance managed by Testcontainers. + +## Add dependencies + +Change to the test project directory and add the SQL Server Entity Framework +provider and the Testcontainers MSSQL module: + +```console +$ cd tests/RazorPagesProject.Tests +$ dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 7.0.0 +$ dotnet add package Testcontainers.MsSql --version 3.0.0 +``` + +> [!NOTE] +> Testcontainers for .NET offers a range of +> [modules](https://www.nuget.org/profiles/Testcontainers) that follow best +> practice configurations. + +## Create the test class + +Create a `MsSqlTests.cs` file in the `IntegrationTests` directory. This class +manages the SQL Server container lifecycle and contains a nested test class. + +```csharp +using System.Data.Common; +using System.Net; +using AngleSharp.Html.Dom; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using RazorPagesProject.Data; +using RazorPagesProject.Tests.Helpers; +using Testcontainers.MsSql; +using Xunit; + +namespace RazorPagesProject.Tests.IntegrationTests; + +public sealed class MsSqlTests : IAsyncLifetime +{ + private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build(); + + public Task InitializeAsync() + { + return _msSqlContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _msSqlContainer.DisposeAsync().AsTask(); + } + + public sealed class IndexPageTests : IClassFixture, IDisposable + { + private readonly WebApplicationFactory _webApplicationFactory; + + private readonly HttpClient _httpClient; + + public IndexPageTests(MsSqlTests fixture) + { + var clientOptions = new WebApplicationFactoryClientOptions(); + clientOptions.AllowAutoRedirect = false; + + _webApplicationFactory = new CustomWebApplicationFactory(fixture); + _httpClient = _webApplicationFactory.CreateClient(clientOptions); + } + + public void Dispose() + { + _webApplicationFactory.Dispose(); + } + + [Fact] + public async Task Post_DeleteAllMessagesHandler_ReturnsRedirectToRoot() + { + // Arrange + var defaultPage = await _httpClient.GetAsync("/") + .ConfigureAwait(false); + + var document = await HtmlHelpers.GetDocumentAsync(defaultPage) + .ConfigureAwait(false); + + // Act + var form = (IHtmlFormElement)document.QuerySelector("form[id='messages']"); + var submitButton = (IHtmlButtonElement)document.QuerySelector("button[id='deleteAllBtn']"); + + var response = await _httpClient.SendAsync(form, submitButton) + .ConfigureAwait(false); + + // Assert + Assert.Equal(HttpStatusCode.OK, defaultPage.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/", response.Headers.Location.OriginalString); + } + + private sealed class CustomWebApplicationFactory : WebApplicationFactory + { + private readonly string _connectionString; + + public CustomWebApplicationFactory(MsSqlTests fixture) + { + _connectionString = fixture._msSqlContainer.GetConnectionString(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.Remove(services.SingleOrDefault(service => typeof(DbContextOptions) == service.ServiceType)); + services.Remove(services.SingleOrDefault(service => typeof(DbConnection) == service.ServiceType)); + services.AddDbContext((_, option) => option.UseSqlServer(_connectionString)); + }); + } + } + } +} +``` + +## Understand the test structure + +### Container lifecycle with IAsyncLifetime + +The outer `MsSqlTests` class implements `IAsyncLifetime`. xUnit calls +`InitializeAsync()` right after creating the class instance, which starts the +SQL Server container. After all tests complete, `DisposeAsync()` stops and +removes the container. + +```csharp +private readonly MsSqlContainer _msSqlContainer = new MsSqlBuilder().Build(); +``` + +`MsSqlBuilder().Build()` creates a pre-configured Microsoft SQL Server +container. Testcontainers modules follow best practices, so you don't need +to configure ports, passwords, or startup wait strategies yourself. + +### Nested test class with IClassFixture + +The `IndexPageTests` class is nested inside `MsSqlTests` and implements +`IClassFixture`. This gives the test class access to the +container's private field and creates a clean hierarchy in the test explorer. + +### Custom WebApplicationFactory + +Instead of using the SQLite-based factory, the nested +`CustomWebApplicationFactory` retrieves the connection string from the running +SQL Server container and passes it to `UseSqlServer()`: + +```csharp +private sealed class CustomWebApplicationFactory : WebApplicationFactory +{ + private readonly string _connectionString; + + public CustomWebApplicationFactory(MsSqlTests fixture) + { + _connectionString = fixture._msSqlContainer.GetConnectionString(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.Remove(services.SingleOrDefault(service => typeof(DbContextOptions) == service.ServiceType)); + services.Remove(services.SingleOrDefault(service => typeof(DbConnection) == service.ServiceType)); + services.AddDbContext((_, option) => option.UseSqlServer(_connectionString)); + }); + } +} +``` + +This factory: + +1. Removes the existing `DbContextOptions` registration +2. Removes the existing `DbConnection` registration +3. Adds a new `ApplicationDbContext` configured with the SQL Server connection + string from the Testcontainers-managed container + +> [!NOTE] +> The Microsoft SQL Server Docker image isn't compatible with ARM devices, such +> as Macs with Apple Silicon. You can use the +> [SqlEdge](https://www.nuget.org/packages/Testcontainers.SqlEdge) module or +> [Testcontainers Cloud](https://www.testcontainers.cloud/) as alternatives. diff --git a/content/guides/testcontainers-dotnet-getting-started/_index.md b/content/guides/testcontainers-dotnet-getting-started/_index.md new file mode 100644 index 00000000000..6f784c94100 --- /dev/null +++ b/content/guides/testcontainers-dotnet-getting-started/_index.md @@ -0,0 +1,34 @@ +--- +title: Getting started with Testcontainers for .NET +linkTitle: Testcontainers for .NET +description: Learn how to use Testcontainers for .NET to test database interactions with a real PostgreSQL instance. +keywords: testcontainers, dotnet, csharp, testing, postgresql, integration testing, xunit +summary: | + Learn how to create a .NET application and test database interactions + using Testcontainers for .NET with a real PostgreSQL instance. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [c-sharp] +params: + time: 20 minutes +--- + + + +In this guide, you will learn how to: + +- Create a .NET solution with a source and test project +- Implement a `CustomerService` that manages customer records in PostgreSQL +- Write integration tests using Testcontainers and xUnit +- Manage container lifecycle with `IAsyncLifetime` + +## Prerequisites + +- .NET 8.0+ SDK +- 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. diff --git a/content/guides/testcontainers-dotnet-getting-started/create-project.md b/content/guides/testcontainers-dotnet-getting-started/create-project.md new file mode 100644 index 00000000000..6058195271c --- /dev/null +++ b/content/guides/testcontainers-dotnet-getting-started/create-project.md @@ -0,0 +1,132 @@ +--- +title: Create the .NET project +linkTitle: Create the project +description: Set up a .NET solution with a PostgreSQL-backed customer service. +weight: 10 +--- + +## Set up the solution + +Create a .NET solution with source and test projects: + +```console +$ dotnet new sln -o TestcontainersDemo +$ cd TestcontainersDemo +$ dotnet new classlib -o CustomerService +$ dotnet sln add ./CustomerService/CustomerService.csproj +$ dotnet new xunit -o CustomerService.Tests +$ dotnet sln add ./CustomerService.Tests/CustomerService.Tests.csproj +$ dotnet add ./CustomerService.Tests/CustomerService.Tests.csproj reference ./CustomerService/CustomerService.csproj +``` + +Add the Npgsql dependency to the source project: + +```console +$ dotnet add ./CustomerService/CustomerService.csproj package Npgsql +``` + +## Implement the business logic + +Create a `Customer` record type: + +```csharp +namespace Customers; + +public readonly record struct Customer(long Id, string Name); +``` + +Create a `DbConnectionProvider` class to manage database connections: + +```csharp +using System.Data.Common; +using Npgsql; + +namespace Customers; + +public sealed class DbConnectionProvider +{ + private readonly string _connectionString; + + public DbConnectionProvider(string connectionString) + { + _connectionString = connectionString; + } + + public DbConnection GetConnection() + { + return new NpgsqlConnection(_connectionString); + } +} +``` + +Create the `CustomerService` class: + +```csharp +namespace Customers; + +public sealed class CustomerService +{ + private readonly DbConnectionProvider _dbConnectionProvider; + + public CustomerService(DbConnectionProvider dbConnectionProvider) + { + _dbConnectionProvider = dbConnectionProvider; + CreateCustomersTable(); + } + + public IEnumerable GetCustomers() + { + IList customers = new List(); + + using var connection = _dbConnectionProvider.GetConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, name FROM customers"; + command.Connection?.Open(); + + using var dataReader = command.ExecuteReader(); + while (dataReader.Read()) + { + var id = dataReader.GetInt64(0); + var name = dataReader.GetString(1); + customers.Add(new Customer(id, name)); + } + + return customers; + } + + public void Create(Customer customer) + { + using var connection = _dbConnectionProvider.GetConnection(); + using var command = connection.CreateCommand(); + + var id = command.CreateParameter(); + id.ParameterName = "@id"; + id.Value = customer.Id; + + var name = command.CreateParameter(); + name.ParameterName = "@name"; + name.Value = customer.Name; + + command.CommandText = "INSERT INTO customers (id, name) VALUES(@id, @name)"; + command.Parameters.Add(id); + command.Parameters.Add(name); + command.Connection?.Open(); + command.ExecuteNonQuery(); + } + + private void CreateCustomersTable() + { + using var connection = _dbConnectionProvider.GetConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE IF NOT EXISTS customers (id BIGINT NOT NULL, name VARCHAR NOT NULL, PRIMARY KEY (id))"; + command.Connection?.Open(); + command.ExecuteNonQuery(); + } +} +``` + +Here's what `CustomerService` does: + +- The constructor calls `CreateCustomersTable()` to ensure the table exists. +- `GetCustomers()` fetches all rows from the `customers` table and returns them as `Customer` objects. +- `Create()` inserts a customer record into the database. diff --git a/content/guides/testcontainers-dotnet-getting-started/run-tests.md b/content/guides/testcontainers-dotnet-getting-started/run-tests.md new file mode 100644 index 00000000000..0fcec03b1a8 --- /dev/null +++ b/content/guides/testcontainers-dotnet-getting-started/run-tests.md @@ -0,0 +1,40 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based integration tests and explore next steps. +weight: 30 +--- + +## Run the tests + +Run the tests: + +```console +$ dotnet test +``` + +You can see in the output that Testcontainers pulls the Postgres Docker image +from Docker Hub (if not already available locally), starts the container, and +runs the test. + +Writing an integration test using Testcontainers works like writing a unit test +that you can run from your IDE. Your teammates can clone the project and run +tests without installing Postgres on their machines. + +## Summary + +The Testcontainers for .NET library helps you write integration tests using the +same type of database (Postgres) that you use in production, instead of mocks. +Because you aren't using mocks and instead talk to real services, you're free +to refactor code and still verify that the application works as expected. + +In addition to Postgres, Testcontainers provides dedicated +[modules](https://www.nuget.org/profiles/Testcontainers) for many SQL +databases, NoSQL databases, messaging queues, and more. + +To learn more about Testcontainers, visit the +[Testcontainers overview](https://testcontainers.com/getting-started/). + +## Further reading + +- [Testing an ASP.NET Core web app](https://testcontainers.com/guides/testing-an-aspnet-core-web-app/) diff --git a/content/guides/testcontainers-dotnet-getting-started/write-tests.md b/content/guides/testcontainers-dotnet-getting-started/write-tests.md new file mode 100644 index 00000000000..91da74e7c10 --- /dev/null +++ b/content/guides/testcontainers-dotnet-getting-started/write-tests.md @@ -0,0 +1,67 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Write integration tests using Testcontainers for .NET and xUnit with a real PostgreSQL database. +weight: 20 +--- + +## Add Testcontainers dependencies + +Add the Testcontainers PostgreSQL module to the test project: + +```console +$ dotnet add ./CustomerService.Tests/CustomerService.Tests.csproj package Testcontainers.PostgreSql +``` + +## Write the test + +Create `CustomerServiceTest.cs` in the test project: + +```csharp +using Testcontainers.PostgreSql; + +namespace Customers.Tests; + +public sealed class CustomerServiceTest : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .Build(); + + public Task InitializeAsync() + { + return _postgres.StartAsync(); + } + + public Task DisposeAsync() + { + return _postgres.DisposeAsync().AsTask(); + } + + [Fact] + public void ShouldReturnTwoCustomers() + { + // Given + var customerService = new CustomerService(new DbConnectionProvider(_postgres.GetConnectionString())); + + // When + customerService.Create(new Customer(1, "George")); + customerService.Create(new Customer(2, "John")); + var customers = customerService.GetCustomers(); + + // Then + Assert.Equal(2, customers.Count()); + } +} +``` + +Here's what the test does: + +- Declares a `PostgreSqlContainer` using the `PostgreSqlBuilder` with the + `postgres:16-alpine` Docker image. +- Implements `IAsyncLifetime` for container lifecycle management: + - `InitializeAsync()` starts the container before the test runs. + - `DisposeAsync()` stops and removes the container after the test finishes. +- `ShouldReturnTwoCustomers()` creates a `CustomerService` with connection + details from the container, inserts two customers, fetches all customers, and + asserts the count. diff --git a/content/guides/testcontainers-java-aws-localstack/_index.md b/content/guides/testcontainers-java-aws-localstack/_index.md new file mode 100644 index 00000000000..85978649e34 --- /dev/null +++ b/content/guides/testcontainers-java-aws-localstack/_index.md @@ -0,0 +1,34 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-aws-localstack/create-project.md b/content/guides/testcontainers-java-aws-localstack/create-project.md new file mode 100644 index 00000000000..22be108ddcf --- /dev/null +++ b/content/guides/testcontainers-java-aws-localstack/create-project.md @@ -0,0 +1,241 @@ +--- +title: Create the Spring Boot project +linkTitle: Create the project +description: Set up a Spring Boot project with Spring Cloud AWS, S3, and SQS. +weight: 10 +--- + +## 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. diff --git a/content/guides/testcontainers-java-aws-localstack/run-tests.md b/content/guides/testcontainers-java-aws-localstack/run-tests.md new file mode 100644 index 00000000000..e5b7c3e971e --- /dev/null +++ b/content/guides/testcontainers-java-aws-localstack/run-tests.md @@ -0,0 +1,37 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based Spring Cloud AWS integration tests and explore next steps. +weight: 30 +--- + +## 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-aws-localstack/write-tests.md b/content/guides/testcontainers-java-aws-localstack/write-tests.md new file mode 100644 index 00000000000..85935f8d741 --- /dev/null +++ b/content/guides/testcontainers-java-aws-localstack/write-tests.md @@ -0,0 +1,148 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Test Spring Cloud AWS S3 and SQS integration using Testcontainers and LocalStack. +weight: 20 +--- + +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. diff --git a/content/guides/testcontainers-java-getting-started/_index.md b/content/guides/testcontainers-java-getting-started/_index.md new file mode 100644 index 00000000000..1a4ee3b837f --- /dev/null +++ b/content/guides/testcontainers-java-getting-started/_index.md @@ -0,0 +1,35 @@ +--- +title: Getting started with Testcontainers for Java +linkTitle: Testcontainers for Java +description: Learn how to use Testcontainers for Java to test a customer service with a real PostgreSQL database. +keywords: testcontainers, java, testing, postgresql, integration testing, junit, maven +summary: | + Learn how to create a Java application and test database interactions + using Testcontainers for Java with a real PostgreSQL instance. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + time: 20 minutes +--- + + + +In this guide, you will learn how to: + +- Create a Java project with Maven +- Implement a `CustomerService` that manages customer records in PostgreSQL +- Write integration tests using Testcontainers with a real Postgres database +- Run the tests and verify everything works + +## 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. diff --git a/content/guides/testcontainers-java-getting-started/create-project.md b/content/guides/testcontainers-java-getting-started/create-project.md new file mode 100644 index 00000000000..523eed9c26e --- /dev/null +++ b/content/guides/testcontainers-java-getting-started/create-project.md @@ -0,0 +1,165 @@ +--- +title: Create the Java project +linkTitle: Create the project +description: Set up a Java project with Maven and implement a PostgreSQL-backed customer service. +weight: 10 +--- + +## Set up the Maven project + +Create a Java project with Maven from your preferred IDE. This guide uses +Maven, but you can use Gradle if you prefer. Add the following dependencies +to `pom.xml`: + +```xml + + + org.postgresql + postgresql + 42.7.3 + + + ch.qos.logback + logback-classic + 1.5.6 + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + +``` + +This adds the Postgres JDBC driver, logback for logging, JUnit 5 for testing, +and the latest `maven-surefire-plugin` for JUnit 5 support. + +## Implement the business logic + +Create a `Customer` record: + +```java +package com.testcontainers.demo; + +public record Customer(Long id, String name) {} +``` + +Create a `DBConnectionProvider` class to hold JDBC connection parameters and +provide a database `Connection`: + +```java +package com.testcontainers.demo; + +import java.sql.Connection; +import java.sql.DriverManager; + +class DBConnectionProvider { + + private final String url; + private final String username; + private final String password; + + public DBConnectionProvider(String url, String username, String password) { + this.url = url; + this.username = username; + this.password = password; + } + + Connection getConnection() { + try { + return DriverManager.getConnection(url, username, password); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} +``` + +Create the `CustomerService` class: + +```java +package com.testcontainers.demo; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public class CustomerService { + + private final DBConnectionProvider connectionProvider; + + public CustomerService(DBConnectionProvider connectionProvider) { + this.connectionProvider = connectionProvider; + createCustomersTableIfNotExists(); + } + + public void createCustomer(Customer customer) { + try (Connection conn = this.connectionProvider.getConnection()) { + PreparedStatement pstmt = conn.prepareStatement( + "insert into customers(id,name) values(?,?)" + ); + pstmt.setLong(1, customer.id()); + pstmt.setString(2, customer.name()); + pstmt.execute(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public List getAllCustomers() { + List customers = new ArrayList<>(); + + try (Connection conn = this.connectionProvider.getConnection()) { + PreparedStatement pstmt = conn.prepareStatement( + "select id,name from customers" + ); + ResultSet rs = pstmt.executeQuery(); + while (rs.next()) { + long id = rs.getLong("id"); + String name = rs.getString("name"); + customers.add(new Customer(id, name)); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return customers; + } + + private void createCustomersTableIfNotExists() { + try (Connection conn = this.connectionProvider.getConnection()) { + PreparedStatement pstmt = conn.prepareStatement( + """ + create table if not exists customers ( + id bigint not null, + name varchar not null, + primary key (id) + ) + """ + ); + pstmt.execute(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } +} +``` + +Here's what `CustomerService` does: + +- The constructor calls `createCustomersTableIfNotExists()` to ensure the table exists. +- `createCustomer()` inserts a customer record into the database. +- `getAllCustomers()` fetches all rows from the `customers` table and returns a list of `Customer` objects. diff --git a/content/guides/testcontainers-java-getting-started/run-tests.md b/content/guides/testcontainers-java-getting-started/run-tests.md new file mode 100644 index 00000000000..751e4a84c65 --- /dev/null +++ b/content/guides/testcontainers-java-getting-started/run-tests.md @@ -0,0 +1,42 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based integration tests and explore next steps. +weight: 30 +--- + +## Run the tests + +Run the tests using Maven: + +```console +$ mvn test +``` + +You can see in the logs that Testcontainers pulls the Postgres Docker image +from Docker Hub (if not already available locally), starts the container, and +runs the test. + +Writing an integration test using Testcontainers works like writing a unit test +that you can run from your IDE. Your teammates can clone the project +and run tests without installing Postgres on their machines. + +## Summary + +The Testcontainers for Java library helps you write integration tests using the +same type of database (Postgres) that you use in production, instead of mocks. +Because you aren't using mocks and instead talk to real services, you're free +to refactor code and still verify that the application works as expected. + +In addition to Postgres, Testcontainers provides dedicated modules for many +SQL databases, NoSQL databases, messaging queues, and more. You can use +Testcontainers to run any containerized dependency for your tests. + +To learn more about Testcontainers, visit the +[Testcontainers overview](https://testcontainers.com/getting-started/). + +## Further reading + +- [Testcontainers container lifecycle management using JUnit 5](https://testcontainers.com/guides/testcontainers-container-lifecycle/) +- [Replace H2 with a real database for testing](https://testcontainers.com/guides/replace-h2-with-real-database-for-testing/) +- [Getting started with Testcontainers in a Java Spring Boot project](https://testcontainers.com/guides/testing-spring-boot-rest-api-using-testcontainers/) diff --git a/content/guides/testcontainers-java-getting-started/write-tests.md b/content/guides/testcontainers-java-getting-started/write-tests.md new file mode 100644 index 00000000000..87272505341 --- /dev/null +++ b/content/guides/testcontainers-java-getting-started/write-tests.md @@ -0,0 +1,94 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Write your first integration test using Testcontainers for Java and PostgreSQL. +weight: 20 +--- + +You have the `CustomerService` implementation ready, but for testing you need a +PostgreSQL database. You can use Testcontainers to spin up a Postgres database +in a Docker container and run your tests against it. + +## Add Testcontainers dependencies + +Add the Testcontainers PostgreSQL module as a test dependency in `pom.xml`: + +```xml + + org.testcontainers + testcontainers-postgresql + 2.0.4 + test + +``` + +Since the application uses a Postgres database, the Testcontainers Postgres +module provides a `PostgreSQLContainer` class for managing the container. + +## Write the test + +Create `CustomerServiceTest.java` under `src/test/java`: + +```java +package com.testcontainers.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.postgresql.PostgreSQLContainer; + +class CustomerServiceTest { + + static PostgreSQLContainer postgres = new PostgreSQLContainer( + "postgres:16-alpine" + ); + + CustomerService customerService; + + @BeforeAll + static void beforeAll() { + postgres.start(); + } + + @AfterAll + static void afterAll() { + postgres.stop(); + } + + @BeforeEach + void setUp() { + DBConnectionProvider connectionProvider = new DBConnectionProvider( + postgres.getJdbcUrl(), + postgres.getUsername(), + postgres.getPassword() + ); + customerService = new CustomerService(connectionProvider); + } + + @Test + void shouldGetCustomers() { + customerService.createCustomer(new Customer(1L, "George")); + customerService.createCustomer(new Customer(2L, "John")); + + List customers = customerService.getAllCustomers(); + assertEquals(2, customers.size()); + } +} +``` + +Here's what the test does: + +- Declares a `PostgreSQLContainer` with the `postgres:16-alpine` Docker image. +- The `@BeforeAll` callback starts the Postgres container before any test + methods run. +- The `@BeforeEach` callback creates a `DBConnectionProvider` using the JDBC + connection parameters from the container, then creates a `CustomerService`. + The `CustomerService` constructor creates the `customers` table if it + doesn't exist. +- `shouldGetCustomers()` inserts 2 customer records, fetches all customers, + and asserts the count. +- The `@AfterAll` callback stops the container after all test methods finish. diff --git a/content/guides/testcontainers-java-jooq-flyway/_index.md b/content/guides/testcontainers-java-jooq-flyway/_index.md new file mode 100644 index 00000000000..86e12078d0d --- /dev/null +++ b/content/guides/testcontainers-java-jooq-flyway/_index.md @@ -0,0 +1,36 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-jooq-flyway/create-project.md b/content/guides/testcontainers-java-jooq-flyway/create-project.md new file mode 100644 index 00000000000..7c2a74cfa1a --- /dev/null +++ b/content/guides/testcontainers-java-jooq-flyway/create-project.md @@ -0,0 +1,331 @@ +--- +title: Create the Spring Boot project +linkTitle: Create the project +description: Set up a Spring Boot project with jOOQ, Flyway, PostgreSQL, and Testcontainers code generation. +weight: 10 +--- + +## 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. diff --git a/content/guides/testcontainers-java-jooq-flyway/run-tests.md b/content/guides/testcontainers-java-jooq-flyway/run-tests.md new file mode 100644 index 00000000000..79b1a25a295 --- /dev/null +++ b/content/guides/testcontainers-java-jooq-flyway/run-tests.md @@ -0,0 +1,37 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run the jOOQ and Flyway integration tests and explore next steps. +weight: 30 +--- + +## 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-jooq-flyway/write-tests.md b/content/guides/testcontainers-java-jooq-flyway/write-tests.md new file mode 100644 index 00000000000..9730a111982 --- /dev/null +++ b/content/guides/testcontainers-java-jooq-flyway/write-tests.md @@ -0,0 +1,216 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Test jOOQ repositories using Testcontainers with the @JooqTest slice and @SpringBootTest. +weight: 20 +--- + +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. diff --git a/content/guides/testcontainers-java-keycloak-spring-boot/_index.md b/content/guides/testcontainers-java-keycloak-spring-boot/_index.md new file mode 100644 index 00000000000..619b69146fa --- /dev/null +++ b/content/guides/testcontainers-java-keycloak-spring-boot/_index.md @@ -0,0 +1,35 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-keycloak-spring-boot/create-project.md b/content/guides/testcontainers-java-keycloak-spring-boot/create-project.md new file mode 100644 index 00000000000..6b505e0473e --- /dev/null +++ b/content/guides/testcontainers-java-keycloak-spring-boot/create-project.md @@ -0,0 +1,313 @@ +--- +title: Create the Spring Boot project +linkTitle: Create the project +description: Set up a Spring Boot OAuth 2.0 Resource Server with Keycloak, PostgreSQL, and Testcontainers. +weight: 10 +--- + +## 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`. diff --git a/content/guides/testcontainers-java-keycloak-spring-boot/run-tests.md b/content/guides/testcontainers-java-keycloak-spring-boot/run-tests.md new file mode 100644 index 00000000000..78171d64046 --- /dev/null +++ b/content/guides/testcontainers-java-keycloak-spring-boot/run-tests.md @@ -0,0 +1,39 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based Spring Boot Keycloak integration tests and explore next steps. +weight: 30 +--- + +## 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-keycloak-spring-boot/write-tests.md b/content/guides/testcontainers-java-keycloak-spring-boot/write-tests.md new file mode 100644 index 00000000000..a3298e4ad71 --- /dev/null +++ b/content/guides/testcontainers-java-keycloak-spring-boot/write-tests.md @@ -0,0 +1,228 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Test the secured Spring Boot API endpoints using Testcontainers Keycloak and PostgreSQL modules. +weight: 20 +--- + +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. diff --git a/content/guides/testcontainers-java-lifecycle/_index.md b/content/guides/testcontainers-java-lifecycle/_index.md new file mode 100644 index 00000000000..b24f7928ae9 --- /dev/null +++ b/content/guides/testcontainers-java-lifecycle/_index.md @@ -0,0 +1,36 @@ +--- +title: Testcontainers container lifecycle management using JUnit 5 +linkTitle: Container lifecycle (Java) +description: Learn how to manage Testcontainers container lifecycle using JUnit 5 callbacks, extension annotations, and the singleton containers pattern. +keywords: testcontainers, java, testing, junit, lifecycle, singleton containers, postgresql +summary: | + Learn different approaches to manage container lifecycle with Testcontainers + using JUnit 5 lifecycle callbacks, extension annotations, and the singleton + containers pattern. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + time: 20 minutes +--- + + + +In this guide, you will learn how to: + +- Start and stop containers using JUnit 5 lifecycle callbacks +- Manage containers using JUnit 5 extension annotations (`@Testcontainers` and `@Container`) +- Share containers across multiple test classes using the singleton containers pattern +- Avoid a common misconfiguration when combining extension annotations with singleton containers + +## Prerequisites + +- Java 17+ +- Your preferred IDE +- 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. diff --git a/content/guides/testcontainers-java-lifecycle/create-project.md b/content/guides/testcontainers-java-lifecycle/create-project.md new file mode 100644 index 00000000000..831833fa8c1 --- /dev/null +++ b/content/guides/testcontainers-java-lifecycle/create-project.md @@ -0,0 +1,166 @@ +--- +title: Create the project and business logic +linkTitle: Create the project +description: Set up a Java project with a PostgreSQL-backed customer service for lifecycle testing. +weight: 10 +--- + +## Set up the project + +Create a Java project with Maven and add the required dependencies: + +```xml + + + org.postgresql + postgresql + 42.7.3 + + + ch.qos.logback + logback-classic + 1.5.6 + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.testcontainers + testcontainers-junit-jupiter + 2.0.4 + test + + + org.testcontainers + testcontainers-postgresql + 2.0.4 + test + + +``` + +## Create the business logic + +Create a `Customer` record: + +```java +package com.testcontainers.demo; + +public record Customer(Long id, String name) {} +``` + +Create a `CustomerService` class with methods to create, retrieve, and delete +customers: + +```java +package com.testcontainers.demo; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class CustomerService { + + private final String url; + private final String username; + private final String password; + + public CustomerService(String url, String username, String password) { + this.url = url; + this.username = username; + this.password = password; + createCustomersTableIfNotExists(); + } + + public void createCustomer(Customer customer) { + try (Connection conn = this.getConnection()) { + PreparedStatement pstmt = conn.prepareStatement( + "insert into customers(id,name) values(?,?)" + ); + pstmt.setLong(1, customer.id()); + pstmt.setString(2, customer.name()); + pstmt.execute(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public List getAllCustomers() { + List customers = new ArrayList<>(); + try (Connection conn = this.getConnection()) { + PreparedStatement pstmt = conn.prepareStatement( + "select id,name from customers" + ); + ResultSet rs = pstmt.executeQuery(); + while (rs.next()) { + long id = rs.getLong("id"); + String name = rs.getString("name"); + customers.add(new Customer(id, name)); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return customers; + } + + public Optional getCustomer(Long customerId) { + try (Connection conn = this.getConnection()) { + PreparedStatement pstmt = conn.prepareStatement( + "select id,name from customers where id = ?" + ); + pstmt.setLong(1, customerId); + ResultSet rs = pstmt.executeQuery(); + if (rs.next()) { + long id = rs.getLong("id"); + String name = rs.getString("name"); + return Optional.of(new Customer(id, name)); + } + } catch (SQLException e) { + throw new RuntimeException(e); + } + return Optional.empty(); + } + + public void deleteAllCustomers() { + try (Connection conn = this.getConnection()) { + PreparedStatement pstmt = conn.prepareStatement("delete from customers"); + pstmt.execute(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private void createCustomersTableIfNotExists() { + try (Connection conn = this.getConnection()) { + PreparedStatement pstmt = conn.prepareStatement( + """ + create table if not exists customers ( + id bigint not null, + name varchar not null, + primary key (id) + ) + """ + ); + pstmt.execute(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private Connection getConnection() { + try { + return DriverManager.getConnection(url, username, password); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} +``` diff --git a/content/guides/testcontainers-java-lifecycle/extension-annotations.md b/content/guides/testcontainers-java-lifecycle/extension-annotations.md new file mode 100644 index 00000000000..bd8bbe1f378 --- /dev/null +++ b/content/guides/testcontainers-java-lifecycle/extension-annotations.md @@ -0,0 +1,75 @@ +--- +title: JUnit 5 extension annotations +linkTitle: Extension annotations +description: Manage Testcontainers container lifecycle using @Testcontainers and @Container annotations. +weight: 30 +--- + +The Testcontainers library provides a JUnit 5 extension that simplifies +starting and stopping containers using annotations. To use it, add the +`org.testcontainers:testcontainers-junit-jupiter` test dependency. + +```java +package com.testcontainers.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +class CustomerServiceWithJUnit5ExtensionTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer( + "postgres:16-alpine" + ); + + CustomerService customerService; + + @BeforeEach + void setUp() { + customerService = + new CustomerService( + postgres.getJdbcUrl(), + postgres.getUsername(), + postgres.getPassword() + ); + customerService.deleteAllCustomers(); + } + + @Test + void shouldCreateCustomer() { + customerService.createCustomer(new Customer(1L, "George")); + + Optional customer = customerService.getCustomer(1L); + assertTrue(customer.isPresent()); + assertEquals(1L, customer.get().id()); + assertEquals("George", customer.get().name()); + } + + @Test + void shouldGetCustomers() { + customerService.createCustomer(new Customer(1L, "George")); + customerService.createCustomer(new Customer(2L, "John")); + + List customers = customerService.getAllCustomers(); + assertEquals(2, customers.size()); + } +} +``` + +Instead of manually starting and stopping the container in `@BeforeAll` and +`@AfterAll`, the `@Testcontainers` annotation on the class and the +`@Container` annotation on the field handle it automatically: + +- The extension finds all `@Container`-annotated fields. +- **Static fields** start once before all tests and stop after all tests. +- **Instance fields** start before each test and stop after each test (not + recommended — it's resource-intensive). diff --git a/content/guides/testcontainers-java-lifecycle/lifecycle-callbacks.md b/content/guides/testcontainers-java-lifecycle/lifecycle-callbacks.md new file mode 100644 index 00000000000..a4e62214575 --- /dev/null +++ b/content/guides/testcontainers-java-lifecycle/lifecycle-callbacks.md @@ -0,0 +1,92 @@ +--- +title: JUnit 5 lifecycle callbacks +linkTitle: Lifecycle callbacks +description: Manage Testcontainers container lifecycle using JUnit 5 @BeforeAll and @AfterAll callbacks. +weight: 20 +--- + +When testing with Testcontainers, you want to start the required containers +before executing any tests and remove them afterwards. You can use JUnit 5 +`@BeforeAll` and `@AfterAll` lifecycle callback methods for this: + +```java +package com.testcontainers.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.postgresql.PostgreSQLContainer; + +class CustomerServiceWithLifeCycleCallbacksTest { + + static PostgreSQLContainer postgres = new PostgreSQLContainer( + "postgres:16-alpine" + ); + + CustomerService customerService; + + @BeforeAll + static void startContainers() { + postgres.start(); + } + + @AfterAll + static void stopContainers() { + postgres.stop(); + } + + @BeforeEach + void setUp() { + customerService = + new CustomerService( + postgres.getJdbcUrl(), + postgres.getUsername(), + postgres.getPassword() + ); + customerService.deleteAllCustomers(); + } + + @Test + void shouldCreateCustomer() { + customerService.createCustomer(new Customer(1L, "George")); + + Optional customer = customerService.getCustomer(1L); + assertTrue(customer.isPresent()); + assertEquals(1L, customer.get().id()); + assertEquals("George", customer.get().name()); + } + + @Test + void shouldGetCustomers() { + customerService.createCustomer(new Customer(1L, "George")); + customerService.createCustomer(new Customer(2L, "John")); + + List customers = customerService.getAllCustomers(); + assertEquals(2, customers.size()); + } +} +``` + +Here's what the code does: + +- `PostgreSQLContainer` is declared as a **static field**. The container starts + before all tests and stops after all tests in this class. +- `@BeforeAll` starts the container, `@AfterAll` stops it. +- `@BeforeEach` initializes `CustomerService` with the container's JDBC + parameters and deletes all rows to give each test a clean database. + +Key observations: + +- Because the container is a **static field**, it's shared across all test + methods in the class. You can declare it as a non-static field and use + `@BeforeEach`/`@AfterEach` to start a new container per test, but this + isn't recommended as it's resource-intensive. +- Even without explicitly stopping the container in `@AfterAll`, Testcontainers + uses the [Ryuk container](https://github.com/testcontainers/moby-ryuk) to + clean up containers automatically when the JVM exits. diff --git a/content/guides/testcontainers-java-lifecycle/singleton-containers.md b/content/guides/testcontainers-java-lifecycle/singleton-containers.md new file mode 100644 index 00000000000..45450b7270c --- /dev/null +++ b/content/guides/testcontainers-java-lifecycle/singleton-containers.md @@ -0,0 +1,113 @@ +--- +title: Singleton containers pattern +linkTitle: Singleton containers +description: Share containers across multiple test classes using the singleton containers pattern. +weight: 40 +--- + +As the number of test classes grows, starting containers for each class adds +up. The singleton containers pattern starts all required containers once in a +common base class and reuses them across all integration tests. + +## Define the base class + +Create an abstract base class that starts the containers in a static +initializer: + +```java +package com.testcontainers.demo; + +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.kafka.ConfluentKafkaContainer; + +public abstract class AbstractIntegrationTest { + + static PostgreSQLContainer postgres = new PostgreSQLContainer( + "postgres:16-alpine"); + static ConfluentKafkaContainer kafka = new ConfluentKafkaContainer( + "confluentinc/cp-kafka:7.8.0"); + + static { + postgres.start(); + kafka.start(); + } +} +``` + +The containers start once when the class loads and Testcontainers uses the +[Ryuk container](https://github.com/testcontainers/moby-ryuk) to remove them +after the JVM exits. + +> [!TIP] +> Instead of starting containers sequentially, start them in parallel using +> `Startables.deepStart(postgres, kafka).join();` + +## Extend the base class + +Each test class inherits from the base class and reuses the same containers: + +```java +class ProductControllerTest extends AbstractIntegrationTest { + + ProductRepository productRepository; + + @BeforeEach + void setUp() { + productRepository = new ProductRepository(...); + productRepository.deleteAll(); + } + + @Test + void shouldGetAllProducts() { + // test logic using the shared postgres container + } +} +``` + +## Avoid a common misconfiguration + +A common mistake is combining singleton containers with the `@Testcontainers` +and `@Container` annotations: + +```java +// DON'T DO THIS — containers will stop after each test class +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +public abstract class AbstractIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>( + DockerImageName.parse("postgres:16-alpine")); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } +} +``` + +The `@Testcontainers` extension stops containers at the end of **each test +class**. Subsequent test classes reuse the cached Spring context, but the +containers are already stopped — causing connection failures. + +Instead, use a static initializer or `@BeforeAll` to start the containers, +without the `@Testcontainers` and `@Container` annotations. + +## Summary + +- Use **JUnit 5 lifecycle callbacks** (`@BeforeAll`/`@AfterAll`) for + explicit control over container startup and shutdown. +- Use **extension annotations** (`@Testcontainers`/`@Container`) for less + boilerplate in single test classes. +- Use the **singleton containers pattern** (static initializer in a base class) + to share containers across multiple test classes. +- Don't mix singleton containers with `@Testcontainers`/`@Container` + annotations. + +## Further reading + +- [Testcontainers JUnit 5 quickstart](https://java.testcontainers.org/quickstart/junit_5_quickstart/) +- [Testcontainers singleton containers pattern](https://java.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers) +- [Testing a Spring Boot REST API with Testcontainers](/guides/testcontainers-java-spring-boot-rest-api/) diff --git a/content/guides/testcontainers-java-micronaut-kafka/_index.md b/content/guides/testcontainers-java-micronaut-kafka/_index.md new file mode 100644 index 00000000000..984349bc9c1 --- /dev/null +++ b/content/guides/testcontainers-java-micronaut-kafka/_index.md @@ -0,0 +1,34 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-micronaut-kafka/create-project.md b/content/guides/testcontainers-java-micronaut-kafka/create-project.md new file mode 100644 index 00000000000..853c01ab459 --- /dev/null +++ b/content/guides/testcontainers-java-micronaut-kafka/create-project.md @@ -0,0 +1,282 @@ +--- +title: Create the Micronaut project +linkTitle: Create the project +description: Set up a Micronaut project with Kafka, Micronaut Data JPA, and MySQL. +weight: 10 +--- + +## 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 +``` diff --git a/content/guides/testcontainers-java-micronaut-kafka/run-tests.md b/content/guides/testcontainers-java-micronaut-kafka/run-tests.md new file mode 100644 index 00000000000..c9ec06208d3 --- /dev/null +++ b/content/guides/testcontainers-java-micronaut-kafka/run-tests.md @@ -0,0 +1,40 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based Micronaut Kafka integration tests and explore next steps. +weight: 30 +--- + +## 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-kafka/write-tests.md b/content/guides/testcontainers-java-micronaut-kafka/write-tests.md new file mode 100644 index 00000000000..25e133c3976 --- /dev/null +++ b/content/guides/testcontainers-java-micronaut-kafka/write-tests.md @@ -0,0 +1,126 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Test the Micronaut Kafka listener using Testcontainers Kafka and MySQL modules with Awaitility. +weight: 20 +--- + +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. diff --git a/content/guides/testcontainers-java-micronaut-wiremock/_index.md b/content/guides/testcontainers-java-micronaut-wiremock/_index.md new file mode 100644 index 00000000000..03923c23857 --- /dev/null +++ b/content/guides/testcontainers-java-micronaut-wiremock/_index.md @@ -0,0 +1,35 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-micronaut-wiremock/create-project.md b/content/guides/testcontainers-java-micronaut-wiremock/create-project.md new file mode 100644 index 00000000000..08dbfd8cba6 --- /dev/null +++ b/content/guides/testcontainers-java-micronaut-wiremock/create-project.md @@ -0,0 +1,214 @@ +--- +title: Create the Micronaut project +linkTitle: Create the project +description: Set up a Micronaut project with an external REST API integration using declarative HTTP clients. +weight: 10 +--- + +## 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" + } + ] +} +``` diff --git a/content/guides/testcontainers-java-micronaut-wiremock/run-tests.md b/content/guides/testcontainers-java-micronaut-wiremock/run-tests.md new file mode 100644 index 00000000000..8088d0cc2e5 --- /dev/null +++ b/content/guides/testcontainers-java-micronaut-wiremock/run-tests.md @@ -0,0 +1,43 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers WireMock integration tests and explore next steps. +weight: 30 +--- + +## 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-micronaut-wiremock/write-tests.md b/content/guides/testcontainers-java-micronaut-wiremock/write-tests.md new file mode 100644 index 00000000000..f469086dbd1 --- /dev/null +++ b/content/guides/testcontainers-java-micronaut-wiremock/write-tests.md @@ -0,0 +1,391 @@ +--- +title: Write tests with WireMock and Testcontainers +linkTitle: Write tests +description: Test external REST API integrations using WireMock and the Testcontainers WireMock module. +weight: 20 +--- + +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. diff --git a/content/guides/testcontainers-java-mockserver/_index.md b/content/guides/testcontainers-java-mockserver/_index.md new file mode 100644 index 00000000000..0bb73d241e5 --- /dev/null +++ b/content/guides/testcontainers-java-mockserver/_index.md @@ -0,0 +1,34 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-mockserver/create-project.md b/content/guides/testcontainers-java-mockserver/create-project.md new file mode 100644 index 00000000000..ad75ee86def --- /dev/null +++ b/content/guides/testcontainers-java-mockserver/create-project.md @@ -0,0 +1,216 @@ +--- +title: Create the Spring Boot project +linkTitle: Create the project +description: Set up a Spring Boot project with an external REST API integration using declarative HTTP clients. +weight: 10 +--- + +## 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" + } + ] +} +``` diff --git a/content/guides/testcontainers-java-mockserver/run-tests.md b/content/guides/testcontainers-java-mockserver/run-tests.md new file mode 100644 index 00000000000..900ce68a8c0 --- /dev/null +++ b/content/guides/testcontainers-java-mockserver/run-tests.md @@ -0,0 +1,39 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers MockServer integration tests and explore next steps. +weight: 30 +--- + +## 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-mockserver/write-tests.md b/content/guides/testcontainers-java-mockserver/write-tests.md new file mode 100644 index 00000000000..2924a5c3069 --- /dev/null +++ b/content/guides/testcontainers-java-mockserver/write-tests.md @@ -0,0 +1,166 @@ +--- +title: Write tests with Testcontainers MockServer +linkTitle: Write tests +description: Test external REST API integrations using the Testcontainers MockServer module and REST Assured. +weight: 20 +--- + +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. diff --git a/content/guides/testcontainers-java-quarkus/_index.md b/content/guides/testcontainers-java-quarkus/_index.md new file mode 100644 index 00000000000..2645bb66f6b --- /dev/null +++ b/content/guides/testcontainers-java-quarkus/_index.md @@ -0,0 +1,37 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-quarkus/create-project.md b/content/guides/testcontainers-java-quarkus/create-project.md new file mode 100644 index 00000000000..0dfd907346b --- /dev/null +++ b/content/guides/testcontainers-java-quarkus/create-project.md @@ -0,0 +1,191 @@ +--- +title: Create the Quarkus project +linkTitle: Create the project +description: Set up a Quarkus project with Hibernate ORM with Panache, PostgreSQL, Flyway, and REST API endpoints. +weight: 10 +--- + +## 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(); + } +} +``` diff --git a/content/guides/testcontainers-java-quarkus/run-tests.md b/content/guides/testcontainers-java-quarkus/run-tests.md new file mode 100644 index 00000000000..6434d52fe95 --- /dev/null +++ b/content/guides/testcontainers-java-quarkus/run-tests.md @@ -0,0 +1,71 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based Quarkus integration tests and explore next steps. +weight: 30 +--- + +## 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-quarkus/write-tests.md b/content/guides/testcontainers-java-quarkus/write-tests.md new file mode 100644 index 00000000000..4896ddf4048 --- /dev/null +++ b/content/guides/testcontainers-java-quarkus/write-tests.md @@ -0,0 +1,185 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Test the Quarkus REST API using Dev Services with Testcontainers, and test with services not supported by Dev Services. +weight: 20 +--- + +## 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. diff --git a/content/guides/testcontainers-java-replace-h2/_index.md b/content/guides/testcontainers-java-replace-h2/_index.md new file mode 100644 index 00000000000..c6dd22e7099 --- /dev/null +++ b/content/guides/testcontainers-java-replace-h2/_index.md @@ -0,0 +1,35 @@ +--- +title: Replace H2 with a real database for testing +linkTitle: Replace H2 database +description: Learn how to replace an H2 in-memory database with a real PostgreSQL database for testing using Testcontainers. +keywords: testcontainers, java, testing, h2, postgresql, spring boot, spring data jpa, jdbc +summary: | + Replace your H2 in-memory test database with a real PostgreSQL instance + using the Testcontainers special JDBC URL — a one-line change. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + time: 15 minutes +--- + + + +In this guide, you will learn how to: + +- Understand the drawbacks of using H2 in-memory databases for testing +- Replace H2 with a real PostgreSQL database using the Testcontainers special JDBC URL +- Use the Testcontainers JUnit 5 extension for more control over the container +- Test both Spring Data JPA and JdbcTemplate-based repositories + +## 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. diff --git a/content/guides/testcontainers-java-replace-h2/jdbc-url-approach.md b/content/guides/testcontainers-java-replace-h2/jdbc-url-approach.md new file mode 100644 index 00000000000..6a082730db0 --- /dev/null +++ b/content/guides/testcontainers-java-replace-h2/jdbc-url-approach.md @@ -0,0 +1,100 @@ +--- +title: Replace H2 with the Testcontainers JDBC URL +linkTitle: JDBC URL approach +description: Use the Testcontainers special JDBC URL to swap H2 for a real PostgreSQL database. +weight: 20 +--- + +Replacing H2 with a real PostgreSQL database requires two test properties: + +```java +@DataJpaTest +@TestPropertySource(properties = { + "spring.test.database.replace=none", + "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db" +}) +class ProductRepositoryWithJdbcUrlTest { + + @Autowired + ProductRepository productRepository; + + @Test + @Sql("classpath:/sql/seed-data.sql") + void shouldGetAllProducts() { + List products = productRepository.findAll(); + assertEquals(2, products.size()); + } +} +``` + +That's it — two properties and your tests run against a real PostgreSQL +database. + +## How the special JDBC URL works + +A standard PostgreSQL JDBC URL looks like: + +```text +jdbc:postgresql://localhost:5432/postgres +``` + +The Testcontainers special JDBC URL inserts `tc:` after `jdbc:`: + +```text +jdbc:tc:postgresql:///db +``` + +The hostname, port, and database name are ignored — Testcontainers manages them +automatically. You can specify the Docker image tag after the database name: + +```text +jdbc:tc:postgresql:16-alpine:///db +``` + +This creates a container from the `postgres:16-alpine` image. + +## Initialize the database with a script + +Pass `TC_INITSCRIPT` to run an SQL script when the container starts: + +```text +jdbc:tc:postgresql:16-alpine:///db?TC_INITSCRIPT=sql/init-db.sql +``` + +Testcontainers runs the script automatically. For production applications, +use a database migration tool like Flyway or Liquibase instead. + +The special JDBC URL also works for MySQL, MariaDB, PostGIS, YugabyteDB, +CockroachDB, and other databases with Testcontainers JDBC support. + +## Testing JdbcTemplate-based repositories + +The same approach works for `JdbcTemplate`-based repositories. Use `@JdbcTest` +instead of `@DataJpaTest`: + +```java +@JdbcTest +@TestPropertySource(properties = { + "spring.test.database.replace=none", + "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db?TC_INITSCRIPT=sql/init-db.sql" +}) +class JdbcProductRepositoryTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + private JdbcProductRepository productRepository; + + @BeforeEach + void setUp() { + productRepository = new JdbcProductRepository(jdbcTemplate); + } + + @Test + @Sql("/sql/seed-data.sql") + void shouldGetAllProducts() { + List products = productRepository.getAllProducts(); + assertEquals(2, products.size()); + } +} +``` diff --git a/content/guides/testcontainers-java-replace-h2/junit-extension-approach.md b/content/guides/testcontainers-java-replace-h2/junit-extension-approach.md new file mode 100644 index 00000000000..b8fec0ce580 --- /dev/null +++ b/content/guides/testcontainers-java-replace-h2/junit-extension-approach.md @@ -0,0 +1,80 @@ +--- +title: Use the JUnit 5 extension for more control +linkTitle: JUnit 5 extension +description: Use the Testcontainers JUnit 5 extension for more control over the PostgreSQL container. +weight: 30 +--- + +If the special JDBC URL doesn't meet your needs, or you need more control over +container creation (for example, to copy initialization scripts), use the +Testcontainers JUnit 5 extension: + +```java +@DataJpaTest +@TestPropertySource(properties = { + "spring.test.database.replace=none" +}) +@Testcontainers +class ProductRepositoryTest { + + @Container + static PostgreSQLContainer postgres = + new PostgreSQLContainer("postgres:16-alpine") + .withCopyFileToContainer( + MountableFile.forClasspathResource("sql/init-db.sql"), + "/docker-entrypoint-initdb.d/init-db.sql"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired + ProductRepository productRepository; + + @Test + @Sql("/sql/seed-data.sql") + void shouldGetAllProducts() { + List products = productRepository.findAll(); + assertEquals(2, products.size()); + } + + @Test + @Sql("/sql/seed-data.sql") + void shouldNotCreateAProductWithDuplicateCode() { + Product product = new Product(3L, "p101", "Test Product"); + productRepository.createProductIfNotExists(product); + Optional optionalProduct = productRepository.findById( + product.getId() + ); + assertThat(optionalProduct).isEmpty(); + } +} +``` + +This approach: + +- Uses `@Testcontainers` and `@Container` to manage the container lifecycle. +- Copies `init-db.sql` into the container's init directory so PostgreSQL + runs it at startup. +- Uses `@DynamicPropertySource` to register the container's connection details + with Spring Boot. +- Tests PostgreSQL-specific features like `ON CONFLICT DO NOTHING` that + wouldn't work with H2. + +## Summary + +- Use the **special JDBC URL** (`jdbc:tc:postgresql:...`) for the quickest way + to switch from H2 to a real database — it's a one-property change. +- Use the **JUnit 5 extension** when you need more control over the container + (custom init scripts, environment variables, etc.). +- Both approaches work with Spring Data JPA (`@DataJpaTest`) and JdbcTemplate + (`@JdbcTest`) tests. + +## Further reading + +- [Testcontainers Postgres module](https://java.testcontainers.org/modules/databases/postgres/) +- [Testcontainers JDBC support](https://java.testcontainers.org/modules/databases/jdbc/) +- [Testing a Spring Boot REST API with Testcontainers](/guides/testcontainers-java-spring-boot-rest-api/) diff --git a/content/guides/testcontainers-java-replace-h2/problem-with-h2.md b/content/guides/testcontainers-java-replace-h2/problem-with-h2.md new file mode 100644 index 00000000000..cc5389dbda7 --- /dev/null +++ b/content/guides/testcontainers-java-replace-h2/problem-with-h2.md @@ -0,0 +1,61 @@ +--- +title: The problem with H2 for testing +linkTitle: The H2 problem +description: Understand why using H2 in-memory databases for testing gives false confidence. +weight: 10 +--- + +A common practice is to use lightweight databases like H2 or HSQL as +in-memory databases for testing while using PostgreSQL, MySQL, or Oracle in +production. This approach has significant drawbacks: + +- The test database might not support all features of your production database. +- SQL syntax might not be compatible between H2 and your production database. +- Tests passing with H2 don't guarantee they'll work in production. + +## Example: PostgreSQL-specific syntax + +Consider implementing an "upsert" — insert a product only if it doesn't +already exist. In PostgreSQL, you can use: + +```sql +INSERT INTO products(id, code, name) VALUES(?,?,?) ON CONFLICT DO NOTHING; +``` + +This query doesn't work with H2 by default: + +```text +Caused by: org.h2.jdbc.JdbcSQLException: Syntax error in SQL statement +"INSERT INTO products (id, code, name) VALUES (?, ?, ?) ON[*] CONFLICT DO NOTHING"; +``` + +You can run H2 in PostgreSQL compatibility mode, but not all features are +supported. The inverse is also true — H2 supports `ROWNUM()` which PostgreSQL +doesn't. + +Testing with a different database than production means you can't trust your +test results and must verify after deployment, defeating the purpose of +automated tests. + +## The Spring Boot test using H2 + +A typical H2-based test looks like this: + +```java +@DataJpaTest +class ProductRepositoryTest { + + @Autowired + ProductRepository productRepository; + + @Test + @Sql("classpath:/sql/seed-data.sql") + void shouldGetAllProducts() { + List products = productRepository.findAll(); + assertEquals(2, products.size()); + } +} +``` + +Spring Boot uses H2 automatically when it's on the classpath. The test passes, +but it doesn't catch PostgreSQL-specific issues. diff --git a/content/guides/testcontainers-java-service-configuration/_index.md b/content/guides/testcontainers-java-service-configuration/_index.md new file mode 100644 index 00000000000..2d1ac1d788c --- /dev/null +++ b/content/guides/testcontainers-java-service-configuration/_index.md @@ -0,0 +1,35 @@ +--- +title: Configuration of services running in a container +linkTitle: Service configuration (Java) +description: Learn how to configure services running in Testcontainers by copying files and executing commands inside containers. +keywords: testcontainers, java, testing, postgresql, localstack, container configuration +summary: | + Learn how to initialize and configure Docker containers for testing + by copying files into containers and executing commands inside them. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + time: 15 minutes +--- + + + +In this guide, you will learn how to: + +- Initialize containers by copying files into them +- Run commands inside running containers using `execInContainer()` +- Set up a PostgreSQL database with SQL scripts +- Create AWS S3 buckets in LocalStack containers + +## Prerequisites + +- Java 17+ +- Your preferred IDE +- 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. diff --git a/content/guides/testcontainers-java-service-configuration/copy-files.md b/content/guides/testcontainers-java-service-configuration/copy-files.md new file mode 100644 index 00000000000..cff823827a7 --- /dev/null +++ b/content/guides/testcontainers-java-service-configuration/copy-files.md @@ -0,0 +1,88 @@ +--- +title: Copy files into containers +linkTitle: Copy files +description: Initialize containers by copying files into specific locations. +weight: 10 +--- + +Sometimes you need to initialize a container by placing files in a specific +location. For example, PostgreSQL runs SQL scripts from +`/docker-entrypoint-initdb.d/` when the container starts. + +## Create the initialization script + +Create `src/test/resources/init-db.sql`: + +```sql +create table customers ( + id bigint not null, + name varchar not null, + primary key (id) +); +``` + +## Copy the file into the container + +Use `withCopyFileToContainer()` to copy the SQL script into the container's +init directory: + +```java +package com.testcontainers.demo; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +@Testcontainers +class CustomerServiceTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer( + "postgres:16-alpine" + ) + .withCopyFileToContainer( + MountableFile.forClasspathResource("init-db.sql"), + "/docker-entrypoint-initdb.d/" + ); + + CustomerService customerService; + + @BeforeEach + void setUp() { + customerService = + new CustomerService( + postgres.getJdbcUrl(), + postgres.getUsername(), + postgres.getPassword() + ); + } + + @Test + void shouldGetCustomers() { + customerService.createCustomer(new Customer(1L, "George")); + customerService.createCustomer(new Customer(2L, "John")); + + List customers = customerService.getAllCustomers(); + assertFalse(customers.isEmpty()); + } +} +``` + +The `withCopyFileToContainer(MountableFile, String)` method copies `init-db.sql` +from the classpath into `/docker-entrypoint-initdb.d/` inside the container. +PostgreSQL executes scripts in that directory automatically at startup. + +You can also copy files from any path on the host: + +```java +.withCopyFileToContainer( + MountableFile.forHostPath("/host/path/to/init-db.sql"), + "/docker-entrypoint-initdb.d/" +); +``` diff --git a/content/guides/testcontainers-java-service-configuration/exec-in-container.md b/content/guides/testcontainers-java-service-configuration/exec-in-container.md new file mode 100644 index 00000000000..68e29bd109d --- /dev/null +++ b/content/guides/testcontainers-java-service-configuration/exec-in-container.md @@ -0,0 +1,117 @@ +--- +title: Execute commands inside containers +linkTitle: Execute commands +description: Run commands inside running containers to initialize services for testing. +weight: 20 +--- + +Some Docker containers provide CLI tools for performing actions. You can use +`container.execInContainer(String...)` to run any available command inside a +running container. + +## Example: Create an S3 bucket in LocalStack + +The [LocalStack](https://localstack.cloud/) module emulates AWS services. To +test S3 file uploads, create a bucket before running tests: + +```java +package com.testcontainers.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Bucket; + +@Testcontainers +class LocalStackTest { + + static final String bucketName = "mybucket"; + + @Container + static LocalStackContainer localStack = new LocalStackContainer( + DockerImageName.parse("localstack/localstack:3.4.0") + ); + + @BeforeAll + static void beforeAll() throws IOException, InterruptedException { + localStack.execInContainer("awslocal", "s3", "mb", "s3://" + bucketName); + + org.testcontainers.containers.Container.ExecResult execResult = + localStack.execInContainer("awslocal", "s3", "ls"); + String stdout = execResult.getStdout(); + int exitCode = execResult.getExitCode(); + assertTrue(stdout.contains(bucketName)); + assertEquals(0, exitCode); + } + + @Test + void shouldListBuckets() { + URI s3Endpoint = localStack.getEndpointOverride(S3); + StaticCredentialsProvider credentialsProvider = + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + localStack.getAccessKey(), + localStack.getSecretKey() + ) + ); + S3Client s3 = S3Client + .builder() + .endpointOverride(s3Endpoint) + .credentialsProvider(credentialsProvider) + .region(Region.of(localStack.getRegion())) + .build(); + + List s3Buckets = s3 + .listBuckets() + .buckets() + .stream() + .map(Bucket::name) + .toList(); + + assertTrue(s3Buckets.contains(bucketName)); + } +} +``` + +The `execInContainer("awslocal", "s3", "mb", "s3://mybucket")` call runs the +`awslocal` CLI tool (provided by the LocalStack image) to create an S3 bucket. + +You can capture the output and exit code from any command: + +```java +Container.ExecResult execResult = + localStack.execInContainer("awslocal", "s3", "ls"); +String stdout = execResult.getStdout(); +int exitCode = execResult.getExitCode(); +``` + +> [!NOTE] +> The `withCopyFileToContainer()` and `execInContainer()` methods are inherited +> from `GenericContainer`, so they're available for all Testcontainers modules. + +## Summary + +- Use `withCopyFileToContainer()` to place initialization files inside + containers before they start. +- Use `execInContainer()` to run commands inside running containers for + setup tasks like creating buckets, topics, or queues. + +## Further reading + +- [Getting started with Testcontainers for Java](/guides/testcontainers-java-getting-started/) +- [Testcontainers Postgres module](https://java.testcontainers.org/modules/databases/postgres/) +- [Testcontainers LocalStack module](https://java.testcontainers.org/modules/localstack/) diff --git a/content/guides/testcontainers-java-spring-boot-kafka/_index.md b/content/guides/testcontainers-java-spring-boot-kafka/_index.md new file mode 100644 index 00000000000..03ff02d617e --- /dev/null +++ b/content/guides/testcontainers-java-spring-boot-kafka/_index.md @@ -0,0 +1,34 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-spring-boot-kafka/create-project.md b/content/guides/testcontainers-java-spring-boot-kafka/create-project.md new file mode 100644 index 00000000000..7046f3b08ed --- /dev/null +++ b/content/guides/testcontainers-java-spring-boot-kafka/create-project.md @@ -0,0 +1,295 @@ +--- +title: Create the Spring Boot project +linkTitle: Create the project +description: Set up a Spring Boot project with Kafka, Spring Data JPA, and MySQL. +weight: 10 +--- + +## 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`. diff --git a/content/guides/testcontainers-java-spring-boot-kafka/run-tests.md b/content/guides/testcontainers-java-spring-boot-kafka/run-tests.md new file mode 100644 index 00000000000..3d50a9e32a8 --- /dev/null +++ b/content/guides/testcontainers-java-spring-boot-kafka/run-tests.md @@ -0,0 +1,39 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based Spring Boot Kafka integration tests and explore next steps. +weight: 30 +--- + +## 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-spring-boot-kafka/write-tests.md b/content/guides/testcontainers-java-spring-boot-kafka/write-tests.md new file mode 100644 index 00000000000..d8312dd7090 --- /dev/null +++ b/content/guides/testcontainers-java-spring-boot-kafka/write-tests.md @@ -0,0 +1,113 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Test the Spring Boot Kafka listener using Testcontainers Kafka and MySQL modules with Awaitility. +weight: 20 +--- + +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. diff --git a/content/guides/testcontainers-java-spring-boot-rest-api/_index.md b/content/guides/testcontainers-java-spring-boot-rest-api/_index.md new file mode 100644 index 00000000000..23dfa0de0bb --- /dev/null +++ b/content/guides/testcontainers-java-spring-boot-rest-api/_index.md @@ -0,0 +1,34 @@ +--- +title: Testing a Spring Boot REST API with Testcontainers +linkTitle: Spring Boot REST API +description: Learn how to test a Spring Boot REST API using Testcontainers with PostgreSQL and REST Assured. +keywords: testcontainers, java, spring boot, testing, postgresql, rest api, rest assured, jpa +summary: | + Learn how to create a Spring Boot REST API with Spring Data JPA and PostgreSQL, + then test it using Testcontainers and REST Assured. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + time: 25 minutes +--- + + + +In this guide, you will learn how to: + +- Create a Spring Boot application with a REST API endpoint +- Use Spring Data JPA with PostgreSQL to store and retrieve data +- Test the REST API using Testcontainers and REST Assured + +## 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. diff --git a/content/guides/testcontainers-java-spring-boot-rest-api/create-project.md b/content/guides/testcontainers-java-spring-boot-rest-api/create-project.md new file mode 100644 index 00000000000..6f21dff6f21 --- /dev/null +++ b/content/guides/testcontainers-java-spring-boot-rest-api/create-project.md @@ -0,0 +1,181 @@ +--- +title: Create the Spring Boot project +linkTitle: Create the project +description: Set up a Spring Boot project with Spring Data JPA, PostgreSQL, and a REST API. +weight: 10 +--- + +## Set up the project + +Create a Spring Boot project from [Spring Initializr](https://start.spring.io) +by selecting the **Spring Web**, **Spring Data JPA**, **PostgreSQL Driver**, and +**Testcontainers** starters. + +Alternatively, clone the +[guide repository](https://github.com/testcontainers/tc-guide-testing-spring-boot-rest-api). + +The key dependencies in `pom.xml` are: + +```xml + + 17 + 2.0.4 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers-postgresql + 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. + +## Create the JPA entity + +Create `Customer.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; + +@Entity +@Table(name = "customers") +class Customer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String email; + + public Customer() {} + + public Customer(Long id, String name, String email) { + this.id = id; + this.name = name; + this.email = email; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} +``` + +## Create the Spring Data JPA repository + +```java +package com.testcontainers.demo; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface CustomerRepository extends JpaRepository {} +``` + +## Add the schema creation script + +Create `src/main/resources/schema.sql`: + +```sql +create table if not exists customers ( + id bigserial not null, + name varchar not null, + email varchar not null, + primary key (id), + UNIQUE (email) +); +``` + +Enable schema initialization in `src/main/resources/application.properties`: + +```properties +spring.sql.init.mode=always +``` + +## Create the REST API endpoint + +Create `CustomerController.java`: + +```java +package com.testcontainers.demo; + +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class CustomerController { + + private final CustomerRepository repo; + + CustomerController(CustomerRepository repo) { + this.repo = repo; + } + + @GetMapping("/api/customers") + List getAll() { + return repo.findAll(); + } +} +``` diff --git a/content/guides/testcontainers-java-spring-boot-rest-api/run-tests.md b/content/guides/testcontainers-java-spring-boot-rest-api/run-tests.md new file mode 100644 index 00000000000..94f80c4a0d0 --- /dev/null +++ b/content/guides/testcontainers-java-spring-boot-rest-api/run-tests.md @@ -0,0 +1,37 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based Spring Boot integration tests and explore next steps. +weight: 30 +--- + +## Run the tests + +```console +$ ./mvnw test +``` + +Or with Gradle: + +```console +$ ./gradlew test +``` + +You should see the Postgres Docker container start and all tests pass. After +the tests finish, the container stops and is removed automatically. + +## Summary + +The Testcontainers library helps you write integration tests by using the same +type of database (Postgres) that you use in production, instead of mocks or +in-memory databases. Because you test against real services, you're free to +refactor code 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 + +- [Testcontainers JUnit 5 quickstart](https://java.testcontainers.org/quickstart/junit_5_quickstart/) +- [Testcontainers Postgres module](https://java.testcontainers.org/modules/databases/postgres/) +- [Testcontainers JDBC support](https://java.testcontainers.org/modules/databases/jdbc/) diff --git a/content/guides/testcontainers-java-spring-boot-rest-api/write-tests.md b/content/guides/testcontainers-java-spring-boot-rest-api/write-tests.md new file mode 100644 index 00000000000..255cd7cd799 --- /dev/null +++ b/content/guides/testcontainers-java-spring-boot-rest-api/write-tests.md @@ -0,0 +1,100 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Test the Spring Boot REST API using Testcontainers and REST Assured. +weight: 20 +--- + +To test the REST API, you need a running Postgres database and a started +Spring context. Testcontainers spins up Postgres in a Docker container and +`@DynamicPropertySource` connects it to Spring. + +## Write the test + +Create `CustomerControllerTest.java`: + +```java +package com.testcontainers.demo; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.hasSize; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +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.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CustomerControllerTest { + + @LocalServerPort + private Integer port; + + static PostgreSQLContainer postgres = new PostgreSQLContainer( + "postgres:16-alpine" + ); + + @BeforeAll + static void beforeAll() { + postgres.start(); + } + + @AfterAll + static void afterAll() { + postgres.stop(); + } + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired + CustomerRepository customerRepository; + + @BeforeEach + void setUp() { + RestAssured.baseURI = "http://localhost:" + port; + customerRepository.deleteAll(); + } + + @Test + void shouldGetAllCustomers() { + List customers = List.of( + new Customer(null, "John", "john@mail.com"), + new Customer(null, "Dennis", "dennis@mail.com") + ); + customerRepository.saveAll(customers); + + given() + .contentType(ContentType.JSON) + .when() + .get("/api/customers") + .then() + .statusCode(200) + .body(".", hasSize(2)); + } +} +``` + +Here's what the test does: + +- `@SpringBootTest` starts the full application on a random port. +- A `PostgreSQLContainer` starts in `@BeforeAll` and stops in `@AfterAll`. +- `@DynamicPropertySource` registers the container's JDBC URL, username, and + password with Spring so that the datasource connects to the test container. +- `@BeforeEach` deletes all customer rows before each test to prevent test + pollution. +- `shouldGetAllCustomers()` inserts two customers, calls `GET /api/customers`, + and verifies the response contains 2 records. diff --git a/content/guides/testcontainers-java-wiremock/_index.md b/content/guides/testcontainers-java-wiremock/_index.md new file mode 100644 index 00000000000..5de620502c5 --- /dev/null +++ b/content/guides/testcontainers-java-wiremock/_index.md @@ -0,0 +1,35 @@ +--- +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. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [java] +params: + 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. diff --git a/content/guides/testcontainers-java-wiremock/create-project.md b/content/guides/testcontainers-java-wiremock/create-project.md new file mode 100644 index 00000000000..20e25f5ace3 --- /dev/null +++ b/content/guides/testcontainers-java-wiremock/create-project.md @@ -0,0 +1,207 @@ +--- +title: Create the Spring Boot project +linkTitle: Create the project +description: Set up a Spring Boot project with an external REST API integration using WireMock and Testcontainers. +weight: 10 +--- + +## 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" + } + ] +} +``` diff --git a/content/guides/testcontainers-java-wiremock/run-tests.md b/content/guides/testcontainers-java-wiremock/run-tests.md new file mode 100644 index 00000000000..4b8fbc6d4a5 --- /dev/null +++ b/content/guides/testcontainers-java-wiremock/run-tests.md @@ -0,0 +1,43 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers WireMock integration tests and explore next steps. +weight: 30 +--- + +## 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/guides/testcontainers-java-wiremock/write-tests.md b/content/guides/testcontainers-java-wiremock/write-tests.md new file mode 100644 index 00000000000..44954d2adc1 --- /dev/null +++ b/content/guides/testcontainers-java-wiremock/write-tests.md @@ -0,0 +1,495 @@ +--- +title: Write tests with WireMock and Testcontainers +linkTitle: Write tests +description: Test external REST API integrations using WireMock with both the JUnit 5 extension and the Testcontainers WireMock module. +weight: 20 +--- + +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. diff --git a/content/guides/testcontainers-nodejs-getting-started/_index.md b/content/guides/testcontainers-nodejs-getting-started/_index.md new file mode 100644 index 00000000000..891fa3266e5 --- /dev/null +++ b/content/guides/testcontainers-nodejs-getting-started/_index.md @@ -0,0 +1,34 @@ +--- +title: Getting started with Testcontainers for Node.js +linkTitle: Testcontainers for Node.js +description: Learn how to use Testcontainers for Node.js to test database interactions with a real PostgreSQL instance. +keywords: testcontainers, nodejs, javascript, testing, postgresql, integration testing, jest +summary: | + Learn how to create a Node.js application and test database interactions + using Testcontainers for Node.js with a real PostgreSQL instance. +toc_min: 1 +toc_max: 2 +tags: [testing-with-docker] +languages: [js] +params: + time: 15 minutes +--- + + + +In this guide, you will learn how to: + +- Create a Node.js application that stores and retrieves customers from PostgreSQL +- Write integration tests using Testcontainers and Jest +- Run tests against a real PostgreSQL database in a Docker container + +## Prerequisites + +- Node.js 18+ +- npm +- 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. diff --git a/content/guides/testcontainers-nodejs-getting-started/create-project.md b/content/guides/testcontainers-nodejs-getting-started/create-project.md new file mode 100644 index 00000000000..6ddc9459278 --- /dev/null +++ b/content/guides/testcontainers-nodejs-getting-started/create-project.md @@ -0,0 +1,53 @@ +--- +title: Create the Node.js project +linkTitle: Create the project +description: Set up a Node.js project with a PostgreSQL-backed customer repository. +weight: 10 +--- + +## Initialize the project + +Create a new Node.js project: + +```console +$ npm init -y +``` + +Add `pg`, `jest`, and `@testcontainers/postgresql` as dependencies: + +```console +$ npm install pg --save +$ npm install jest @testcontainers/postgresql --save-dev +``` + +## Implement the customer repository + +Create `src/customer-repository.js` with functions to manage customers in +PostgreSQL: + +```javascript +async function createCustomerTable(client) { + const sql = + "CREATE TABLE IF NOT EXISTS customers (id INT NOT NULL, name VARCHAR NOT NULL, PRIMARY KEY (id))"; + await client.query(sql); +} + +async function createCustomer(client, customer) { + const sql = "INSERT INTO customers (id, name) VALUES($1, $2)"; + await client.query(sql, [customer.id, customer.name]); +} + +async function getCustomers(client) { + const sql = "SELECT * FROM customers"; + const result = await client.query(sql); + return result.rows; +} + +module.exports = { createCustomerTable, createCustomer, getCustomers }; +``` + +The module provides three functions: + +- `createCustomerTable()` creates the `customers` table if it doesn't exist. +- `createCustomer()` inserts a customer record. +- `getCustomers()` fetches all customer records. diff --git a/content/guides/testcontainers-nodejs-getting-started/run-tests.md b/content/guides/testcontainers-nodejs-getting-started/run-tests.md new file mode 100644 index 00000000000..b4ebe3f43bb --- /dev/null +++ b/content/guides/testcontainers-nodejs-getting-started/run-tests.md @@ -0,0 +1,60 @@ +--- +title: Run tests and next steps +linkTitle: Run tests +description: Run your Testcontainers-based integration tests and explore next steps. +weight: 30 +--- + +## Run the tests + +Add the test script to `package.json` if it isn't there already: + +```json +{ + "scripts": { + "test": "jest" + } +} +``` + +Then run the tests: + +```console +$ npm test +``` + +You should see output like: + +```text + PASS src/customer-repository.test.js + Customer Repository + ✓ should create and return multiple customers (5 ms) + +Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +``` + +To see what Testcontainers is doing under the hood — which containers it +starts, what versions it uses — set the `DEBUG` environment variable: + +```console +$ DEBUG=testcontainers* npm test +``` + +## Summary + +The Testcontainers for Node.js library helps you write integration tests using +the same type of database (Postgres) that you use in production, instead of +mocks. Because you aren't using mocks and instead talk to real services, you're +free to refactor code and still verify that the application works as expected. + +In addition to PostgreSQL, Testcontainers provides dedicated +[modules](https://github.com/testcontainers/testcontainers-node/tree/main/packages/modules) +for many SQL databases, NoSQL databases, messaging queues, and more. + +To learn more about Testcontainers, visit the +[Testcontainers overview](https://testcontainers.com/getting-started/). + +## Further reading + +- [Testcontainers for Node.js documentation](https://node.testcontainers.org) diff --git a/content/guides/testcontainers-nodejs-getting-started/write-tests.md b/content/guides/testcontainers-nodejs-getting-started/write-tests.md new file mode 100644 index 00000000000..a4b530df7ce --- /dev/null +++ b/content/guides/testcontainers-nodejs-getting-started/write-tests.md @@ -0,0 +1,62 @@ +--- +title: Write tests with Testcontainers +linkTitle: Write tests +description: Write integration tests using Testcontainers for Node.js and Jest with a real PostgreSQL database. +weight: 20 +--- + +Create `src/customer-repository.test.js` with the test: + +```javascript +const { Client } = require("pg"); +const { PostgreSqlContainer } = require("@testcontainers/postgresql"); +const { + createCustomerTable, + createCustomer, + getCustomers, +} = require("./customer-repository"); + +describe("Customer Repository", () => { + jest.setTimeout(60000); + + let postgresContainer; + let postgresClient; + + beforeAll(async () => { + postgresContainer = await new PostgreSqlContainer().start(); + postgresClient = new Client({ + connectionString: postgresContainer.getConnectionUri(), + }); + await postgresClient.connect(); + await createCustomerTable(postgresClient); + }); + + afterAll(async () => { + await postgresClient.end(); + await postgresContainer.stop(); + }); + + it("should create and return multiple customers", async () => { + const customer1 = { id: 1, name: "John Doe" }; + const customer2 = { id: 2, name: "Jane Doe" }; + + await createCustomer(postgresClient, customer1); + await createCustomer(postgresClient, customer2); + + const customers = await getCustomers(postgresClient); + expect(customers).toEqual([customer1, customer2]); + }); +}); +``` + +Here's what the test does: + +- The `beforeAll` block starts a real PostgreSQL container using + `PostgreSqlContainer`. It then creates a `pg` client connected to the + container and sets up the `customers` table. +- The `afterAll` block closes the client connection and stops the container. +- The test inserts two customers, fetches all customers, and asserts the + results match. + +The test timeout is set to 60 seconds to allow time for the container to start +on the first run (when the Docker image needs to be pulled). diff --git a/content/guides/testcontainers-python-getting-started/create-project.md b/content/guides/testcontainers-python-getting-started/create-project.md index 88ab26be5f5..1081c0cb09c 100644 --- a/content/guides/testcontainers-python-getting-started/create-project.md +++ b/content/guides/testcontainers-python-getting-started/create-project.md @@ -24,7 +24,7 @@ running a PostgreSQL database in a container. Install the dependencies: ```console -$ pip install psycopg pytest testcontainers[postgres] +$ pip install "psycopg[binary]" pytest testcontainers[postgres] $ pip freeze > requirements.txt ``` diff --git a/content/manuals/testcontainers.md b/content/manuals/testcontainers.md index 9d991119eb8..e3bf13bc758 100644 --- a/content/manuals/testcontainers.md +++ b/content/manuals/testcontainers.md @@ -33,14 +33,6 @@ Using Testcontainers, you can write tests that depend on the same services you u {{< grid items=intro >}} -## Guides - -Explore hands-on Testcontainers guides to learn how to use Testcontainers -with different languages and popular frameworks: - -- [Getting started with Testcontainers for Go](/guides/testcontainers-go-getting-started/) -- [Getting started with Testcontainers for Python](/guides/testcontainers-python-getting-started/) - ## Quickstart ### Supported languages @@ -66,3 +58,28 @@ If you have further questions about configuration details for your setup or whet contact the Testcontainers team and other users from the Testcontainers community on [Slack](https://slack.testcontainers.org/). {{< grid items=quickstart >}} + +## Guides + +Explore hands-on Testcontainers guides to learn how to use Testcontainers +with different languages and popular frameworks: + +- [Getting started with Testcontainers for .NET](/guides/testcontainers-dotnet-getting-started/) +- [Getting started with Testcontainers for Go](/guides/testcontainers-go-getting-started/) +- [Getting started with Testcontainers for Java](/guides/testcontainers-java-getting-started/) +- [Getting started with Testcontainers for Node.js](/guides/testcontainers-nodejs-getting-started/) +- [Getting started with Testcontainers for Python](/guides/testcontainers-python-getting-started/) +- [Testing a Spring Boot REST API with Testcontainers](/guides/testcontainers-java-spring-boot-rest-api/) +- [Testcontainers container lifecycle management](/guides/testcontainers-java-lifecycle/) +- [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/)