diff --git a/Frank.Testing.TestOutputExtensions/Frank.Testing.TestOutputExtensions.csproj b/Frank.Testing.TestOutputExtensions/Frank.Testing.TestOutputExtensions.csproj
index b469bbc..1da3e5b 100644
--- a/Frank.Testing.TestOutputExtensions/Frank.Testing.TestOutputExtensions.csproj
+++ b/Frank.Testing.TestOutputExtensions/Frank.Testing.TestOutputExtensions.csproj
@@ -3,12 +3,13 @@
Xunit.Abstractions
Extends ITestOutputHelper to allow output of a generic type using a serializer.
- test, testing, extensions, xunit, output, helper, serializer, json, xml, csharp, dump, var, dumpvar, vardump, xmlserializer, jsonserializer, testoutputhelper, testoutput, outputhelper, ITestOutputHelper, abstractions, xunit.abstractions, xunit.abstractions.extensions, xunit.extensions.ordering
+ test, testing, extensions, xunit, output, helper, table, output, serializer, json, xml, csharp, dump, var, dumpvar, vardump, xmlserializer, jsonserializer, testoutputhelper, testoutput, outputhelper, ITestOutputHelper, abstractions, xunit.abstractions, xunit.abstractions.extensions, xunit.extensions.ordering
-
-
+
+
+
diff --git a/Frank.Testing.TestOutputExtensions/TestOutputTableExtensions.cs b/Frank.Testing.TestOutputExtensions/TestOutputTableExtensions.cs
new file mode 100644
index 0000000..2700673
--- /dev/null
+++ b/Frank.Testing.TestOutputExtensions/TestOutputTableExtensions.cs
@@ -0,0 +1,13 @@
+using ConsoleTableExt;
+
+namespace Xunit.Abstractions;
+
+public static class TestOutputTableExtensions
+{
+ public static void WriteTable(this ITestOutputHelper outputHelper, IEnumerable source, ConsoleTableBuilderFormat format = ConsoleTableBuilderFormat.Minimal) where T : class =>
+ outputHelper.WriteLine(ConsoleTableBuilder
+ .From(source.ToList())
+ .WithFormat(format)
+ .Export()
+ .ToString());
+}
diff --git a/Frank.Testing.Testcontainers/ContainerRunner.cs b/Frank.Testing.Testcontainers/ContainerRunner.cs
new file mode 100644
index 0000000..26d045c
--- /dev/null
+++ b/Frank.Testing.Testcontainers/ContainerRunner.cs
@@ -0,0 +1,64 @@
+using DotNet.Testcontainers.Containers;
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Frank.Testing.Testcontainers;
+
+public class ContainerRunner : ITestcontainerRunner where T : class, IContainer
+{
+ private readonly ILogger _logger;
+ private readonly T _container;
+ private readonly CancellationToken _cancellationToken;
+ private readonly TimeSpan _timeout;
+
+ private CancellationTokenSource? _cancellationTokenSource;
+
+ internal ContainerRunner(ILogger? logger, T container, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ _logger = logger ??= new NullLogger();
+ _container = container ?? throw new ArgumentNullException(nameof(container));
+ _cancellationToken = cancellationToken;
+ _timeout = timeout;
+ }
+
+ public async Task StartAsync()
+ {
+ _cancellationTokenSource = new CancellationTokenSource(_timeout);
+ _cancellationToken.Register(() => _cancellationTokenSource.Cancel());
+ await _container.StartAsync(_cancellationTokenSource.Token);
+ }
+
+ public async Task StopAsync()
+ {
+ await _container.StopAsync(_cancellationToken);
+ await DisposeAsync();
+ }
+
+ public TestcontainersStates GetState() => _container.State;
+
+ ///
+ public async Task ExecuteCommandAsync(string command, CancellationToken cancellationToken = default)
+ {
+ await _container.ExecAsync(new[] { command }, _cancellationToken);
+ }
+
+ public async Task ExecuteAsync(Func actionAsync)
+ {
+ try
+ {
+ await actionAsync();
+ }
+ catch (Exception? exception)
+ {
+ _logger.LogError(exception, "Failed to execute action in container {ContainerName}", _container.Name);
+ }
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await _container.DisposeAsync();
+ _cancellationTokenSource?.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/Frank.Testing.Testcontainers/Frank.Testing.Testcontainers.csproj b/Frank.Testing.Testcontainers/Frank.Testing.Testcontainers.csproj
new file mode 100644
index 0000000..10db732
--- /dev/null
+++ b/Frank.Testing.Testcontainers/Frank.Testing.Testcontainers.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Frank.Testing.Testcontainers/ITestcontainerRunner.cs b/Frank.Testing.Testcontainers/ITestcontainerRunner.cs
new file mode 100644
index 0000000..f714e63
--- /dev/null
+++ b/Frank.Testing.Testcontainers/ITestcontainerRunner.cs
@@ -0,0 +1,16 @@
+using DotNet.Testcontainers.Containers;
+
+namespace Frank.Testing.Testcontainers;
+
+public interface ITestcontainerRunner : IAsyncDisposable
+{
+ Task StartAsync();
+
+ Task StopAsync();
+
+ TestcontainersStates GetState();
+
+ Task ExecuteCommandAsync(string command, CancellationToken cancellationToken = default);
+
+ Task ExecuteAsync(Func actionAsync);
+}
\ No newline at end of file
diff --git a/Frank.Testing.Testcontainers/TestContainerRunnerBuilder.cs b/Frank.Testing.Testcontainers/TestContainerRunnerBuilder.cs
new file mode 100644
index 0000000..0ec924b
--- /dev/null
+++ b/Frank.Testing.Testcontainers/TestContainerRunnerBuilder.cs
@@ -0,0 +1,51 @@
+using DotNet.Testcontainers.Containers;
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Frank.Testing.Testcontainers;
+
+public class TestContainerRunnerBuilder where T : class, IContainer
+{
+ private ILogger? _logger;
+ private TimeSpan _maxLifetime;
+ private CancellationToken _cancellationToken;
+ private Func? _containerFactory;
+
+ public TestContainerRunnerBuilder WithLogger(ILogger? logger)
+ {
+ _logger = logger;
+ return this;
+ }
+
+ public TestContainerRunnerBuilder WithMaxLifetime(TimeSpan maxLifetime)
+ {
+ _maxLifetime = maxLifetime;
+ return this;
+ }
+
+ public TestContainerRunnerBuilder WithCancellationToken(CancellationToken cancellationToken)
+ {
+ _cancellationToken = cancellationToken;
+ return this;
+ }
+
+ public TestContainerRunnerBuilder WithContainerFactory(Func? containerFactory)
+ {
+ _containerFactory = containerFactory;
+ return this;
+ }
+
+ public ITestcontainerRunner Build()
+ {
+ _logger ??= new NullLogger();
+ if (_containerFactory == null)
+ throw new ArgumentNullException(nameof(_containerFactory));
+ if (_maxLifetime == default)
+ _maxLifetime = TimeSpan.FromMinutes(1);
+ if (_cancellationToken == default)
+ _cancellationToken = CancellationToken.None;
+
+ return new ContainerRunner(_logger, _containerFactory(), _maxLifetime, _cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/Frank.Testing.Tests/Frank.Testing.Tests.csproj b/Frank.Testing.Tests/Frank.Testing.Tests.csproj
index 75f4672..0d61dde 100644
--- a/Frank.Testing.Tests/Frank.Testing.Tests.csproj
+++ b/Frank.Testing.Tests/Frank.Testing.Tests.csproj
@@ -6,6 +6,7 @@
+
diff --git a/Frank.Testing.Tests/TestOutputHelperExtensionsTests.cs b/Frank.Testing.Tests/TestOutputExtensionsTests/TestOutputHelperExtensionsTests.cs
similarity index 97%
rename from Frank.Testing.Tests/TestOutputHelperExtensionsTests.cs
rename to Frank.Testing.Tests/TestOutputExtensionsTests/TestOutputHelperExtensionsTests.cs
index 491334b..0a065c3 100644
--- a/Frank.Testing.Tests/TestOutputHelperExtensionsTests.cs
+++ b/Frank.Testing.Tests/TestOutputExtensionsTests/TestOutputHelperExtensionsTests.cs
@@ -6,7 +6,7 @@
using Xunit.Abstractions;
-namespace Frank.Testing.Tests;
+namespace Frank.Testing.Tests.TestOutputExtensionsTests;
[TestSubject(typeof(TestOutputHelperExtensions))]
public class TestOutputHelperExtensionsTests(ITestOutputHelper testOutputHelper)
diff --git a/Frank.Testing.Tests/TestOutputExtensionsTests/TestOutputTableExtensionsTests.cs b/Frank.Testing.Tests/TestOutputExtensionsTests/TestOutputTableExtensionsTests.cs
new file mode 100644
index 0000000..fcd9030
--- /dev/null
+++ b/Frank.Testing.Tests/TestOutputExtensionsTests/TestOutputTableExtensionsTests.cs
@@ -0,0 +1,27 @@
+using Xunit.Abstractions;
+
+namespace Frank.Testing.Tests.TestOutputExtensionsTests;
+
+public class TestOutputTableExtensionsTests
+{
+ private readonly ITestOutputHelper _outputHelper;
+
+ public TestOutputTableExtensionsTests(ITestOutputHelper outputHelper)
+ {
+ _outputHelper = outputHelper;
+ }
+
+ [Fact]
+ public void ToTable_WithEnumerable_ReturnsTable()
+ {
+ // Arrange
+ var passwords = new[]
+ {
+ new { Sha1Hash = "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8", Sha1Prefix = "5BAA6", Sha2Suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8", TimesPwned = 3645844 }, new { Sha1Hash = "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8", Sha1Prefix = "5BAA6", Sha2Suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8", TimesPwned = 3645844 }, new { Sha1Hash = "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8", Sha1Prefix = "5BAA6", Sha2Suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8", TimesPwned = 3645844 },
+ new { Sha1Hash = "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8", Sha1Prefix = "5BAA6", Sha2Suffix = "1E4C9B93F3F0682250B6CF8331B7EE68FD8", TimesPwned = 364584 },
+ };
+
+ // Act
+ _outputHelper.WriteTable(passwords);
+ }
+}
\ No newline at end of file
diff --git a/Frank.Testing.sln b/Frank.Testing.sln
index b0ace23..58df3c9 100644
--- a/Frank.Testing.sln
+++ b/Frank.Testing.sln
@@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Frank.Testing.ApiTesting",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Frank.Testing.EntityFrameworkCore", "Frank.Testing.EntityFrameworkCore\Frank.Testing.EntityFrameworkCore.csproj", "{A64BD8FC-364F-40E5-A276-F1DEA9D98F67}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Frank.Testing.Testcontainers", "Frank.Testing.Testcontainers\Frank.Testing.Testcontainers.csproj", "{13E7B32B-CBA5-4BA3-854B-697B835796F7}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -53,5 +55,9 @@ Global
{A64BD8FC-364F-40E5-A276-F1DEA9D98F67}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A64BD8FC-364F-40E5-A276-F1DEA9D98F67}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A64BD8FC-364F-40E5-A276-F1DEA9D98F67}.Release|Any CPU.Build.0 = Release|Any CPU
+ {13E7B32B-CBA5-4BA3-854B-697B835796F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {13E7B32B-CBA5-4BA3-854B-697B835796F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {13E7B32B-CBA5-4BA3-854B-697B835796F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {13E7B32B-CBA5-4BA3-854B-697B835796F7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal