Skip to content

Commit 1cf48b2

Browse files
0xcedHofmeisterAn
andauthored
feat: Introduce a new Testcontainers.Xunit package (#1165)
Co-authored-by: Andre Hofmeister <[email protected]>
1 parent aa8234d commit 1cf48b2

30 files changed

+16683
-1
lines changed

.github/workflows/cicd.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ jobs:
7878
{ name: "Testcontainers.RavenDb", runs-on: "ubuntu-22.04" },
7979
{ name: "Testcontainers.Redis", runs-on: "ubuntu-22.04" },
8080
{ name: "Testcontainers.Redpanda", runs-on: "ubuntu-22.04" },
81-
{ name: "Testcontainers.WebDriver", runs-on: "ubuntu-22.04" }
81+
{ name: "Testcontainers.WebDriver", runs-on: "ubuntu-22.04" },
82+
{ name: "Testcontainers.Xunit", runs-on: "ubuntu-22.04" }
8283
]
8384

8485
runs-on: ${{ matrix.test-projects.runs-on }}

Directory.Packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1"/>
1818
<PackageVersion Include="Microsoft.Extensions.Diagnostics.Testing" Version="8.10.0"/>
1919
<PackageVersion Include="coverlet.collector" Version="6.0.2"/>
20+
<PackageVersion Include="Dapper" Version="2.1.35"/>
2021
<PackageVersion Include="ReflectionMagic" Version="5.0.1"/>
22+
<PackageVersion Include="xunit.extensibility.execution" Version="2.9.0"/>
2123
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2"/>
24+
<PackageVersion Include="xunit.v3.extensibility.core" Version="0.2.0-pre.69"/>
2225
<PackageVersion Include="xunit" Version="2.9.2"/>
2326
<!-- Third-party client dependencies to connect and interact with the containers: -->
2427
<PackageVersion Include="Apache.NMS.ActiveMQ" Version="2.1.0"/>

Testcontainers.sln

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Redpanda", "
9797
EndProject
9898
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver", "src\Testcontainers.WebDriver\Testcontainers.WebDriver.csproj", "{64A87DE5-29B0-4A54-9E74-560484D8C7C0}"
9999
EndProject
100+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Xunit", "src\Testcontainers.Xunit\Testcontainers.Xunit.csproj", "{380BB29B-F556-404D-B13B-CA250599C565}"
101+
EndProject
102+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.XunitV3", "src\Testcontainers.XunitV3\Testcontainers.XunitV3.csproj", "{84911C93-C2A9-46E9-AE5E-D567306589E5}"
103+
EndProject
100104
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers", "src\Testcontainers\Testcontainers.csproj", "{EC76857B-A3B8-4B7A-A1B0-8D867A4D1733}"
101105
EndProject
102106
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.ActiveMq.Tests", "tests\Testcontainers.ActiveMq.Tests\Testcontainers.ActiveMq.Tests.csproj", "{AB93C67F-0A53-4525-AE6C-29B065820ABE}"
@@ -195,6 +199,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tes
195199
EndProject
196200
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver.Tests", "tests\Testcontainers.WebDriver.Tests\Testcontainers.WebDriver.Tests.csproj", "{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}"
197201
EndProject
202+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Xunit.Tests", "tests\Testcontainers.Xunit.Tests\Testcontainers.Xunit.Tests.csproj", "{E901DF14-6F05-4FC2-825A-3055FAD33561}"
203+
EndProject
198204
Global
199205
GlobalSection(SolutionConfigurationPlatforms) = preSolution
200206
Debug|Any CPU = Debug|Any CPU
@@ -372,6 +378,14 @@ Global
372378
{64A87DE5-29B0-4A54-9E74-560484D8C7C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
373379
{64A87DE5-29B0-4A54-9E74-560484D8C7C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
374380
{64A87DE5-29B0-4A54-9E74-560484D8C7C0}.Release|Any CPU.Build.0 = Release|Any CPU
381+
{380BB29B-F556-404D-B13B-CA250599C565}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
382+
{380BB29B-F556-404D-B13B-CA250599C565}.Debug|Any CPU.Build.0 = Debug|Any CPU
383+
{380BB29B-F556-404D-B13B-CA250599C565}.Release|Any CPU.ActiveCfg = Release|Any CPU
384+
{380BB29B-F556-404D-B13B-CA250599C565}.Release|Any CPU.Build.0 = Release|Any CPU
385+
{84911C93-C2A9-46E9-AE5E-D567306589E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
386+
{84911C93-C2A9-46E9-AE5E-D567306589E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
387+
{84911C93-C2A9-46E9-AE5E-D567306589E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
388+
{84911C93-C2A9-46E9-AE5E-D567306589E5}.Release|Any CPU.Build.0 = Release|Any CPU
375389
{EC76857B-A3B8-4B7A-A1B0-8D867A4D1733}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
376390
{EC76857B-A3B8-4B7A-A1B0-8D867A4D1733}.Debug|Any CPU.Build.0 = Debug|Any CPU
377391
{EC76857B-A3B8-4B7A-A1B0-8D867A4D1733}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -568,6 +582,10 @@ Global
568582
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
569583
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
570584
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.Build.0 = Release|Any CPU
585+
{E901DF14-6F05-4FC2-825A-3055FAD33561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
586+
{E901DF14-6F05-4FC2-825A-3055FAD33561}.Debug|Any CPU.Build.0 = Debug|Any CPU
587+
{E901DF14-6F05-4FC2-825A-3055FAD33561}.Release|Any CPU.ActiveCfg = Release|Any CPU
588+
{E901DF14-6F05-4FC2-825A-3055FAD33561}.Release|Any CPU.Build.0 = Release|Any CPU
571589
EndGlobalSection
572590
GlobalSection(NestedProjects) = preSolution
573591
{5365F780-0E6C-41F0-B1B9-7DC34368F80C} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
@@ -612,6 +630,8 @@ Global
612630
{BFDA179A-40EB-4CEB-B8E9-0DF32C65E2C5} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
613631
{45D6F69C-4D87-4130-AA90-0DB2F7460DAE} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
614632
{64A87DE5-29B0-4A54-9E74-560484D8C7C0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
633+
{380BB29B-F556-404D-B13B-CA250599C565} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
634+
{84911C93-C2A9-46E9-AE5E-D567306589E5} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
615635
{EC76857B-A3B8-4B7A-A1B0-8D867A4D1733} = {673F23AE-7694-4BB9-ABD4-136D6C13634E}
616636
{AB93C67F-0A53-4525-AE6C-29B065820ABE} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
617637
{8E1E0A6D-EEBB-4455-B8E8-A55AF9B2062C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
@@ -661,5 +681,6 @@ Global
661681
{9E8E6AA5-65D1-498F-BEAB-BA34723A0050} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
662682
{27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
663683
{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
684+
{E901DF14-6F05-4FC2-825A-3055FAD33561} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF}
664685
EndGlobalSection
665686
EndGlobal

docs/test_frameworks/xunit_net.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Testing with xUnit.net
2+
3+
The [Testcontainers.Xunit](https://www.nuget.org/packages/Testcontainers.Xunit) package simplifies writing tests with containers in [xUnit.net](https://xunit.net). By leveraging xUnit.net's [shared context](https://xunit.net/docs/shared-context), this package automates the setup and teardown of test resources, creating and disposing of containers as needed. This reduces repetitive code and avoids common patterns that developers would otherwise need to implement repeatedly.
4+
5+
To get started, add the following dependency to your project file:
6+
7+
```shell title="NuGet"
8+
dotnet add package Testcontainers.Xunit
9+
```
10+
11+
## Creating an isolated test context
12+
13+
To create a new test resource instance for each test, inherit from the `ContainerTest<TBuilderEntity, TContainerEntity>` class. Each test resource instance is isolated and not shared across other tests, making this approach ideal for destructive operations that could interfere with other tests. You can access the generic `TContainerEntity` container instance through the `Container` property.
14+
15+
The example below demonstrates how to override the `Configure(TBuilderEntity)` method and pin the image version. This method allows you to configure the container instance specifically for your test case, with all container builder methods available. If your tests rely on a Testcontainers' module, the module's default configurations will be applied.
16+
17+
=== "Configure a Redis Container"
18+
```csharp
19+
--8<-- "tests/Testcontainers.Xunit.Tests/RedisContainerTest`1.cs:ConfigureRedisContainer"
20+
```
21+
22+
!!!tip
23+
Always pin the image version to avoid flakiness. This ensures consistency and prevents unexpected behavior, as the `latest` tag may pointing to a new version.
24+
25+
The base class also receives an instance of xUnit.net's [ITestOutputHelper](https://xunit.net/docs/capturing-output) to capture and forward log messages to the running test.
26+
27+
Considering that xUnit.net runs tests in a deterministic natural sort order (like `Test1`, `Test2`, etc.), retrieving the Redis (string) value in the second test will always return `null` since a new test resource instance (Redis container) is created for each test.
28+
29+
=== "Run Tests"
30+
```csharp
31+
--8<-- "tests/Testcontainers.Xunit.Tests/RedisContainerTest`1.cs:RunTests"
32+
```
33+
34+
If you check the output of `docker ps`, you will notice that three container instances in total are run, with two of them being Redis instances.
35+
36+
```title="List running containers"
37+
PS C:\Sources\dotnet\testcontainers-dotnet> docker ps
38+
CONTAINER ID IMAGE COMMAND CREATED
39+
be115f3df138 redis:7.0 "docker-entrypoint.s…" 3 seconds ago
40+
59349127f8c0 redis:7.0 "docker-entrypoint.s…" 4 seconds ago
41+
45fa02b3e997 testcontainers/ryuk:0.9.0 "/bin/ryuk" 4 seconds ago
42+
```
43+
44+
## Creating a shared test context
45+
46+
Sometimes, creating and disposing of a test resource can be an expensive operation that you do not want to repeat for every test. By inheriting from the `ContainerFixture<TBuilderEntity, TContainerEntity>` class, you can share the test resource instance across all tests within the same test class.
47+
48+
xUnit.net's fixture implementation does not rely on the `ITestOutputHelper` interface to capture and forward log messages; instead, it expects an implementation of `IMessageSink`. Make sure your fixture's default constructor accepts the interface implementation and forwards it to the base class.
49+
50+
=== "Configure Redis Container"
51+
```csharp
52+
--8<-- "tests/Testcontainers.Xunit.Tests/RedisContainerTest`2.cs:ConfigureRedisContainer"
53+
```
54+
55+
This ensures that the fixture is created only once for the entire test class, which also improves overall test performance. You must implement the `IClassFixture<TFixture>` interface with the previously created container fixture type in your test class and add the type as argument to the default constructor.
56+
57+
=== "Inject Redis Container"
58+
```csharp
59+
--8<-- "tests/Testcontainers.Xunit.Tests/RedisContainerTest`2.cs:InjectContainerFixture"
60+
```
61+
62+
In this case, retrieving the Redis (string) value in the second test will no longer return `null`. Instead, it will return the value added in the first test.
63+
64+
=== "Run Tests"
65+
```csharp
66+
--8<-- "tests/Testcontainers.Xunit.Tests/RedisContainerTest`2.cs:RunTests"
67+
```
68+
69+
The output of `docker ps` shows that, instead of two Redis containers, only one runs.
70+
71+
```title="List running containers"
72+
PS C:\Sources\dotnet\testcontainers-dotnet> docker ps
73+
CONTAINER ID IMAGE COMMAND CREATED
74+
d29a393816ce redis:7.0 "docker-entrypoint.s…" 3 seconds ago
75+
e878f0b8f4bc testcontainers/ryuk:0.9.0 "/bin/ryuk" 3 seconds ago
76+
```
77+
78+
## Testing ADO.NET services
79+
80+
In addition to the two mentioned base classes, the package contains two more classes: `DbContainerTest` and `DbContainerFixture`, which behave identically but offer additional convenient features when working with services accessible through an ADO.NET provider.
81+
82+
Inherit from either the `DbContainerTest` or `DbContainerFixture` class and override the `Configure(TBuilderEntity)` method to configure your database service.
83+
84+
In this example, we use the default configuration of the PostgreSQL module. The container image capabilities are used to instantiate the database, schema, and test data. During startup, the PostgreSQL container runs SQL scripts placed under the `/docker-entrypoint-initdb.d/` directory automatically.
85+
86+
=== "Configure PostgreSQL Container"
87+
```csharp
88+
--8<-- "tests/Testcontainers.Xunit.Tests/PostgreSqlContainer.cs:ConfigurePostgreSqlContainer"
89+
```
90+
91+
Inheriting from the database container test or fixture class requires you to implement the abstract `DbProviderFactory` property and resolve a compatible `DbProviderFactory` according to your ADO.NET service.
92+
93+
=== "Configure DbProviderFactory"
94+
```csharp
95+
--8<-- "tests/Testcontainers.Xunit.Tests/PostgreSqlContainer.cs:ConfigureDbProviderFactory"
96+
```
97+
98+
!!! note
99+
100+
Depending on how you initialize and access the database, it may be necessary to override the `ConnectionString` property and replace the default database name with the one actual in use.
101+
102+
After configuring the dependent ADO.NET service, you can add the necessary tests. In this case, we run an SQL `SELECT` statement to retrieve the first record from the `album` table.
103+
104+
=== "Run Tests"
105+
```csharp
106+
--8<-- "tests/Testcontainers.Xunit.Tests/PostgreSqlContainer.cs:RunTests"
107+
```
108+
109+
--8<-- "docs/modules/_call_out_test_projects.txt"

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ nav:
4141
- api/resource_reuse.md
4242
- api/wait_strategies.md
4343
- api/best_practices.md
44+
- test_frameworks/xunit_net.md
4445
- Examples:
4546
- examples/dind.md
4647
- examples/aspnet.md
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
root = true
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace Testcontainers.Xunit;
2+
3+
/// <summary>
4+
/// Fixture for sharing a container instance across multiple tests in a single class.
5+
/// See <a href="https://xunit.net/docs/shared-context">Shared Context between Tests</a> from xUnit.net documentation for more information about fixtures.
6+
/// A logger is automatically configured to write diagnostic messages to xUnit's <see cref="IMessageSink" />.
7+
/// </summary>
8+
/// <param name="messageSink">An optional <see cref="IMessageSink" /> where the logs are written to. Pass <c>null</c> to ignore logs.</param>
9+
/// <typeparam name="TBuilderEntity">The builder entity.</typeparam>
10+
/// <typeparam name="TContainerEntity">The container entity.</typeparam>
11+
[PublicAPI]
12+
public class ContainerFixture<TBuilderEntity, TContainerEntity>(IMessageSink messageSink)
13+
: ContainerLifetime<TBuilderEntity, TContainerEntity>(new MessageSinkLogger(messageSink))
14+
where TBuilderEntity : IContainerBuilder<TBuilderEntity, TContainerEntity>, new()
15+
where TContainerEntity : IContainer;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
namespace Testcontainers.Xunit;
2+
3+
/// <summary>
4+
/// Base class managing the lifetime of a container.
5+
/// </summary>
6+
/// <typeparam name="TBuilderEntity">The builder entity.</typeparam>
7+
/// <typeparam name="TContainerEntity">The container entity.</typeparam>
8+
public abstract class ContainerLifetime<TBuilderEntity, TContainerEntity> : IAsyncLifetime
9+
where TBuilderEntity : IContainerBuilder<TBuilderEntity, TContainerEntity>, new()
10+
where TContainerEntity : IContainer
11+
{
12+
private readonly Lazy<TContainerEntity> _container;
13+
14+
[CanBeNull]
15+
private ExceptionDispatchInfo _exception;
16+
17+
protected ContainerLifetime(ILogger logger)
18+
{
19+
_container = new Lazy<TContainerEntity>(() => Configure(new TBuilderEntity().WithLogger(logger)).Build());
20+
}
21+
22+
/// <summary>
23+
/// Gets the container instance.
24+
/// </summary>
25+
public TContainerEntity Container
26+
{
27+
get
28+
{
29+
_exception?.Throw();
30+
return _container.Value;
31+
}
32+
}
33+
34+
/// <inheritdoc />
35+
LifetimeTask IAsyncLifetime.InitializeAsync() => InitializeAsync();
36+
37+
#if !XUNIT_V3
38+
/// <inheritdoc />
39+
LifetimeTask IAsyncLifetime.DisposeAsync() => DisposeAsync();
40+
#else
41+
/// <inheritdoc />
42+
LifetimeTask IAsyncDisposable.DisposeAsync() => DisposeAsync();
43+
#endif
44+
45+
/// <summary>
46+
/// Extension method to further configure the container instance.
47+
/// </summary>
48+
/// <example>
49+
/// <code>
50+
/// public class MariaDbRootUserFixture(IMessageSink messageSink) : DbContainerFixture&lt;MariaDbBuilder, MariaDbContainer&gt;(messageSink)
51+
/// {
52+
/// public override DbProviderFactory DbProviderFactory =&gt; MySqlConnectorFactory.Instance;
53+
/// <br />
54+
/// protected override MariaDbBuilder Configure(MariaDbBuilder builder)
55+
/// {
56+
/// return builder.WithUsername("root");
57+
/// }
58+
/// }
59+
/// </code>
60+
/// </example>
61+
/// <param name="builder">The container builder to configure.</param>
62+
/// <returns>A configured instance of <typeparamref name="TBuilderEntity" />.</returns>
63+
protected virtual TBuilderEntity Configure(TBuilderEntity builder)
64+
{
65+
return builder;
66+
}
67+
68+
/// <inheritdoc cref="IAsyncLifetime" />
69+
protected virtual async LifetimeTask InitializeAsync()
70+
{
71+
try
72+
{
73+
await Container.StartAsync()
74+
.ConfigureAwait(false);
75+
}
76+
catch (Exception e)
77+
{
78+
_exception = ExceptionDispatchInfo.Capture(e);
79+
}
80+
}
81+
82+
/// <inheritdoc cref="IAsyncLifetime" />
83+
protected virtual async LifetimeTask DisposeAsync()
84+
{
85+
if (_exception == null)
86+
{
87+
await Container.DisposeAsync()
88+
.ConfigureAwait(false);
89+
}
90+
}
91+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Testcontainers.Xunit;
2+
3+
/// <summary>
4+
/// Base class for tests needing a container per test method.
5+
/// A logger is automatically configured to write messages to xUnit's <see cref="ITestOutputHelper" />.
6+
/// </summary>
7+
/// <param name="testOutputHelper">An optional <see cref="ITestOutputHelper" /> where the logs are written to. Pass <c>null</c> to ignore logs.</param>
8+
/// <param name="configure">An optional callback to configure the container.</param>
9+
/// <typeparam name="TBuilderEntity">The builder entity.</typeparam>
10+
/// <typeparam name="TContainerEntity">The container entity.</typeparam>
11+
[PublicAPI]
12+
public abstract class ContainerTest<TBuilderEntity, TContainerEntity>(ITestOutputHelper testOutputHelper, Func<TBuilderEntity, TBuilderEntity> configure = null)
13+
: ContainerLifetime<TBuilderEntity, TContainerEntity>(new TestOutputLogger(testOutputHelper))
14+
where TBuilderEntity : IContainerBuilder<TBuilderEntity, TContainerEntity>, new()
15+
where TContainerEntity : IContainer
16+
{
17+
protected override TBuilderEntity Configure(TBuilderEntity builder) => configure != null ? configure(builder) : builder;
18+
}

0 commit comments

Comments
 (0)