Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Introduce a new Testcontainers.Xunit package #1165

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from

Conversation

0xced
Copy link
Contributor

@0xced 0xced commented Apr 27, 2024

What does this PR do?

This pull request introduces a new Testcontainers.Xunit NuGet package.

It provides two main classes to simplify working with Testcontainers within xUnit.net tests.

  • ContainerTest is a base class for tests needing one container per test method.
  • ContainerFixture is a fixture that can be injected into test classes that need one container per test class (a.k.a. singleton container instance).

Both support logging, respectively through ITestOutputHelper and IMessageSink which are the standard xUnit.net logging mechanisms.

DbContainerTest and DbContainerFixture are also provided to ease working with database containers. They provide methods to simplify the creation of DbConnection instances.

Why is it important?

This will greatly reduce boilerplate code needed when using Testcontainers with xUnit.net, both for Testcontainers consumers and for the Testcontainers tests themselves.

Related issues

Follow-ups

I have another branch (feature/Testcontainers.Xunit+samples) where I have started using Testcontainers.Xunit in the Testcontainers tests themselves. I have currently updated MongoDbContainerTest, ClickHouseContainerTest, MariaDbContainerTest and PostgreSqlContainerTest. You can have a look at them to see how the tests are simplified by using the new base classes or fixtures.

I'm not sure yet whether updating all the Testcontainers tests should be part of this pull request or part of a subsequent pull request.

I've been using xUnit.net extensively so I'm pretty confident about the design of this new package. I'm not familiar enough with either NUnit or MSTest to propose similar packages, maybe someone else can step in.

Copy link

netlify bot commented Apr 27, 2024

Deploy Preview for testcontainers-dotnet ready!

Name Link
🔨 Latest commit c20188e
🔍 Latest deploy log https://app.netlify.com/sites/testcontainers-dotnet/deploys/6654f593801afe000836a01d
😎 Deploy Preview https://deploy-preview-1165--testcontainers-dotnet.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@0xced 0xced force-pushed the feature/Testcontainers.Xunit branch from 93f372d to 3641802 Compare May 27, 2024 10:00
src/Testcontainers.Xunit/ContainerFixture.cs Outdated Show resolved Hide resolved
Comment on lines 28 to 36
get
{
if (_container == null)
{
var containerBuilder = new TBuilderEntity().WithLogger(new MessageSinkLogger(MessageSink));
_container = Configure(containerBuilder).Build();
}
return _container;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you choose a different implementation (ctor) compared to ContainerTest? Can't you use the same class but use an overloaded ctor?

WDYT about using Lazy<TContainerEntity> here? Thread-safety is probably not something we need to worry about though. This is probably unnecessary with the changes I have in mind.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it in e419240 then moved it to the base ContainerLifetime class in c20188e.

This is probably unnecessary with the changes I have in mind.

Happy to hear about it to further refine the implementation.

Comment on lines 39 to 56
/// <summary>
/// Extension point to further configure the container instance.
/// </summary>
/// <example>
/// <code>
/// public class MariaDbRootUserFixture(IMessageSink messageSink) : DbContainerFixture&lt;MariaDbBuilder, MariaDbContainer&gt;(messageSink)
/// {
/// public override DbProviderFactory DbProviderFactory => MySqlConnectorFactory.Instance;
///
/// protected override MariaDbBuilder Configure(MariaDbBuilder builder)
/// {
/// return builder.WithUsername("root");
/// }
/// }
/// </code>
/// </example>
/// <param name="builder">The container builder.</param>
/// <returns>A configured instance of <typeparamref name="TBuilderEntity" />.</returns>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// <summary>
/// Extension point to further configure the container instance.
/// </summary>
/// <example>
/// <code>
/// public class MariaDbRootUserFixture(IMessageSink messageSink) : DbContainerFixture&lt;MariaDbBuilder, MariaDbContainer&gt;(messageSink)
/// {
/// public override DbProviderFactory DbProviderFactory => MySqlConnectorFactory.Instance;
///
/// protected override MariaDbBuilder Configure(MariaDbBuilder builder)
/// {
/// return builder.WithUsername("root");
/// }
/// }
/// </code>
/// </example>
/// <param name="builder">The container builder.</param>
/// <returns>A configured instance of <typeparamref name="TBuilderEntity" />.</returns>
/// <summary>
/// Extension point to further configure the container instance.
/// </summary>
/// <example>
/// <code>
/// public class MariaDbRootUserFixture(IMessageSink messageSink) : DbContainerFixture&lt;MariaDbBuilder, MariaDbContainer&gt;(messageSink)
/// {
/// public override DbProviderFactory DbProviderFactory =&gt; MySqlConnectorFactory.Instance;
/// <br />
/// protected override MariaDbBuilder Configure(MariaDbBuilder builder)
/// {
/// return builder.WithUsername("root");
/// }
/// }
/// </code>
/// </example>
/// <param name="builder">The container builder.</param>
/// <returns>A configured instance of <typeparamref name="TBuilderEntity" />.</returns>
Suggested change
/// <summary>
/// Extension point to further configure the container instance.
/// </summary>
/// <example>
/// <code>
/// public class MariaDbRootUserFixture(IMessageSink messageSink) : DbContainerFixture&lt;MariaDbBuilder, MariaDbContainer&gt;(messageSink)
/// {
/// public override DbProviderFactory DbProviderFactory => MySqlConnectorFactory.Instance;
///
/// protected override MariaDbBuilder Configure(MariaDbBuilder builder)
/// {
/// return builder.WithUsername("root");
/// }
/// }
/// </code>
/// </example>
/// <param name="builder">The container builder.</param>
/// <returns>A configured instance of <typeparamref name="TBuilderEntity" />.</returns>
/// <summary>
/// Extension point to further configure the container instance.
/// </summary>
/// <example>
/// <code>
/// public class MariaDbRootUserFixture(IMessageSink messageSink) : DbContainerFixture&lt;MariaDbBuilder, MariaDbContainer&gt;(messageSink)
/// {
/// public override DbProviderFactory DbProviderFactory => MySqlConnectorFactory.Instance;
///
/// protected override MariaDbBuilder Configure(MariaDbBuilder builder)
/// {
/// return builder.WithUsername("root");
/// }
/// }
/// </code>
/// </example>
/// <param name="builder">The container builder.</param>
/// <returns>A configured instance of <typeparamref name="TBuilderEntity" />.</returns>

@@ -0,0 +1,24 @@
namespace DotNet.Testcontainers.Xunit;

internal sealed class MessageSinkLogger(IMessageSink messageSink) : ILogger, IDisposable
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. We can simply use overloaded ctors and use the same underlying implementation to write the message (for IMessageSink and ITestOutputHelper). I can share the pattern that I usually use tomorrow. Then we can discuss this topic further.

@HofmeisterAn
Copy link
Collaborator

I think we can consolidate a few things. The underlying configuration for a single instance (ContainerTest) and a shared instance (ContainerFixture) do not differ that much. At least in the past, I reused the same implementation. I think we can do the same here. I will share the setup I typically use tomorrow.

@0xced
Copy link
Contributor Author

0xced commented May 27, 2024

Excellent point! It did not even come to my mind that the lifetime implementation could be shared. I have done it in c20188e with a new ContainerLifetime base class used by both ContainerTest and ContainerFixture.

@@ -0,0 +1,23 @@
namespace Testcontainers.Xunit;

internal sealed class TestOutputLogger(ITestOutputHelper testOutputHelper) : ILogger, IDisposable

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of providing one implementation each for ITestOutputHelper and IMessageSink, you could realize this with an Action<string> call.

Something like

private readonly Action<string> _writeAction;

public TestLogger(ITestOutputHelper testOutputHelper)
{
    _writeAction = testOutputHelper.WriteLine;
}

public TestLogger(IMessageSink messageSink)
{
    _writeAction = message => _ = messageSink.OnMessage(new DiagnosticMessage(message));
}


public bool IsEnabled(LogLevel logLevel) => true;

public IDisposable BeginScope<TState>(TState state) => this;
Copy link

@samtrion samtrion May 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entails the risk of the logger being disposed of during use. It is better to replace it with a NullDisposable...

private sealed class NullScope : IDisposable
{
    public NullScope() { }

    /// <inheritdoc />
    public void Dispose() { }
}

@HofmeisterAn
Copy link
Collaborator

HofmeisterAn commented May 28, 2024

Sorry for the late response today. Tuesdays are always busy for me.

Excellent point! It did not even come to my mind that the lifetime implementation could be shared. I have done it in c20188e with a new ContainerLifetime base class used by both ContainerTest and ContainerFixture.

👍 We do not need to apply the suggestion, but I would like to share and discuss it quickly because I think the code (xUnit's shared context) has a lot in common, and some parts feel like copy and paste. The example I am sharing is not complete because I had to remove some critical parts, but the important parts are still there, and the concept should not be very difficult to understand. Please also take into account that the example contains much more than what is actually necessary for Testcontainers.

The ILoggerProvider implementation is similar to what @samtrion shared. The ILogger implementation is the same for both ITestOutputHelper and IMessageSink, so I do not think code duplication is really necessary.

internal sealed class XunitLoggerProvider : ILoggerProvider
{
    private readonly Stopwatch _stopwatch = Stopwatch.StartNew();

    private readonly ITestOutputHelper _testOutputHelper;

    public XunitLoggerProvider(IMessageSink messageSink)
    {
        _testOutputHelper = new MessageSinkTestOutputHelper(messageSink);
    }

    public XunitLoggerProvider(ITestOutputHelper testOutputHelper)
    {
        _testOutputHelper = testOutputHelper;
    }

    public void Dispose()
    {
    }

    public ILogger CreateLogger(string categoryName)
    {
        return new XunitLogger(_stopwatch, _testOutputHelper, categoryName);
    }

    private sealed class MessageSinkTestOutputHelper : ITestOutputHelper
    {
        private readonly IMessageSink _messageSink;

        public MessageSinkTestOutputHelper(IMessageSink messageSink)
        {
            _messageSink = messageSink;
        }

        public void WriteLine(string message)
        {
            _messageSink.OnMessage(new DiagnosticMessage(message));
        }

        public void WriteLine(string format, params object[] args)
        {
            _messageSink.OnMessage(new DiagnosticMessage(format, args));
        }
    }

    private sealed class XunitLogger : ILogger
    {
        private readonly Stopwatch _stopwatch;

        private readonly ITestOutputHelper _testOutputHelper;

        private readonly string _categoryName;

        public XunitLogger(Stopwatch stopwatch, ITestOutputHelper testOutputHelper, string categoryName)
        {
            _stopwatch = stopwatch;
            _testOutputHelper = testOutputHelper;
            _categoryName = categoryName;
        }

        public IDisposable BeginScope<TState>(TState state)
        {
            return Disposable.Instance;
        }

        public bool IsEnabled(LogLevel logLevel)
        {
            return logLevel != LogLevel.None;
        }

        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
        {
            _testOutputHelper.WriteLine("[{0} {1:hh\\:mm\\:ss\\.ff}] {2}", _categoryName, _stopwatch.Elapsed, formatter.Invoke(state, exception));
        }

        private sealed class Disposable : IDisposable
        {
            private Disposable()
            {
            }

            public static IDisposable Instance { get; } = new Disposable();

            public void Dispose()
            {
            }
        }
    }
}

I had to remove some parts here that I cannot share. The example builds on top of the WebApplicationFactory<TEntryPoint>, but the concept will be the same for Testcontainers. The actual test class utilizes either the SharedInstance or the SingleInstance implementation.

public abstract class CustomWebApplicationFactory<TEntryPoint> : WebApplicationFactory<TEntryPoint>, IAsyncLifetime where TEntryPoint : class
{
    private readonly List<IWebHostConfiguration> _configurations = new();

    protected CustomWebApplicationFactory()
    {
        AddWebHostConfiguration(new ClearLoggingProviders());
    }

    public void AddWebHostConfiguration(IWebHostConfiguration configuration)
    {
        _configurations.Add(configuration);
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);

        foreach (var configuration in _configurations)
        {
            configuration.ConfigureWebHost(builder);
        }
    }

    // For Testcontainers, the AsyncLifetime property is not necessary; this is specific to the type clash (WebApplicationFactory<TEntryPoint>).
    public IAsyncLifetime AsyncLifetime => this;

    public ValueTask InitializeAsync()
    {
        // Due to a naming conflict where the interfaces `IAsyncLifetime` and
        // `IAsyncDisposable` share the same name for the member `DisposeAsync`, but have
        // different return types, we have implemented the members of the `IAsyncLifetime`
        // interface explicitly. To prevent warnings, a non-explicit implementation has
        // been added. This matter will be addressed in xUnit.net version 3, which also
        // utilizes the `IAsyncDisposable` interface.
        throw new InvalidOperationException("Use the delegate property AsyncLifetime instead.");
    }

    async Task IAsyncLifetime.InitializeAsync()
    {
        await Task.WhenAll(_configurations.Select(configuration => configuration.InitializeAsync()))
            .ConfigureAwait(false);
    }

    async Task IAsyncLifetime.DisposeAsync()
    {
        await Task.WhenAll(_configurations.Select(configuration => configuration.DisposeAsync()))
            .ConfigureAwait(false);
    }

    public class SharedInstance : CustomWebApplicationFactory<TEntryPoint>
    {
        public SharedInstance(IMessageSink messageSink)
        {
            AddWebHostConfiguration(new LoggingWebHostConfiguration(messageSink));
        }
    }

    public class SingleInstance : CustomWebApplicationFactory<TEntryPoint>
    {
        private readonly MoqLoggerProvider _mockOfILogger = new();

        public SingleInstance(ITestOutputHelper testOutputHelper)
        {
            AddWebHostConfiguration(new LoggingWebHostConfiguration(testOutputHelper));
            AddWebHostConfiguration(new LoggingWebHostConfiguration(_mockOfILogger));
        }

        public Mock<ILogger> MockOfILogger => _mockOfILogger;

        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);
            _mockOfILogger.Dispose();
        }
    }

    private sealed class ClearLoggingProviders : WebHostConfiguration
    {
        public override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureTestServices(services => services.RemoveAll<ILoggerFactory>());
            builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders());
        }
    }
}

The test class will use either one of the instances, similar to:

public class XunitShardContext1 : IClassFixture<CustomWebApplicationFactory<Program>.SharedInstance>
{
    private readonly CustomWebApplicationFactory<Program> _app;

    public XunitShardContext1(CustomWebApplicationFactory<Program>.SharedInstance app)
    {
        _app = app;
    }
}

public class XunitShardContext2 : IAsyncLifetime
{
    private readonly CustomWebApplicationFactory<Program> _app;

    public XunitShardContext2(ITestOutputHelper testOutputHelper)
    {
        _app = new CustomWebApplicationFactory<Program>.SingleInstance(testOutputHelper);
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Enhancement]: Enable built-in support for singleton container instances
3 participants