diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f11f662..8415b4d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -22,6 +22,19 @@ "keywords": ["testcontainers", "go", "docker", "integration-testing", "testing", "containers", "databases", "postgres", "redis", "kafka", "mysql", "mongodb"], "category": "development", "strict": false + }, + { + "name": "testcontainers-dotnet", + "source": "./testcontainers-dotnet", + "description": "A comprehensive guide for using Testcontainers for .NET to write reliable integration tests with Docker containers in .NET projects. Supports 65+ pre-configured modules for databases, message queues, cloud services, and more.", + "version": "1.0.0", + "author": { + "name": "Testcontainers", + "email": "info@testcontainers.org" + }, + "keywords": ["testcontainers", "dotnet", "csharp", "containers", "docker", "integration-testing", "testing", "mstest", "nunit", "xunit", "databases", "mongodb", "postgres", "sqlserver", "message-queues", "redis", "rabbitmq", "kafka", "entityframework"], + "category": "development", + "strict": false } ] } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..349b6e6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# CODEOWNERS file for testcontainers/claude-skills repository +# +# This file defines code ownership for the repository. +# Reviewers will be automatically requested for pull requests that modify the specified paths. +# +# For more information, see: +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# testcontainers-go skill +/testcontainers-go/ @mdelapenya + +# testcontainers-dotnet skill +/testcontainers-dotnet/ @HofmeisterAn @mdelapenya diff --git a/.github/workflows/test-skills.yml b/.github/workflows/test-skills.yml index 4e9196c..93ebf0d 100644 --- a/.github/workflows/test-skills.yml +++ b/.github/workflows/test-skills.yml @@ -3,19 +3,40 @@ name: Test Skills on: push: branches: [ main, copilot/** ] - paths: - - 'testcontainers-go/**' - - '.github/workflows/test-skills.yml' pull_request: branches: [ main ] - paths: - - 'testcontainers-go/**' - - '.github/workflows/test-skills.yml' jobs: + detect-changes: + name: Detect changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + go: ${{ steps.filter.outputs.go }} + dotnet: ${{ steps.filter.outputs.dotnet }} + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check for file changes + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + go: + - 'testcontainers-go/**' + - '.github/workflows/test-skills.yml' + dotnet: + - 'testcontainers-dotnet/**' + - '.github/workflows/test-skills.yml' + test-testcontainers-go: name: Test testcontainers-go examples runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.go == 'true' permissions: contents: read @@ -75,3 +96,48 @@ jobs: # Tests will automatically pull required Docker images go test -v -timeout 10m ./... echo "✅ All tests passed!" + + test-testcontainers-dotnet: + name: Test testcontainers-dotnet examples + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.dotnet == 'true' + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up .NET + uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + working-directory: testcontainers-dotnet/examples + run: | + echo "Restoring .NET dependencies..." + dotnet restore + echo "✅ Dependencies restored!" + + - name: Build examples + working-directory: testcontainers-dotnet/examples + run: | + echo "Building .NET test project..." + dotnet build --no-restore --configuration Release + echo "✅ Build successful!" + + - name: Set up Docker + run: | + echo "Docker is available in GitHub Actions runners by default" + docker --version + + - name: Run tests + working-directory: testcontainers-dotnet/examples + run: | + echo "Running .NET tests..." + # Run tests with verbose output and a timeout since they involve container operations + # Tests will automatically pull required Docker images + dotnet test --no-build --configuration Release --logger "console;verbosity=detailed" --blame-hang-timeout 10m + echo "✅ All tests passed!" diff --git a/README.md b/README.md index 9ea89ea..7c68ad2 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,24 @@ A comprehensive guide for using Testcontainers for Go to write reliable integrat See the [testcontainers-go skill documentation](./testcontainers-go/SKILL.md) for detailed usage instructions and examples. +### testcontainers-dotnet +A comprehensive guide for using Testcontainers for .NET to write reliable integration tests with Docker containers in .NET projects. This skill provides: + +- Support for 65+ pre-configured modules for databases, message queues, cloud services, and more +- Best practices for setting up and managing Docker containers in .NET tests (xUnit, NUnit, MSTest) +- Configuration guidance for networking, volumes, and environment variables +- Proper cleanup and resource management patterns with IAsyncLifetime +- Debugging and troubleshooting techniques + +**Key capabilities:** +- Use pre-configured modules (PostgreSQL, SQL Server, Redis, MongoDB, Kafka, and more) +- Write integration tests with real services instead of mocks +- Test against multiple versions or configurations of dependencies +- Create reproducible test environments with Entity Framework Core +- Set up ephemeral test infrastructure + +See the [testcontainers-dotnet skill documentation](./testcontainers-dotnet/SKILL.md) for detailed usage instructions and examples. + ## Try in Claude Code, Claude.ai, and the API ### Claude Code @@ -137,6 +155,7 @@ This repository is licensed under the MIT License. See the [LICENSE](./LICENSE) ## Related Projects - [Testcontainers for Go](https://github.com/testcontainers/testcontainers-go) - The main Testcontainers for Go library +- [Testcontainers for .NET](https://github.com/testcontainers/testcontainers-dotnet) - The main Testcontainers for .NET library - [Testcontainers](https://testcontainers.com/) - Official Testcontainers website - [Anthropic Skills](https://github.com/anthropics/skills) - Main skills repository with additional examples diff --git a/testcontainers-dotnet/LICENSE b/testcontainers-dotnet/LICENSE new file mode 100644 index 0000000..1d230e9 --- /dev/null +++ b/testcontainers-dotnet/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Testcontainers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/testcontainers-dotnet/SKILL.md b/testcontainers-dotnet/SKILL.md new file mode 100644 index 0000000..76e6a76 --- /dev/null +++ b/testcontainers-dotnet/SKILL.md @@ -0,0 +1,1620 @@ +--- +name: testcontainers-dotnet +description: A comprehensive guide for using Testcontainers for .NET (4.10.0+) to write reliable integration tests with Docker containers in .NET projects. Supports 65+ pre-configured modules for databases, message queues, cloud services, and more. +applies_to: Testcontainers for .NET 4.10.0+ +license: MIT +--- + +# Testcontainers for .NET Integration Testing + +A comprehensive guide for using Testcontainers for .NET (4.10.0+) to write reliable integration tests with Docker containers in .NET projects. + +## Description + +This skill helps you write integration tests using Testcontainers for .NET, a .NET library that provides lightweight, throwaway instances of common databases, message queues, web browsers, or anything that can run in a Docker container. + +**Key capabilities**: +- Use 65+ pre-configured modules for common services (databases, message queues, cloud services, etc.) +- Set up and manage Docker containers in .NET tests (xUnit, NUnit, MSTest) +- Configure networking, volumes, and environment variables +- Implement proper cleanup and resource management +- Debug and troubleshoot container issues + +## When to Use This Skill + +Use this skill when you need to: +- Write integration tests that require real services (databases, message queues, etc.) +- Test against multiple versions or configurations of dependencies +- Create reproducible test environments +- Avoid mocking external dependencies in integration tests +- Set up ephemeral test infrastructure + +## Decision Rule (Recommended) + +1. **If a pre-configured module exists for your service**, use the module. +2. **If no module exists**, use a generic container with `ContainerBuilder` **and always define an explicit wait strategy**. + +## Prerequisites + +- **Docker or Podman** installed and running +- **.NET 8.0+** (check project requirements; library supports .NET and .NET Standard) +- **Docker socket** accessible at standard locations (Docker Desktop on macOS/Windows, `/var/run/docker.sock` on Linux) +- **Test framework**: xUnit, NUnit, or MSTest + +## Instructions + +### Quick Start (Framework-Agnostic) + +Use this when you want a minimal, reliable integration test setup without committing to xUnit/NUnit/MSTest patterns up front. + +1. Create (or choose) a test project using your preferred test framework. +2. Add the Testcontainers module for your service (prefer modules over generic containers). +3. Add the client library for the service you will connect to. +4. Write a single test that: + - starts the container + - connects to the service and performs a small smoke operation + - disposes the container (for example, via `await using`) +5. Run the test project. + +```bash +# Example: PostgreSQL (module + client library) +dotnet add package Testcontainers.PostgreSql +dotnet add package Npgsql + +# Run your tests (command varies by runner, but this works for most) +dotnet test +``` + +```csharp +// NuGet dependencies: +// - dotnet add package Npgsql +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package xunit.v3 + +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using Testcontainers.PostgreSql; + +public sealed class PostgresSmokeTest +{ + // Note: Add your framework's test attribute (e.g., [Fact]/[Test]/[TestMethod]). + public async Task CanQueryPostgres() + { + await using var postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + await postgres.StartAsync(CancellationToken.None); + + await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); + await connection.OpenAsync(CancellationToken.None); + + await using var command = new NpgsqlCommand("SELECT 1", connection); + var result = await command.ExecuteScalarAsync(CancellationToken.None); + + if (!Equals(result, 1)) + { + throw new InvalidOperationException($"Expected SELECT 1 to return 1. Actual: {result}"); + } + } +} +``` + +### What I Need From You (Checklist) + +When you ask for help, include these details so the generated test code matches your environment: + +- **Test framework**: xUnit / NUnit / MSTest (and version if relevant) +- **.NET version**: e.g., net8.0 +- **Container runtime**: Docker Desktop / Docker Engine / Podman (and OS) +- **Service under test**: PostgreSQL / Redis / SQL Server / Kafka / ... +- **Image + tag**: e.g., `postgres:16-alpine` (pin versions for CI stability) +- **How the test should connect**: host port mapping vs container-to-container network +- **Readiness signal**: HTTP endpoint, log line, command (if you know it) +- **Data setup**: schema/init scripts, seed data, migrations (and where they live) +- **Lifecycle**: per-test container vs shared fixture (performance vs isolation) +- **Parallelism/CI**: Will tests run in parallel? Any CI constraints/timeouts? + +If you do not know an item, say so (the default recommendation is: module + explicit wait strategy + random host ports + dispose in teardown). + +### 1. Installation & Setup + +Add Testcontainers for .NET to your test project: + +```bash +# Core library (required) +# For modules, the core library will be resolved through transitive dependencies. +dotnet add package Testcontainers + +# For pre-configured modules (recommended) +# PostgreSQL +dotnet add package Testcontainers.PostgreSql + +# SQL Server +dotnet add package Testcontainers.MsSql + +# MySQL +dotnet add package Testcontainers.MySql + +# MongoDB +dotnet add package Testcontainers.MongoDB + +# Redis +dotnet add package Testcontainers.Redis + +# Kafka +dotnet add package Testcontainers.Kafka + +# RabbitMQ +dotnet add package Testcontainers.RabbitMq + +# Elasticsearch +dotnet add package Testcontainers.Elasticsearch + +# And many more... +``` + +**Verify Docker availability**: + +Note: Testcontainers usually fails (throws) when **creating/starting containers or other resources** if no container runtime is reachable. The snippet below is a lightweight sanity check that helps you see what configuration Testcontainers resolves on the current machine. + +```csharp +using DotNet.Testcontainers.Configurations; +using Xunit; + +[Fact] +public void DockerIsAvailable() +{ + var testcontainersConfiguration = TestcontainersSettings.OS; + Assert.NotNull(testcontainersConfiguration); +} +``` + +--- + +### 2. Using Pre-Configured Modules (Recommended Approach) + +**Testcontainers for .NET provides 65+ pre-configured modules** that offer production-ready configurations, sensible defaults, and helper methods. **Always prefer modules over generic containers** when available. + +#### Why Use Modules? + +- **Sensible defaults**: Pre-configured ports, environment variables, and wait strategies +- **Connection helpers**: Built-in properties like `GetConnectionString()`, `GetBootstrapAddress()` +- **Specialized features**: Module-specific functionality (e.g., running scripts inside the container) +- **Automatic credentials**: Secure credential generation and management +- **Battle-tested**: Used in production by thousands of projects + +#### Available Module Categories + +**Databases (15+ modules)**: +- `Testcontainers.Cassandra` +- `Testcontainers.ClickHouse` +- `Testcontainers.CosmosDb` +- `Testcontainers.CouchDb` +- `Testcontainers.Db2` +- `Testcontainers.DynamoDb` +- `Testcontainers.InfluxDb` +- `Testcontainers.MariaDb` +- `Testcontainers.MongoDB` +- `Testcontainers.MsSql` +- `Testcontainers.MySql` +- `Testcontainers.Oracle` +- `Testcontainers.PostgreSql` +- `Testcontainers.Redis` + +**Message Queues (5+ modules)**: +- `Testcontainers.Kafka` +- `Testcontainers.NATS` +- `Testcontainers.Pulsar` +- `Testcontainers.RabbitMq` +- `Testcontainers.Redpanda` + +**Search & Storage (5+ modules)**: +- `Testcontainers.Azurite` +- `Testcontainers.Elasticsearch` +- `Testcontainers.LocalStack` +- `Testcontainers.Minio` +- `Testcontainers.Qdrant` + +**Cloud & Infrastructure (5+ modules)**: +- `Testcontainers.Azurite` (Azure Storage) +- `Testcontainers.GCloud` (Google Cloud) +- `Testcontainers.LocalStack` (AWS services) +- `Testcontainers.Consul` +- `Testcontainers.Vault` + +**Development Tools (10+ modules)**: +- `Testcontainers.WebDriver` (Selenium) +- `Testcontainers.Grafana` +- `Testcontainers.Keycloak` +- `Testcontainers.MockServer` +- `Testcontainers.Neo4j` + +#### Basic Module Usage Pattern + +```csharp +// NuGet dependencies: +// - dotnet add package Npgsql +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package xunit.v3 + +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class DatabaseTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + public async ValueTask InitializeAsync() + { + await _postgres.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _postgres.DisposeAsync(); + } + + [Fact] + public async Task ConnectionTest() + { + // Includes mapped port and generated credentials. + var connectionString = _postgres.GetConnectionString(); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + await using var command = new NpgsqlCommand("SELECT 1", connection); + var result = await command.ExecuteScalarAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, result); + } +} +``` + +#### Module Configuration with Builder Pattern + +Modules use a fluent builder API for configuration: + +**Level 1: Basic Configuration** + +```csharp +var postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("myapp_test") + .WithUsername("custom_user") + .WithPassword("custom_pass") + .Build(); +``` + +**Level 2: Advanced Configuration** + +```csharp +// PostgreSQL with init scripts +var postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("myapp_test") + .WithResourceMapping("./init.sql", "/docker-entrypoint-initdb.d/init.sql") + .Build(); + +// Redis with custom configuration +var redis = new RedisBuilder("redis:7-alpine") + .WithCommand("redis-server", "--maxmemory", "256mb") + .Build(); + +// Kafka with custom configuration +var kafka = new KafkaBuilder("confluentinc/cp-kafka:7.5.12") + .WithKRaft() + .Build(); +``` + +**Level 3: Network and Environment Configuration** + +```csharp +var postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithEnvironment("POSTGRES_INITDB_ARGS", "-E UTF8") + .WithLabel("environment", "test") + .WithTmpfsMount("/tmp") + .WithBindMount("/host/path", "/container/path") // Optional: mount directory or file (not recommended) + .WithPortBinding(5432, 5432) // Optional: fixed port (not recommended) + .Build(); +``` + +#### Module-Specific Helper Methods + +Most modules provide convenience methods: + +```csharp +// PostgreSQL +await using var postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); +await postgres.StartAsync(); +var postgresConnectionString = postgres.GetConnectionString(); + +// SQL Server +await using var mssql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04").Build(); +await mssql.StartAsync(); +var mssqlConnectionString = mssql.GetConnectionString(); + +// MongoDB +await using var mongo = new MongoDbBuilder("mongo:6.0").Build(); +await mongo.StartAsync(); +var mongoConnectionString = mongo.GetConnectionString(); + +// Redis +await using var redis = new RedisBuilder("redis:7-alpine").Build(); +await redis.StartAsync(); +var redisConnectionString = redis.GetConnectionString(); + +// Kafka +await using var kafka = new KafkaBuilder("confluentinc/cp-kafka:7.5.12").Build(); +await kafka.StartAsync(); +var kafkaBootstrapAddress = kafka.GetBootstrapAddress(); + +// Elasticsearch +await using var elasticsearch = new ElasticsearchBuilder("elasticsearch:8.7.0").Build(); +await elasticsearch.StartAsync(); +var elasticsearchConnectionString = elasticsearch.GetConnectionString(); +``` + +#### Finding the Right Module + +1. **Browse available modules**: https://testcontainers.com/modules/?language=dotnet (complete, up-to-date list) +2. **Browse NuGet packages**: Search for "Testcontainers" on [NuGet.org](https://www.nuget.org/packages?q=testcontainers) +3. **Official documentation**: https://dotnet.testcontainers.org/ +4. **GitHub repository**: https://github.com/testcontainers/testcontainers-dotnet +5. **Module examples**: Each module has examples/tests in the repository + +**Module naming pattern**: + +``` +Testcontainers. +``` + +--- + +### 3. Using Generic Containers (Fallback) + +When no pre-configured module exists, use generic containers with `ContainerBuilder`. + +**Important: Always add a wait strategy** to ensure the container is ready before tests run. This is critical for reliability, especially in CI environments. + +```csharp +// NuGet dependencies: +// - dotnet add package Testcontainers +// - dotnet add package xunit.v3 + +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Xunit; + +public sealed class CustomContainerTests : IAsyncLifetime +{ + private readonly IContainer _container = new ContainerBuilder("custom-image:latest") + .WithPortBinding(8080, true) // Random host port (recommended) + .WithEnvironment("APP_ENV", "test") + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/"))) + .Build(); + + public async ValueTask InitializeAsync() + { + await _container.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _container.DisposeAsync(); + } + + [Fact] + public void GetEndpoint() + { + // Use mapped host port + resolved hostname. + var port = _container.GetMappedPublicPort(8080); + var hostname = _container.Hostname; + + // Prefer Hostname over hard-coding localhost (works across runtimes/CI). + var endpoint = $"http://{hostname}:{port}"; + + Assert.True(port > 0, $"Port value must be greater than 0. Actual value: '{port}'."); + } +} +``` + +**Common generic container options**: + +```csharp +var container = new ContainerBuilder("image:tag") + + // Ports + .WithPortBinding(80, true) // Random host port + .WithPortBinding(443, 8443) // Fixed port (not recommended) + .WithExposedPort(80) // Expose without binding + + // Environment + .WithEnvironment("KEY", "value") + .WithEnvironment(new Dictionary + { + ["DATABASE_URL"] = "postgres://localhost/db", + ["LOG_LEVEL"] = "debug" + }) + + // Files and Mounts + .WithResourceMapping("./config.yml", "/app/config.yml") + // Bind mounts are not recommended; prefer WithResourceMapping. + .WithBindMount("/host/path", "/container/path") + .WithBindMount("/host/path", "/container/path", AccessMode.ReadOnly) + .WithTmpfsMount("/tmp") + + // Wait strategies (REQUIRED for reliability) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(80).ForPath("/"))) + // Or: .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(80))) + // Or: .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("ready")) + + // Commands + .WithCommand("arg1", "arg2") + .WithEntrypoint("/bin/sh", "-c") + + // Labels + .WithLabel("app", "myapp") + .WithLabel("environment", "test") + + // Cleanup + .WithCleanUp(true) // Auto-cleanup (default: true) + + .Build(); +``` + +--- + +### 4. Writing Integration Tests + +#### Test Framework Integration + +Note: The **xUnit.net examples in this document use xUnit.net v3** (for example `TestContext.Current.CancellationToken`). The overall patterns are **framework-agnostic**: the same container setup/teardown concepts apply to NUnit and MSTest, and you can adapt cancellation-token usage to your test framework/version. + +**xUnit (Recommended Pattern with IAsyncLifetime)** + +```csharp +// NuGet dependencies: +// - dotnet add package Npgsql +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package xunit.v3 + +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class DatabaseTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + public async ValueTask InitializeAsync() + { + await _postgres.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _postgres.DisposeAsync(); + } + + [Fact] + public async Task CanConnectToDatabase() + { + var connectionString = _postgres.GetConnectionString(); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(connection); + } +} +``` + +**xUnit with Class Fixture (Shared Container)** + +```csharp +// NuGet dependencies: +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package xunit.v3 + +using Testcontainers.PostgreSql; +using Xunit; + +// Fixture: Container shared across multiple tests in the class +public sealed class DatabaseFixture : IAsyncLifetime +{ + public PostgreSqlContainer Postgres { get; } = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + public async ValueTask InitializeAsync() + { + await Postgres.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await Postgres.DisposeAsync(); + } +} + +// Test class using the fixture +public sealed class DatabaseTests : IClassFixture +{ + private readonly DatabaseFixture _fixture; + + public DatabaseTests(DatabaseFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void CanGetConnectionString() + { + var connectionString = _fixture.Postgres.GetConnectionString(); + Assert.NotEmpty(connectionString); + } +} +``` + +**NUnit** + +```csharp +// NuGet dependencies: +// - dotnet add package Npgsql +// - dotnet add package NUnit +// - dotnet add package Testcontainers.PostgreSql + +using Npgsql; +using Testcontainers.PostgreSql; +using NUnit.Framework; + +[TestFixture] +public sealed class DatabaseTests +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + await _postgres.StartAsync(); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + await _postgres.DisposeAsync(); + } + + [Test] + public async Task CanConnectToDatabase() + { + var connectionString = _postgres.GetConnectionString(); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + + Assert.That(connection, Is.Not.Null); + } +} +``` + +**MSTest** + +```csharp +// NuGet dependencies: +// - dotnet add package MSTest.TestFramework +// - dotnet add package Npgsql +// - dotnet add package Testcontainers.PostgreSql + +using Npgsql; +using Testcontainers.PostgreSql; + +[TestClass] +public sealed class DatabaseTests +{ + private static readonly PostgreSqlContainer Postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + [ClassInitialize] + public static async Task ClassInitialize(TestContext context) + { + await Postgres.StartAsync(); + } + + [ClassCleanup] + public static async Task ClassCleanup() + { + await Postgres.DisposeAsync(); + } + + [TestMethod] + public async Task CanConnectToDatabase() + { + var connectionString = Postgres.GetConnectionString(); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + + Assert.IsNotNull(connection); + } +} +``` + +#### Theory/Parameterized Tests + +**xUnit Theory**: + +```csharp +// NuGet dependencies: +// - dotnet add package Npgsql +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package xunit.v3 + +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class VersionTests +{ + [Theory] + [InlineData("postgres:14-alpine")] + [InlineData("postgres:15-alpine")] + [InlineData("postgres:16-alpine")] + public async Task TestMultipleVersions(string image) + { + await using var postgres = new PostgreSqlBuilder(image).Build(); + + await postgres.StartAsync(TestContext.Current.CancellationToken); + + var connectionString = postgres.GetConnectionString(); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(connection); + } +} +``` + +--- + +### 5. Container Networking + +#### Connecting Multiple Containers + +```csharp +// NuGet dependencies: +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package xunit.v3 + +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class MultiContainerTests : IAsyncLifetime +{ + private INetwork _network; + private PostgreSqlContainer _postgres; + private IContainer _app; + + public async ValueTask InitializeAsync() + { + // Create custom network + _network = new NetworkBuilder() + .Build(); + + // Start database on network + _postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithNetwork(_network) + .WithNetworkAliases("database") + .Build(); + + // Start app on network + _app = new ContainerBuilder("custom-image:latest") + .WithNetwork(_network) + .WithNetworkAliases("app") + .WithEnvironment("DB_HOST", "database") // Use network alias to connect to the DB + .WithEnvironment("DB_PORT", "5432") // Use internal DB port + .WithPortBinding(8080, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/"))) + .Build(); + + await _network.CreateAsync(); + await _postgres.StartAsync(); + await _app.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _app.DisposeAsync(); + await _postgres.DisposeAsync(); + await _network.DeleteAsync(); + } + + [Fact] + public void AppCanCommunicateWithDatabase() + { + var endpoint = $"http://{_app.Hostname}:{_app.GetMappedPublicPort(8080)}"; + Assert.NotEmpty(endpoint); + } +} +``` + +#### Accessing Container Services + +```csharp +[Fact] +public void GetServiceInformation() +{ + // Method 1: Get mapped public port + var publicPort = _container.GetMappedPublicPort(80); + // publicPort = 49153 (random port assigned by Docker) + + // Method 2: Get hostname + var hostname = _container.Hostname; + // hostname = "localhost" (or Docker host) + + // Method 3: Build full endpoint + var endpoint = $"http://{_container.Hostname}:{_container.GetMappedPublicPort(80)}"; + // endpoint = "http://localhost:49153" +} +``` + +--- + +### 6. Resource Management & Cleanup + +#### Cleanup Patterns + +Goal: Start containers only for the time you need them, and ensure cleanup runs reliably even when tests fail. + +**Pattern 1: IAsyncLifetime (xUnit - Recommended)** + +```csharp +public sealed class DatabaseTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + public async ValueTask InitializeAsync() + { + await _postgres.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + // Ryuk cleans up automatically, but disposing early is still best practice. + await _postgres.DisposeAsync(); + } +} +``` + +**Pattern 2: IAsyncDisposable** + +```csharp +[Fact] +public async Task TestWithDisposable() +{ + await using var postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + await postgres.StartAsync(); + + // Use container... + + // Automatically disposed at end of scope +} +``` + +**Pattern 3: Explicit Cleanup** + +```csharp +[Fact] +public async Task TestWithExplicitCleanup() +{ + var postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + try + { + await postgres.StartAsync(); + + // Use container... + } + finally + { + await postgres.DisposeAsync(); + } +} +``` + +#### Automatic Cleanup with Ryuk + +Testcontainers for .NET uses **[Ryuk](https://github.com/testcontainers/moby-ryuk)**, a garbage collector that automatically cleans up containers even if tests crash or timeout: + +- Runs as a sidecar container (e.g., `testcontainers/ryuk:0.14.0`) +- Monitors test session lifecycle +- Cleans up containers when session ends +- Handles parallel test execution + +**Control Ryuk behavior**: + +```csharp +// Disable Ryuk (not recommended) +Environment.SetEnvironmentVariable("TESTCONTAINERS_RYUK_DISABLED", "true"); + +// Custom Ryuk image +Environment.SetEnvironmentVariable("TESTCONTAINERS_RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.14.0"); +``` + +**Cleanup options**: + +```csharp +var container = new ContainerBuilder("nginx:alpine") + .WithCleanUp(true) // Enable auto-cleanup (default: true) + .Build(); +``` + +--- + +### 7. Configuration Patterns + +#### Environment Variables + +```csharp +var container = new ContainerBuilder("custom-image:latest") + .WithEnvironment("DATABASE_URL", "postgres://localhost/db") + .WithEnvironment("LOG_LEVEL", "debug") + .Build(); + +// Same idea, using a dictionary +var containerWithDictionary = new ContainerBuilder("custom-image:latest") + .WithEnvironment(new Dictionary + { + ["DATABASE_URL"] = "postgres://localhost/db", + ["LOG_LEVEL"] = "debug" + }) + .Build(); +``` + +#### Executing Commands in Containers + +```csharp +[Fact] +public async Task ExecuteCommandInContainer() +{ + await using var container = new ContainerBuilder("alpine:3.23") + .WithCommand("tail", "-f", "/dev/null") // Keep container running + .Build(); + + await container.StartAsync(); + + var execResult = await container.ExecAsync(new[] { "echo", "Hello, World!" }); + + Assert.Equal(0, execResult.ExitCode); + Assert.Contains("Hello, World!", execResult.Stdout); +} +``` + +#### Reading Logs + +```csharp +[Fact] +public async Task ReadContainerLogs() +{ + await using var container = new ContainerBuilder("nginx:alpine") + .WithPortBinding(80, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(80).ForPath("/"))) + .Build(); + + await container.StartAsync(); + + var (stdout, stderr) = await container.GetLogsAsync(); + + Assert.NotEmpty(stdout); +} +``` + +#### Files and Directories + +```csharp +// Copy a local file into the container +var nginx = new ContainerBuilder("nginx:alpine") + .WithResourceMapping("./nginx.conf", "/etc/nginx/nginx.conf") + .Build(); + +// Copy multiple files +var appWithFiles = new ContainerBuilder("custom-image:latest") + .WithResourceMapping("./config.yml", "/app/config.yml") + .WithResourceMapping("./secrets.json", "/app/secrets.json") + .Build(); + +// Bind mount (not recommended for hermetic tests) +var postgresWithBindMount = new ContainerBuilder("postgres:16") + .WithBindMount("/host/data", "/var/lib/postgresql/data") + .Build(); + +// Read-only bind mount +var appWithReadOnlyMount = new ContainerBuilder("custom-image:latest") + .WithBindMount("/host/config", "/app/config", AccessMode.ReadOnly) + .Build(); + +// Read a file from a running container +await nginx.StartAsync(); +var nginxConf = await nginx.ReadFileAsync("/etc/nginx/nginx.conf"); +``` + +#### Volume Mounts + +```csharp +public sealed class VolumeTests : IAsyncLifetime +{ + private IVolume _volume; + private IContainer _container; + + public async ValueTask InitializeAsync() + { + // Create volume + _volume = new VolumeBuilder() + .Build(); + + // Use volume in container + _container = new ContainerBuilder("postgres:16-alpine") + .WithVolumeMount(_volume, "/var/lib/postgresql/data") + .Build(); + + await _volume.CreateAsync(); + await _container.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _container.DisposeAsync(); + await _volume.DeleteAsync(); + } +} +``` + +#### Temporary Filesystems + +```csharp +var container = new ContainerBuilder("custom-image:latest") + .WithTmpfsMount("/tmp") + .WithTmpfsMount("/app/temp") + .Build(); +``` + +--- + +### 8. Wait Strategies + +**Wait strategies are critical for reliable tests.** They ensure containers are fully ready before tests run, which is especially important in CI environments where timing can vary. + +**Best Practices**: +- ✅ **Always use wait strategies for services** - Ensures reliability +- ✅ **Choose appropriate wait strategies** based on your service +- ❌ **Never use `Task.Delay()` or `Thread.Sleep()` as a readiness mechanism** - This is an anti-pattern that leads to flaky tests +- ✅ **Set reasonable timeouts** to handle slow CI environments + +**Common pitfall**: A `Task.Delay(...)` can be fine *inside a test* (for example, waiting for an expiration to happen). The anti-pattern is using fixed sleeps/delays to decide **when a containerized service is ready**. For readiness, always prefer explicit wait strategies. + +#### HTTP-Based Waiting (Recommended for Web Services) + +```csharp +using System.Net; + +var container = new ContainerBuilder("nginx:alpine") + .WithPortBinding(80, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r.ForPort(80).ForPath("/"))) + .Build(); + +// Wait for a specific path and expected status code +var healthCheckContainer = new ContainerBuilder("custom-image:latest") + .WithPortBinding(8080, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request + .ForPort(8080) + .ForPath("/health") + .ForStatusCode(HttpStatusCode.OK))) + .Build(); +``` + +#### Log-Based Waiting + +```csharp +var container = new ContainerBuilder("elasticsearch:8.7.0") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged("started")) + .Build(); + +// Wait for specific log message with timeout +var containerWithTimeout = new ContainerBuilder("elasticsearch:8.7.0") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged("started", o => o.WithTimeout(TimeSpan.FromMinutes(5)))) + .Build(); +``` + +#### Command-Based Waiting + +```csharp +var container = new ContainerBuilder("postgres:16-alpine") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("pg_isready")) + .Build(); +``` + +#### Multiple Wait Strategies + +```csharp +var container = new ContainerBuilder("custom-image:latest") + .WithPortBinding(8080, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/")) + .UntilMessageIsLogged("Application started") + .UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/health"))) + .Build(); +``` + +#### Custom Wait Strategies + +```csharp +var container = new ContainerBuilder("custom-image:latest") + .WithWaitStrategy(Wait.ForUnixContainer() + .AddCustomWaitStrategy(new MyCustomWaitStrategy())) + .Build(); + +public sealed class MyCustomWaitStrategy : IWaitUntil +{ + public async Task UntilAsync(IContainer container) + { + // Custom wait logic + return true; + } +} +``` + +--- + +### 9. Troubleshooting + +#### Verify Docker Availability + +```csharp +[Fact] +public void CheckDockerConnection() +{ + var dockerEndpoint = TestcontainersSettings.OS.DockerEndpointAuthConfig; + Assert.NotNull(dockerEndpoint); +} +``` + +#### Debug Container Logs + +Note: In xUnit, `_output` typically comes from `ITestOutputHelper` injected into the test class constructor. If you are using NUnit/MSTest (or you prefer a quick local repro), you can replace `_output.WriteLine(...)` with your framework's logging mechanism or `Console.WriteLine(...)`. + +```csharp +[Fact] +public async Task DebugWithLogging() +{ + await using var container = new ContainerBuilder("custom-image:latest") + .WithPortBinding(8080, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/"))) + .Build(); + + await container.StartAsync(); + + var (stdout, stderr) = await container.GetLogsAsync(); + _output.WriteLine($"STDOUT:\n{stdout}"); + _output.WriteLine($"STDERR:\n{stderr}"); + _output.WriteLine($"Container ID: {container.Id}"); +} +``` + +#### Common Issues + +**Issue: Container startup timeout** + +```csharp +var container = new ContainerBuilder("slow-starting-app:latest") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/"), o => o.WithTimeout(TimeSpan.FromMinutes(5)))) + .Build(); +``` + +**Issue: Port already in use** +- Testcontainers auto-assigns random ports when using `.WithPortBinding(port, true)` +- Avoid fixed port bindings unless necessary +- Check for leaked containers: `docker ps -a` + +**Issue: Image pull failures** + +```bash +# Pull manually first to verify +docker pull postgres:16-alpine + +# For private registries, login first +docker login registry.example.com +# Testcontainers will use credentials from Docker config +``` + +**Issue: Container not cleaning up** + +```csharp +// Verify cleanup is enabled +var container = new ContainerBuilder("nginx:alpine3.23") + .WithCleanUp(true) // Ensure auto-cleanup is enabled (default: true) + .Build(); + +// Check Ryuk is running +// docker ps | grep ryuk +// Windows PowerShell: docker ps | Select-String ryuk +// Windows CMD: docker ps | findstr ryuk +``` + +#### Environment Variables for Configuration + +```csharp +// Custom Docker host +Environment.SetEnvironmentVariable("DOCKER_HOST", "tcp://localhost:2375"); + +// Disable Ryuk (not recommended) +Environment.SetEnvironmentVariable("TESTCONTAINERS_RYUK_DISABLED", "true"); + +// Custom Ryuk image +Environment.SetEnvironmentVariable("TESTCONTAINERS_RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.14.0"); + +// Hub image name prefix (for private registries) +Environment.SetEnvironmentVariable("TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX", "my.registry.com/"); +``` + +--- + +## Examples + +### Example 1: PostgreSQL Integration Test + +```csharp +// NuGet dependencies: +// - dotnet add package Npgsql +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package xunit.v3 + +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class UserRepositoryTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("testdb") + .WithUsername("testuser") + .WithPassword("testpass") + .Build(); + + public async ValueTask InitializeAsync() + { + await _postgres.StartAsync(); + + // Initialize schema + await using var connection = new NpgsqlConnection(_postgres.GetConnectionString()); + await connection.OpenAsync(); + + await using var command = new NpgsqlCommand(@" + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", connection); + + await command.ExecuteNonQueryAsync(); + } + + public async ValueTask DisposeAsync() + { + await _postgres.DisposeAsync(); + } + + [Fact] + public async Task CreateUser_ShouldInsertUser() + { + await using var connection = new NpgsqlConnection(_postgres.GetConnectionString()); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + await using var command = new NpgsqlCommand( + "INSERT INTO users (name, email) VALUES (@name, @email) RETURNING id", + connection); + + command.Parameters.AddWithValue("name", "Alice"); + command.Parameters.AddWithValue("email", "alice@example.com"); + + var userId = await command.ExecuteScalarAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(userId); + } + + [Fact] + public async Task GetUser_ShouldReturnUser() + { + await using var connection = new NpgsqlConnection(_postgres.GetConnectionString()); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + await using var insertCmd = new NpgsqlCommand( + "INSERT INTO users (name, email) VALUES (@name, @email)", + connection); + insertCmd.Parameters.AddWithValue("name", "Bob"); + insertCmd.Parameters.AddWithValue("email", "bob@example.com"); + await insertCmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + + await using var selectCmd = new NpgsqlCommand( + "SELECT name, email FROM users WHERE email = @email", + connection); + selectCmd.Parameters.AddWithValue("email", "bob@example.com"); + + await using var reader = await selectCmd.ExecuteReaderAsync(TestContext.Current.CancellationToken); + await reader.ReadAsync(TestContext.Current.CancellationToken); + + var name = reader.GetString(0); + var email = reader.GetString(1); + + Assert.Equal("Bob", name); + Assert.Equal("bob@example.com", email); + } +} +``` + +### Example 2: Redis Cache Test + +```csharp +// NuGet dependencies: +// - dotnet add package StackExchange.Redis +// - dotnet add package Testcontainers.Redis +// - dotnet add package xunit.v3 + +using StackExchange.Redis; +using Testcontainers.Redis; +using Xunit; + +public sealed class RedisCacheTests : IAsyncLifetime +{ + private readonly RedisContainer _redis = new RedisBuilder("redis:7-alpine").Build(); + + private IConnectionMultiplexer _connection; + private IDatabase _db; + + public async ValueTask InitializeAsync() + { + await _redis.StartAsync(); + + _connection = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString()); + _db = _connection.GetDatabase(); + } + + public async ValueTask DisposeAsync() + { + _connection.Dispose(); + await _redis.DisposeAsync(); + } + + [Fact] + public async Task SetAndGet_ShouldStoreAndRetrieveValue() + { + await _db.StringSetAsync("key1", "value1"); + var value = await _db.StringGetAsync("key1"); + + Assert.Equal("value1", value); + } + + [Fact] + public async Task SetWithExpiration_ShouldExpireKey() + { + await _db.StringSetAsync("key2", "value2", TimeSpan.FromSeconds(1)); + var valueBefore = await _db.StringGetAsync("key2"); + + await Task.Delay(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + + var valueAfter = await _db.StringGetAsync("key2"); + + Assert.Equal("value2", valueBefore); + Assert.True(valueAfter.IsNull); + } +} +``` + +### Example 3: SQL Server with Entity Framework Core (SqlServer) + +```csharp +// NuGet dependencies: +// - dotnet add package Microsoft.EntityFrameworkCore +// - dotnet add package Testcontainers.Mssql +// - dotnet add package xunit.v3 + +using Microsoft.EntityFrameworkCore; +using Testcontainers.MsSql; +using Xunit; + +public sealed class ApplicationDbContext : DbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Users { get; set; } +} + +public sealed class User +{ + public int Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } +} + +public sealed class EntityFrameworkTests : IAsyncLifetime +{ + private readonly MsSqlContainer _mssql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04").Build(); + + private ApplicationDbContext _dbContext; + + public async ValueTask InitializeAsync() + { + await _mssql.StartAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(_mssql.GetConnectionString()) + .Options; + + _dbContext = new ApplicationDbContext(options); + await _dbContext.Database.EnsureCreatedAsync(); + } + + public async ValueTask DisposeAsync() + { + await _dbContext.DisposeAsync(); + await _mssql.DisposeAsync(); + } + + [Fact] + public async Task AddUser_ShouldPersistToDatabase() + { + var user = new User + { + Name = "Alice", + Email = "alice@example.com" + }; + + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var savedUser = await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == "alice@example.com", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(savedUser); + Assert.Equal("Alice", savedUser.Name); + } +} +``` + +### Example 4: Kafka Producer/Consumer Test + +```csharp +// NuGet dependencies: +// - dotnet add package Confluent.Kafka +// - dotnet add package Testcontainers.Kafka +// - dotnet add package xunit.v3 + +using Confluent.Kafka; +using Testcontainers.Kafka; +using Xunit; + +public sealed class KafkaTests : IAsyncLifetime +{ + private readonly KafkaContainer _kafka = new KafkaBuilder("confluentinc/confluent-local:7.5.0").Build(); + + public async ValueTask InitializeAsync() + { + await _kafka.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _kafka.DisposeAsync(); + } + + [Fact] + public async Task ProduceAndConsume_ShouldTransferMessage() + { + const string topic = "test-topic"; + var bootstrapServers = _kafka.GetBootstrapAddress(); + + var producerConfig = new ProducerConfig + { + BootstrapServers = bootstrapServers + }; + + using var producer = new ProducerBuilder(producerConfig).Build(); + + var consumerConfig = new ConsumerConfig + { + BootstrapServers = bootstrapServers, + GroupId = "test-group", + AutoOffsetReset = AutoOffsetReset.Earliest + }; + + using var consumer = new ConsumerBuilder(consumerConfig).Build(); + consumer.Subscribe(topic); + + await producer.ProduceAsync(topic, new Message + { + Key = "key1", + Value = "Hello, Kafka!" + }, TestContext.Current.CancellationToken); + + var consumeResult = consumer.Consume(TimeSpan.FromSeconds(10)); + + Assert.NotNull(consumeResult); + Assert.Equal("Hello, Kafka!", consumeResult.Message.Value); + } +} +``` + +### Example 5: ASP.NET Core WebApplicationFactory Integration + +```csharp +// NuGet dependencies: +// - dotnet add package Microsoft.AspNetCore.Mvc.Testing +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package xunit.v3 + +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Testcontainers.PostgreSql; +using Xunit; + +public sealed class ApiTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + public async ValueTask InitializeAsync() + { + await _postgres.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _postgres.DisposeAsync(); + } + + public sealed class WebAppTests : WebApplicationFactory, IClassFixture + { + private readonly string _connectionString; + + public WebAppTests(ApiTests fixture) + { + _connectionString = fixture._postgres.GetConnectionString(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Uses the .NET configuration system's connection-string support (ConnectionStrings:). + builder.UseSetting("ConnectionStrings:Database", _connectionString); + } + + [Fact] + public async Task HealthCheck_ReturnsOk() + { + using var client = CreateClient(); + var response = await client.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + } +} +``` + +--- + +### Example 6: Multi-Container Application Stack + +```csharp +// NuGet dependencies: +// - dotnet add package Testcontainers.PostgreSql +// - dotnet add package Testcontainers.Redis +// - dotnet add package xunit.v3 + +using System.Net; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; +using Testcontainers.PostgreSql; +using Testcontainers.Redis; +using Xunit; + +public sealed class FullStackTests : IAsyncLifetime +{ + private INetwork _network; + private PostgreSqlContainer _postgres; + private RedisContainer _redis; + private IContainer _app; + + public async ValueTask InitializeAsync() + { + // Create network + _network = new NetworkBuilder().Build(); + + // Start PostgreSQL + _postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithNetwork(_network) + .WithNetworkAliases("database") + .Build(); + + // Start Redis + _redis = new RedisBuilder("redis:7-alpine") + .WithNetwork(_network) + .WithNetworkAliases("cache") + .Build(); + + _app = new ContainerBuilder("custom-image:latest") + .WithNetwork(_network) + .WithNetworkAliases("app") + .WithEnvironment("DB_HOST", "database") + .WithEnvironment("DB_PORT", "5432") + .WithEnvironment("REDIS_HOST", "cache") + .WithEnvironment("REDIS_PORT", "6379") + .WithPortBinding(8080, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(8080).ForPath("/"))) + .Build(); + + await _network.CreateAsync(); + await _postgres.StartAsync(); + await _redis.StartAsync(); + await _app.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _app.DisposeAsync(); + await _redis.DisposeAsync(); + await _postgres.DisposeAsync(); + await _network.DeleteAsync(); + } + + [Fact] + public async Task HealthCheck_ShouldReturnOk() + { + var endpoint = $"http://{_app.Hostname}:{_app.GetMappedPublicPort(8080)}"; + + using var httpClient = new HttpClient(); + + var response = await httpClient.GetAsync($"{endpoint}/health", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} +``` + +--- + +## Best Practices + +- **Always use pre-configured modules when available** - They provide sensible defaults and helper methods. +- **Use async lifecycle management** - Proper async initialization and cleanup (`IAsyncLifetime` in xUnit, `[OneTimeSetUp]`/`[OneTimeTearDown]` in NUnit, `[ClassInitialize]`/`[ClassCleanup]` in MSTest). +- **Always add wait strategies** - Ensures containers are ready before tests run; never use `Task.Delay()`/`Thread.Sleep()` as a readiness mechanism. +- **Use randomly assigned host ports** - Do not rely on fixed ports. +- **Copy configuration files into the container** - Do not rely on mounting files or directories. +- **Choose appropriate wait strategies** - Use HTTP for health endpoints, logs for startup messages, or commands for readiness. +- **Test against multiple configurations** - Use parameterized tests to validate versions/configurations (`Theory`/`InlineData` in xUnit, `TestCase` in NUnit, `DataRow` in MSTest). +- **Use custom networks** - For multi-container communication. +- **Keep containers ephemeral** - Do not rely on state between tests. +- **Share containers when appropriate** - Use fixtures or setup methods to share containers across tests for better performance. +- **Use module helper methods** - e.g., `GetConnectionString()`, `GetBootstrapAddress()`. +- **Debug with logs** - Use `GetLogsAsync()` when troubleshooting. +- **Use the builder pattern** - Fluent API for clear, maintainable configuration. + +--- + +## Additional Resources + +- **Official Documentation**: https://dotnet.testcontainers.org/ +- **NuGet Packages**: https://www.nuget.org/packages?q=testcontainers +- **GitHub Repository**: https://github.com/testcontainers/testcontainers-dotnet +- **Examples**: https://github.com/testcontainers/testcontainers-dotnet/tree/develop/examples + - https://github.com/testcontainers/testcontainers-dotnet/tree/develop/examples/Flyway + - https://github.com/testcontainers/testcontainers-dotnet/tree/develop/examples/Respawn +- **Community Slack**: [testcontainers.slack.com](https://testcontainers.slack.com) diff --git a/testcontainers-dotnet/examples/.gitignore b/testcontainers-dotnet/examples/.gitignore new file mode 100644 index 0000000..8a1e13d --- /dev/null +++ b/testcontainers-dotnet/examples/.gitignore @@ -0,0 +1,39 @@ +# .NET build artifacts +bin/ +obj/ + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Test results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NuGet Packages +*.nupkg +*.snupkg +**/packages/* +!**/packages/build/ + +# Visual Studio cache/options directory +.vs/ + +# JetBrains Rider +.idea/ +*.sln.iml diff --git a/testcontainers-dotnet/examples/01_PostgreSqlBasicTests.cs b/testcontainers-dotnet/examples/01_PostgreSqlBasicTests.cs new file mode 100644 index 0000000..d25caef --- /dev/null +++ b/testcontainers-dotnet/examples/01_PostgreSqlBasicTests.cs @@ -0,0 +1,140 @@ +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +namespace TestcontainersExamples; + +/// +/// Demonstrates basic PostgreSQL container usage with Testcontainers for .NET +/// +public class PostgreSqlBasicTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + public async Task InitializeAsync() + { + await _postgres.StartAsync(); + } + + public async Task DisposeAsync() + { + await _postgres.DisposeAsync(); + } + + [Fact] + public async Task ConnectionTest_ShouldConnect() + { + // Arrange + var connectionString = _postgres.GetConnectionString(); + + // Act + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + + // Assert + Assert.NotNull(connection); + Assert.Equal(System.Data.ConnectionState.Open, connection.State); + } + + [Fact] + public async Task SimpleQuery_ShouldReturnResult() + { + // Arrange + var connectionString = _postgres.GetConnectionString(); + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + + // Act + await using var command = new NpgsqlCommand("SELECT 1 + 1", connection); + var result = await command.ExecuteScalarAsync(); + + // Assert + Assert.Equal(2, result); + } +} + +/// +/// Demonstrates PostgreSQL with custom configuration +/// +public class PostgreSqlCustomConfigTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("testdb") + .WithUsername("testuser") + .WithPassword("testpass") + .Build(); + + public async Task InitializeAsync() + { + await _postgres.StartAsync(); + } + + public async Task DisposeAsync() + { + await _postgres.DisposeAsync(); + } + + [Fact] + public void ConnectionString_ShouldContainCustomValues() + { + // Act + var connectionString = _postgres.GetConnectionString(); + + // Assert + Assert.Contains("testuser", connectionString); + Assert.Contains("testpass", connectionString); + Assert.Contains("testdb", connectionString); + } + + [Fact] + public async Task CreateTableAndInsert_ShouldPersist() + { + // Arrange + var connectionString = _postgres.GetConnectionString(); + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + + // Create table + await using (var createCommand = new NpgsqlCommand(@" + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", connection)) + { + await createCommand.ExecuteNonQueryAsync(); + } + + // Insert data + await using (var insertCommand = new NpgsqlCommand( + "INSERT INTO users (name, email) VALUES (@name, @email) RETURNING id", + connection)) + { + insertCommand.Parameters.AddWithValue("name", "Alice"); + insertCommand.Parameters.AddWithValue("email", "alice@example.com"); + + var userId = await insertCommand.ExecuteScalarAsync(); + + // Assert insert succeeded + Assert.NotNull(userId); + } + + // Query data + await using (var selectCommand = new NpgsqlCommand( + "SELECT name, email FROM users WHERE email = @email", + connection)) + { + selectCommand.Parameters.AddWithValue("email", "alice@example.com"); + + await using var reader = await selectCommand.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + + var name = reader.GetString(0); + var email = reader.GetString(1); + + Assert.Equal("Alice", name); + Assert.Equal("alice@example.com", email); + } + } +} diff --git a/testcontainers-dotnet/examples/02_RedisCacheTests.cs b/testcontainers-dotnet/examples/02_RedisCacheTests.cs new file mode 100644 index 0000000..c2f3ef3 --- /dev/null +++ b/testcontainers-dotnet/examples/02_RedisCacheTests.cs @@ -0,0 +1,106 @@ +using StackExchange.Redis; +using Testcontainers.Redis; +using Xunit; + +namespace TestcontainersExamples; + +/// +/// Demonstrates Redis container usage for caching scenarios +/// +public class RedisCacheTests : IAsyncLifetime +{ + private readonly RedisContainer _redis = new RedisBuilder("redis:7-alpine").Build(); + + private IConnectionMultiplexer? _connection; + private IDatabase? _db; + + public async Task InitializeAsync() + { + await _redis.StartAsync(); + _connection = await ConnectionMultiplexer.ConnectAsync(_redis.GetConnectionString()); + _db = _connection.GetDatabase(); + } + + public async Task DisposeAsync() + { + _connection?.Dispose(); + await _redis.DisposeAsync(); + } + + [Fact] + public async Task SetAndGet_ShouldStoreAndRetrieveValue() + { + // Arrange + const string key = "test:key1"; + const string value = "test-value-1"; + + // Act + await _db!.StringSetAsync(key, value); + var retrievedValue = await _db.StringGetAsync(key); + + // Assert + Assert.Equal(value, retrievedValue); + } + + [Fact] + public async Task SetWithExpiration_ShouldExpireKey() + { + // Arrange + const string key = "test:expiring-key"; + const string value = "temporary-value"; + + // Act + await _db!.StringSetAsync(key, value, TimeSpan.FromSeconds(1)); + var valueBefore = await _db.StringGetAsync(key); + + // Wait for expiration + await Task.Delay(TimeSpan.FromSeconds(2)); + + var valueAfter = await _db.StringGetAsync(key); + + // Assert + Assert.Equal(value, valueBefore.ToString()); + Assert.True(valueAfter.IsNull); + } + + [Fact] + public async Task Increment_ShouldIncrementCounter() + { + // Arrange + const string key = "test:counter"; + + // Act + var count1 = await _db!.StringIncrementAsync(key); + var count2 = await _db.StringIncrementAsync(key); + var count3 = await _db.StringIncrementAsync(key); + + // Assert + Assert.Equal(1, count1); + Assert.Equal(2, count2); + Assert.Equal(3, count3); + } + + [Fact] + public async Task HashOperations_ShouldStoreAndRetrieveFields() + { + // Arrange + const string key = "test:user:1"; + + // Act + await _db!.HashSetAsync(key, new HashEntry[] + { + new("name", "Alice"), + new("email", "alice@example.com"), + new("age", "30") + }); + + var name = await _db.HashGetAsync(key, "name"); + var email = await _db.HashGetAsync(key, "email"); + var age = await _db.HashGetAsync(key, "age"); + + // Assert + Assert.Equal("Alice", name.ToString()); + Assert.Equal("alice@example.com", email.ToString()); + Assert.Equal("30", age.ToString()); + } +} diff --git a/testcontainers-dotnet/examples/03_SqlServerEntityFrameworkTests.cs b/testcontainers-dotnet/examples/03_SqlServerEntityFrameworkTests.cs new file mode 100644 index 0000000..9c38a80 --- /dev/null +++ b/testcontainers-dotnet/examples/03_SqlServerEntityFrameworkTests.cs @@ -0,0 +1,158 @@ +using Microsoft.EntityFrameworkCore; +using Testcontainers.MsSql; +using Xunit; + +namespace TestcontainersExamples; + +/// +/// Sample Entity Framework DbContext +/// +public class ApplicationDbContext : DbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Users { get; set; } = null!; +} + +/// +/// Sample entity +/// +public class User +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string Email { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// Demonstrates SQL Server container with Entity Framework Core +/// +public class SqlServerEntityFrameworkTests : IAsyncLifetime +{ + private readonly MsSqlContainer _mssql = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest").Build(); + + private ApplicationDbContext? _dbContext; + + public async Task InitializeAsync() + { + await _mssql.StartAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(_mssql.GetConnectionString()) + .Options; + + _dbContext = new ApplicationDbContext(options); + await _dbContext.Database.EnsureCreatedAsync(); + } + + public async Task DisposeAsync() + { + if (_dbContext != null) + { + await _dbContext.DisposeAsync(); + } + await _mssql.DisposeAsync(); + } + + [Fact] + public async Task AddUser_ShouldPersistToDatabase() + { + // Arrange + var user = new User + { + Name = "Alice", + Email = "alice@example.com" + }; + + // Act + _dbContext!.Users.Add(user); + await _dbContext.SaveChangesAsync(); + + // Assert + var savedUser = await _dbContext.Users + .FirstOrDefaultAsync(u => u.Email == "alice@example.com"); + + Assert.NotNull(savedUser); + Assert.Equal("Alice", savedUser.Name); + Assert.True(savedUser.Id > 0); + } + + [Fact] + public async Task UpdateUser_ShouldModifyExistingUser() + { + // Arrange + var user = new User + { + Name = "Bob", + Email = "bob@example.com" + }; + + _dbContext!.Users.Add(user); + await _dbContext.SaveChangesAsync(); + + // Act + user.Name = "Robert"; + await _dbContext.SaveChangesAsync(); + + // Assert + var updatedUser = await _dbContext.Users + .FirstOrDefaultAsync(u => u.Email == "bob@example.com"); + + Assert.NotNull(updatedUser); + Assert.Equal("Robert", updatedUser.Name); + } + + [Fact] + public async Task DeleteUser_ShouldRemoveFromDatabase() + { + // Arrange + var user = new User + { + Name = "Charlie", + Email = "charlie@example.com" + }; + + _dbContext!.Users.Add(user); + await _dbContext.SaveChangesAsync(); + + // Act + _dbContext.Users.Remove(user); + await _dbContext.SaveChangesAsync(); + + // Assert + var deletedUser = await _dbContext.Users + .FirstOrDefaultAsync(u => u.Email == "charlie@example.com"); + + Assert.Null(deletedUser); + } + + [Fact] + public async Task QueryUsers_ShouldReturnFilteredResults() + { + // Arrange + var users = new[] + { + new User { Name = "Alice", Email = "alice@example.com" }, + new User { Name = "Bob", Email = "bob@example.com" }, + new User { Name = "Charlie", Email = "charlie@example.com" } + }; + + _dbContext!.Users.AddRange(users); + await _dbContext.SaveChangesAsync(); + + // Act + var filteredUsers = await _dbContext.Users + .Where(u => u.Name.StartsWith("A") || u.Name.StartsWith("C")) + .OrderBy(u => u.Name) + .ToListAsync(); + + // Assert + Assert.Equal(2, filteredUsers.Count); + Assert.Equal("Alice", filteredUsers[0].Name); + Assert.Equal("Charlie", filteredUsers[1].Name); + } +} diff --git a/testcontainers-dotnet/examples/04_MultiContainerNetworkTests.cs b/testcontainers-dotnet/examples/04_MultiContainerNetworkTests.cs new file mode 100644 index 0000000..fdcb29c --- /dev/null +++ b/testcontainers-dotnet/examples/04_MultiContainerNetworkTests.cs @@ -0,0 +1,225 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; +using Testcontainers.PostgreSql; +using Testcontainers.Redis; +using Xunit; + +namespace TestcontainersExamples; + +/// +/// Demonstrates multi-container networking with custom Docker networks +/// +public class MultiContainerNetworkTests : IAsyncLifetime +{ + private INetwork? _network; + private PostgreSqlContainer? _postgres; + private RedisContainer? _redis; + + public async Task InitializeAsync() + { + // Create custom network + _network = new NetworkBuilder() + .Build(); + + await _network.CreateAsync(); + + // Start PostgreSQL on the network + _postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithNetwork(_network) + .WithNetworkAliases("database") + .Build(); + + await _postgres.StartAsync(); + + // Start Redis on the same network + _redis = new RedisBuilder("redis:7-alpine") + .WithNetwork(_network) + .WithNetworkAliases("cache") + .Build(); + + await _redis.StartAsync(); + } + + public async Task DisposeAsync() + { + // Important: Dispose containers before network + if (_redis != null) + { + await _redis.DisposeAsync(); + } + + if (_postgres != null) + { + await _postgres.DisposeAsync(); + } + + if (_network != null) + { + await _network.DeleteAsync(); + } + } + + [Fact] + public void PostgresContainer_ShouldBeOnNetwork() + { + // Assert + Assert.NotNull(_postgres); + var connectionString = _postgres!.GetConnectionString(); + Assert.NotEmpty(connectionString); + } + + [Fact] + public void RedisContainer_ShouldBeOnNetwork() + { + // Assert + Assert.NotNull(_redis); + var connectionString = _redis!.GetConnectionString(); + Assert.NotEmpty(connectionString); + } + + [Fact] + public async Task ContainerCommunication_UsingNetworkAliases() + { + // Arrange - Create an app container that connects to both services + var appContainer = new ContainerBuilder("alpine:latest") + .WithNetwork(_network!) + .WithNetworkAliases("app") + .WithCommand("sh", "-c", "sleep 30") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("sh", "-c", "echo ready")) + .Build(); + + await appContainer.StartAsync(); + + try + { + // Act - Verify network connectivity + // Check if database alias is resolvable + var pingDbResult = await appContainer.ExecAsync(new[] { "ping", "-c", "1", "database" }); + + // Check if cache alias is resolvable + var pingCacheResult = await appContainer.ExecAsync(new[] { "ping", "-c", "1", "cache" }); + + // Assert + Assert.Equal(0, pingDbResult.ExitCode); + Assert.Equal(0, pingCacheResult.ExitCode); + } + finally + { + await appContainer.DisposeAsync(); + } + } +} + +/// +/// Demonstrates a simulated microservices architecture with multiple containers +/// +public class MicroservicesArchitectureTests : IAsyncLifetime +{ + private INetwork? _network; + private PostgreSqlContainer? _database; + private RedisContainer? _cache; + private IContainer? _appContainer; + + public async Task InitializeAsync() + { + // Create network for all services + _network = new NetworkBuilder() + .WithName("microservices-net") + .Build(); + + await _network.CreateAsync(); + + // Start database service + _database = new PostgreSqlBuilder("postgres:16-alpine") + .WithNetwork(_network) + .WithNetworkAliases("db", "database") + .Build(); + + // Start cache service + _cache = new RedisBuilder("redis:7-alpine") + .WithNetwork(_network) + .WithNetworkAliases("redis", "cache") + .Build(); + + // Start both in parallel + await Task.WhenAll( + _database.StartAsync(), + _cache.StartAsync() + ); + + // Start application container that uses both services + _appContainer = new ContainerBuilder("alpine:latest") + .WithNetwork(_network) + .WithNetworkAliases("app") + .WithEnvironment("DB_HOST", "database") + .WithEnvironment("DB_PORT", "5432") + .WithEnvironment("REDIS_HOST", "cache") + .WithEnvironment("REDIS_PORT", "6379") + .WithCommand("sh", "-c", "sleep 60") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("sh", "-c", "echo ready")) + .Build(); + + await _appContainer.StartAsync(); + } + + public async Task DisposeAsync() + { + // Cleanup in reverse order + if (_appContainer != null) + { + await _appContainer.DisposeAsync(); + } + + if (_cache != null) + { + await _cache.DisposeAsync(); + } + + if (_database != null) + { + await _database.DisposeAsync(); + } + + if (_network != null) + { + await _network.DeleteAsync(); + } + } + + [Fact] + public void AllServices_ShouldBeRunning() + { + // Assert + Assert.NotNull(_database); + Assert.NotNull(_cache); + Assert.NotNull(_appContainer); + } + + [Fact] + public async Task AppContainer_CanResolveServiceAliases() + { + // Act + var dbHostResult = await _appContainer!.ExecAsync(new[] { "sh", "-c", "getent hosts database" }); + var cacheHostResult = await _appContainer.ExecAsync(new[] { "sh", "-c", "getent hosts cache" }); + + // Assert + Assert.Equal(0, dbHostResult.ExitCode); + Assert.Equal(0, cacheHostResult.ExitCode); + } + + [Fact] + public void EnvironmentVariables_ShouldBeSet() + { + // This test verifies that environment configuration is set correctly + // In a real scenario, the app would use these to connect to services + + // Assert - Just verify containers are configured + Assert.NotNull(_appContainer); + Assert.NotNull(_database); + Assert.NotNull(_cache); + } +} diff --git a/testcontainers-dotnet/examples/05_GenericContainerTests.cs b/testcontainers-dotnet/examples/05_GenericContainerTests.cs new file mode 100644 index 0000000..eeffbad --- /dev/null +++ b/testcontainers-dotnet/examples/05_GenericContainerTests.cs @@ -0,0 +1,285 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Xunit; + +namespace TestcontainersExamples; + +/// +/// Demonstrates generic container usage without pre-configured modules +/// +public class GenericContainerTests : IAsyncLifetime +{ + private IContainer? _container; + + public Task InitializeAsync() + { + // No shared setup + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + [Fact] + public async Task NginxContainer_ShouldServeDefaultPage() + { + // Arrange + _container = new ContainerBuilder("nginx:alpine") + .WithPortBinding(80, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r.ForPort(80).ForPath("/"))) + .Build(); + + // Act + await _container.StartAsync(); + + var port = _container.GetMappedPublicPort(80); + var hostname = _container.Hostname; + + // Assert + Assert.True(port > 0); + Assert.NotEmpty(hostname); + } + + [Fact] + public async Task ContainerWithEnvironment_ShouldSetVariables() + { + // Arrange + _container = new ContainerBuilder("alpine:latest") + .WithEnvironment("TEST_VAR", "test-value") + .WithEnvironment("ANOTHER_VAR", "another-value") + .WithCommand("sh", "-c", "sleep 10") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("sh", "-c", "echo ready")) + .Build(); + + // Act + await _container.StartAsync(); + + var result = await _container.ExecAsync(new[] { "sh", "-c", "echo $TEST_VAR" }); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.Contains("test-value", result.Stdout); + } + + [Fact] + public async Task ExecCommand_ShouldExecuteAndReturnOutput() + { + // Arrange + _container = new ContainerBuilder("alpine:latest") + .WithCommand("sh", "-c", "sleep 20") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("sh", "-c", "echo ready")) + .Build(); + + await _container.StartAsync(); + + // Act + var result = await _container.ExecAsync(new[] { "echo", "Hello, Testcontainers!" }); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.Contains("Hello, Testcontainers!", result.Stdout); + } + + [Fact] + public async Task ReadLogs_ShouldReturnContainerOutput() + { + // Arrange + _container = new ContainerBuilder("alpine:latest") + .WithCommand("sh", "-c", "echo 'Container started successfully' && sleep 5") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged("Container started successfully")) + .Build(); + + // Act + await _container.StartAsync(); + + var (stdout, stderr) = await _container.GetLogsAsync(); + + // Assert + Assert.Contains("Container started successfully", stdout); + } + + [Fact] + public async Task WaitForLog_ShouldWaitUntilMessageAppears() + { + // Arrange + _container = new ContainerBuilder("alpine:latest") + .WithCommand("sh", "-c", "sleep 2 && echo 'Ready to serve' && sleep 10") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged("Ready to serve")) + .Build(); + + // Act + var startTime = DateTime.UtcNow; + await _container.StartAsync(); + var elapsed = DateTime.UtcNow - startTime; + + // Assert + Assert.True(elapsed.TotalSeconds >= 2, "Should wait for log message"); + + var (stdout, _) = await _container.GetLogsAsync(); + Assert.Contains("Ready to serve", stdout); + } + + [Fact] + public async Task PortMapping_ShouldMapToRandomPort() + { + // Arrange + _container = new ContainerBuilder("nginx:alpine") + .WithPortBinding(80, true) // true = assign random port + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r.ForPort(80).ForPath("/"))) + .Build(); + + // Act + await _container.StartAsync(); + + var mappedPort = _container.GetMappedPublicPort(80); + + // Assert + Assert.True(mappedPort > 0); + Assert.NotEqual(80, mappedPort); // Should be randomly assigned + } +} + +/// +/// Demonstrates advanced generic container patterns +/// +public class AdvancedGenericContainerTests +{ + [Fact] + public async Task ContainerWithCustomWaitStrategy_HTTP() + { + // Arrange + await using var container = new ContainerBuilder("nginx:alpine") + .WithPortBinding(80, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request + .ForPort(80) + .ForPath("/") + .ForStatusCode(System.Net.HttpStatusCode.OK))) + .Build(); + + // Act + await container.StartAsync(); + + // Assert + var port = container.GetMappedPublicPort(80); + Assert.True(port > 0); + + // Verify HTTP endpoint is accessible + using var httpClient = new HttpClient(); + var response = await httpClient.GetAsync($"http://{container.Hostname}:{port}"); + + Assert.True(response.IsSuccessStatusCode); + } + + [Fact] + public async Task ContainerWithBindMount_ShouldAccessHostFiles() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var testFile = Path.Combine(tempDir, "test.txt"); + await File.WriteAllTextAsync(testFile, "Hello from host!"); + + await using var container = new ContainerBuilder("alpine:latest") + .WithBindMount(tempDir, "/data") + .WithCommand("sh", "-c", "sleep 10") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("sh", "-c", "echo ready")) + .Build(); + + // Act + await container.StartAsync(); + + var result = await container.ExecAsync(new[] { "cat", "/data/test.txt" }); + + // Assert + Assert.Equal(0, result.ExitCode); + Assert.Contains("Hello from host!", result.Stdout); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task ContainerWithLabels_ShouldSetMetadata() + { + // Arrange + await using var container = new ContainerBuilder("alpine:latest") + .WithLabel("test.project", "testcontainers-dotnet") + .WithLabel("test.environment", "ci") + .WithCommand("sh", "-c", "sleep 5") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("sh", "-c", "echo ready")) + .Build(); + + // Act + await container.StartAsync(); + + // Assert - verify container is running + var id = container.Id; + Assert.NotEmpty(id); + } + + [Fact] + public async Task MultipleContainers_CanRunInParallel() + { + // Arrange + var container1 = new ContainerBuilder("alpine:latest") + .WithCommand("sh", "-c", "sleep 10") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("sh", "-c", "echo ready")) + .Build(); + + var container2 = new ContainerBuilder("alpine:latest") + .WithCommand("sh", "-c", "sleep 10") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("sh", "-c", "echo ready")) + .Build(); + + try + { + // Act - Start both containers in parallel + await Task.WhenAll( + container1.StartAsync(), + container2.StartAsync() + ); + + // Assert - verify both containers are running + var id1 = container1.Id; + var id2 = container2.Id; + + Assert.NotEmpty(id1); + Assert.NotEmpty(id2); + Assert.NotEqual(id1, id2); + } + finally + { + // Cleanup + await Task.WhenAll( + container1.DisposeAsync().AsTask(), + container2.DisposeAsync().AsTask() + ); + } + } +} diff --git a/testcontainers-dotnet/examples/README.md b/testcontainers-dotnet/examples/README.md new file mode 100644 index 0000000..68c6bc5 --- /dev/null +++ b/testcontainers-dotnet/examples/README.md @@ -0,0 +1,253 @@ +# Testcontainers for .NET Examples + +This directory contains practical, runnable examples demonstrating various features and patterns of Testcontainers for .NET. + +## Prerequisites + +Before running these examples, you need: + +1. **.NET 8.0 SDK** or later installed +2. **Docker** running locally +3. Required NuGet packages (restored automatically) + +## Examples Overview + +### 01_PostgreSqlBasicTests.cs +**Basic PostgreSQL Usage** + +Demonstrates: +- Starting a PostgreSQL container with default settings +- Connecting to PostgreSQL with Npgsql +- Custom database configuration (database name, username, password) +- Creating schemas and inserting data +- Using IAsyncLifetime for test lifecycle management + +Run with: +```bash +dotnet test --filter "FullyQualifiedName~PostgreSqlBasicTests" +``` + +### 02_RedisCacheTests.cs +**Redis Operations** + +Demonstrates: +- Basic Redis key-value operations with StackExchange.Redis +- Key expiration +- Using RedisBuilder for container configuration +- Proper async cleanup with IAsyncLifetime + +Run with: +```bash +dotnet test --filter "FullyQualifiedName~RedisCacheTests" +``` + +### 03_SqlServerEntityFrameworkTests.cs +**SQL Server with Entity Framework Core** + +Demonstrates: +- Using SQL Server container with Entity Framework Core +- Database context initialization +- Entity CRUD operations +- EnsureCreated for schema setup + +Run with: +```bash +dotnet test --filter "FullyQualifiedName~SqlServerEntityFrameworkTests" +``` + +### 04_MultiContainerNetworkTests.cs +**Multi-Container Networking** + +Demonstrates: +- Creating custom Docker networks +- Connecting multiple containers on the same network +- Container-to-container communication using network aliases +- Simulating microservices architectures +- Proper cleanup order (containers before networks) + +This is essential for: +- Integration testing with multiple services +- Testing service dependencies +- Simulating production-like environments + +Run with: +```bash +dotnet test --filter "FullyQualifiedName~MultiContainerNetworkTests" +``` + +### 05_GenericContainerTests.cs +**Generic Container Patterns** + +Demonstrates: +- Using containers without pre-configured modules +- Custom nginx container with HTML content +- Environment variables +- Port mappings +- Different wait strategies (port-based, log-based, HTTP-based) +- Reading container logs +- Executing commands in running containers + +Run with: +```bash +dotnet test --filter "FullyQualifiedName~GenericContainerTests" +``` + +## Running All Examples + +To run all examples: + +```bash +# Run all tests +dotnet test + +# Run all tests with verbose output +dotnet test --logger "console;verbosity=detailed" + +# Run a specific test file +dotnet test --filter "FullyQualifiedName~PostgreSqlBasicTests" +``` + +## Common Patterns + +### 1. Basic Pattern (with Module and IAsyncLifetime) + +```csharp +using Testcontainers.PostgreSql; +using Xunit; + +public class DatabaseTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:16-alpine").Build(); + + public async Task InitializeAsync() + { + await _postgres.StartAsync(); + } + + public async Task DisposeAsync() + { + await _postgres.DisposeAsync(); + } + + [Fact] + public async Task CanConnect() + { + var connectionString = _postgres.GetConnectionString(); + // Use connection string... + } +} +``` + +### 2. Generic Container Pattern + +```csharp +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +var container = new ContainerBuilder() + .WithImage("image:tag") + .WithPortBinding(8080, true) + .WithEnvironment("KEY", "value") + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(8080)) + .Build(); + +await container.StartAsync(); +``` + +### 3. Multi-Container Pattern + +```csharp +using DotNet.Testcontainers.Networks; + +// Create network +var network = new NetworkBuilder().Build(); +await network.CreateAsync(); + +// Start containers on network +var db = new PostgreSqlBuilder("postgres:16-alpine") + .WithNetwork(network) + .WithNetworkAliases("database") + .Build(); +await db.StartAsync(); + +var app = new ContainerBuilder() + .WithImage("myapp:latest") + .WithNetwork(network) + .WithEnvironment("DB_HOST", "database") + .Build(); +await app.StartAsync(); +``` + +## Tips and Best Practices + +1. **Always use IAsyncLifetime for async setup/teardown** + ```csharp + public class Tests : IAsyncLifetime + { + public async Task InitializeAsync() { /* setup */ } + public async Task DisposeAsync() { /* cleanup */ } + } + ``` + +2. **Use pre-configured modules when available** + - Modules provide sensible defaults + - Helper methods like `GetConnectionString()` + - Automatic credential management + +3. **Use class fixtures for shared containers** + - Faster test execution + - Shared state across multiple tests + +4. **Use custom networks for multi-container tests** + - Containers can communicate via aliases + - More realistic than host networking + +5. **Use appropriate wait strategies** + - `UntilPortIsAvailable` - when service listens on a port + - `UntilMessageIsLogged` - when service logs a ready message + - `UntilHttpRequestIsSucceeded` - when service has an HTTP health endpoint + +## Troubleshooting + +### Container won't start +- Check if Docker is running: `docker ps` +- Check container logs: `await container.GetLogsAsync()` +- Increase timeout: `.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(80).WithTimeout(TimeSpan.FromMinutes(2)))` + +### Port conflicts +- Use `.WithPortBinding(port, true)` for random host ports +- Avoid fixed port bindings in tests + +### Image pull failures +- Pull manually first: `docker pull postgres:16-alpine` +- Check network connectivity +- For private registries: `docker login registry.example.com` + +### Cleanup issues +- Verify Ryuk is running: `docker ps | grep ryuk` +- Ensure `WithCleanUp(true)` is set (default) +- Check cleanup order: dispose containers before networks + +## Additional Resources + +- [Testcontainers for .NET Documentation](https://dotnet.testcontainers.org/) +- [NuGet Packages](https://www.nuget.org/packages?q=testcontainers) +- [GitHub Repository](https://github.com/testcontainers/testcontainers-dotnet) + +## Building and Running + +This is a standard .NET test project: + +```bash +# Restore dependencies +dotnet restore + +# Build the project +dotnet build + +# Run all tests +dotnet test + +# Run specific test +dotnet test --filter "FullyQualifiedName~TestName" +``` diff --git a/testcontainers-dotnet/examples/TestcontainersExamples.csproj b/testcontainers-dotnet/examples/TestcontainersExamples.csproj new file mode 100644 index 0000000..9ffb640 --- /dev/null +++ b/testcontainers-dotnet/examples/TestcontainersExamples.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + +