testcontainers / testcontainers-dotnet

A library to support tests with throwaway instances of Docker containers for all compatible .NET Standard versions.
https://dotnet.testcontainers.org
MIT License
3.75k stars 270 forks source link

[Enhancement]: Single database per test. #1262

Open PureKrome opened 5 days ago

PureKrome commented 5 days ago

Problem

First linked/referenced in #1165 Shoutout to @0xced

Context: MS-SQL containers + xUnit / testing

One of the frustrating things with running DB tests and test containers is that all the DB tests are synchronous. This is via the Collection which we run all the tests under.

This raises some issues

a quick fix to all of this is a single container per test method but that is resource expensive 😢 Resetting the Db back after each test still means we're stuck with synchronous tests.

Solution

Would be really lovely would be to have a mix of both!

(I believe this is what 🐦‍⬛ RavenDb does?)

In the context of MS-SQL this could be achieved via the connection string Initial Catalog key/value.

So maybe this means

For example - here's two classes to try and set this up. I'm not sure how close this is to #1165 PR code:

// Simple fixture which is ran once at start when the test run first runs..

public class SqlServerFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _msSqlContainer;

    public SqlServerFixture() =>
        _msSqlContainer = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-CU13-ubuntu-22.04")
            //.WithReuse(true)
            .Build();

    public string ConnectionString => _msSqlContainer.GetConnectionString();

    public async Task InitializeAsync()
    {
        await _msSqlContainer.StartAsync();
    }

    public async Task DisposeAsync()
    {
        if (_msSqlContainer != null)
        {
            await _msSqlContainer.StopAsync();
        }
    }
}

// Sample 'DB' base class for tests

public abstract class BaseSqlServerTest(
    SqlServerFixture _sqlServerFixture,
    ITestOutputHelper _testOutputHelper) : IClassFixture<SqlServerFixture>, IAsyncLifetime
{
    protected string ConnectionString { get; private set; }

    public async Task InitializeAsync()
    {
        // Generate a unique database name using the test class name and the test name
        const int maxDatabaseNameLength = 100;// MSSql has a problem with long names.
        var guid = Guid.NewGuid().ToString().Replace("-", "");
        var testName = _testOutputHelper.TestDisplayName();
        var uniqueDbName = $"{testName}_{guid}";
        if (uniqueDbName.Length > maxDatabaseNameLength)
        {
            var truncatedText = uniqueDbName.Substring(0, maxDatabaseNameLength - Guid.NewGuid().ToString().Length);
            uniqueDbName = $"{truncatedText}_{guid}";
        }

        // Update the connection string to use the unique database name.
        // ⭐⭐ This is the magic 🪄🪄🪄 
        ConnectionString = new SqlConnectionStringBuilder(_sqlServerFixture.ConnectionString)
        {
            InitialCatalog = uniqueDbName
        }.ToString();

        _testOutputHelper.WriteLine($"** SQL Server is running. Connection String: {ConnectionString}");
    }

    public Task DisposeAsync() => Task.CompletedTask;
}

So now we can just inherit this into our own xUnit test class:

public class DoSomethingAsyncTests(SqlServerFixture _sqlServerFixture, ITestOutputHelper _testOutputHelper)
    : BaseSqlServerTest(_sqlServerFixture, _testOutputHelper)
{
}

So what I left out of this solution is creating your own DbConnection or EF DbContext. Do that .. then you can Seed your data and you're good to go. You can even add that functionality to the above BaseSqlServerTest class if you want that to happen for each test ran or just set some protected properties which can be accessible in all your concrete classes which inherit from this.

Benefit

Potential downside: each test will seed the data which could be more records than needed. That's up to the developer. Potential downside: each tests has to create all the tables and other schema objects.

I'm assuming that the sum of the positive (minus the negs) will still be faster that the current 'Collection' solution.

Alternatives

-

Would you like to help contributing this enhancement?

Yes

summerson1985 commented 2 days ago

Hi,

In a scenario when you have hundreds of tests and each test needs to run db migration consisting of tens (if not hundreds) of migration files the tests become slow anyway. You also do want to test a scenario as close to real life as possible meaning multiple records created, modified, deleted, etc. in parallel (hello deadlocks). 1 db per test does not appear to be such an improvement then.

We achieve great test isolation using unique user ids - guid - then each user creates its own entity, let's say shopping cart, puts items in the cart, updates, etc. The cart is then selected for assertion by a userId. Hundreds of tests are getting executed in parallel thanks to Xunit.Extensions.Ordering

PureKrome commented 2 days ago

@summerson1985 Great points but also different scenario's. Yep, it's true that it can be a very important test to see how systems work with load. I can't stress hard enough how much mental anquish i've suffered over the years when seeing 'deadlock' errors/situations 😭 💀

But those are a specific test condition which would generally be targeting applications with lots of multicurrent requests. I would have thought that a common starting/entry level test scenario would be to just making sure all the DB queries work. For 1 person. With -some- existing data.

I was hoping this would be considered with their .NET library, especially considering they are doing a heap of awesome work on #1165