Skip to content

Recipe Xunit Per Test Isolation

Mark Lauter edited this page Jun 1, 2026 · 2 revisions

Recipe: xUnit per-test isolation

You want each xUnit test (or test class) to see a fresh, empty database, with no leakage from any other test in the run. DynamoDbLite has no built-in cleanup — isolation comes from naming. A unique Data Source keys SQLite's shared cache uniquely, so two clients with different names see independent in-memory databases even in the same process.

The interesting wrinkle is SqliteConnection.ClearAllPools(). The static call clears the connection pool for every database in the process — call it from one test's teardown and you tear down the connections every other concurrent test depends on. The fix is SqliteConnection.ClearPool(connection) for the specific connection string only.

Pattern 1: per-test instantiation (simplest)

Every [Fact] gets a fresh client. Implement IDisposable on the test class so xUnit disposes between tests:

using DynamoDbLite;

public sealed class UserRepositoryTests : IDisposable
{
    private readonly DynamoDbClient client;

    public UserRepositoryTests()
    {
        client = new DynamoDbClient(new DynamoDbLiteOptions(
            $"Data Source=test_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"));
    }

    public void Dispose() => client.Dispose();

    [Fact]
    public async Task Test_Sees_Empty_Database()
    {
        // ...
    }
}

xUnit instantiates the test class once per [Fact], so each test gets its own GUID-suffixed Data Source — independent from every other.

Trade-off: table creation runs on every test. For a few tables that's negligible; for many tables it's wasteful.

Pattern 2: shared per class, isolated across classes (IClassFixture)

When the table set is large enough that recreating it per test costs measurably, share the client across the class via IClassFixture<T>. Each test class still gets its own database — only tests within the class share state, and they should be designed around that:

public sealed class UserRepositoryFixture : IDisposable
{
    public DynamoDbClient Client { get; }

    public UserRepositoryFixture()
    {
        Client = new DynamoDbClient(new DynamoDbLiteOptions(
            $"Data Source=fixture_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"));

        // Create tables once for all tests in the class
        CreateTables(Client).GetAwaiter().GetResult();
    }

    public void Dispose() => Client.Dispose();

    private static async Task CreateTables(DynamoDbClient client)
    {
        // ... CreateTableAsync calls
    }
}

public sealed class UserRepositoryTests : IClassFixture<UserRepositoryFixture>
{
    private readonly UserRepositoryFixture fx;

    public UserRepositoryTests(UserRepositoryFixture fx) => this.fx = fx;

    [Fact]
    public async Task Test_Sees_Class_Scoped_Database() { /* ... */ }
}

When tests within a class would interfere with each other, use distinct partition keys per test — var key = $"{nameof(Test_X)}-{Guid.NewGuid():N}"; — rather than dropping the fixture pattern.

Pattern 3: file-based with cleanup (xUnit v3 + IAsyncLifetime)

For tests that need persistence across the test run (long-running scenarios, manual inspection of the .db file post-run), back the client with a file. Cleanup needs to dispose the client before deleting the file — SQLite holds an exclusive handle on the file until the connection is disposed, and on Windows File.Delete fails with file-in-use otherwise.

The DynamoDbLite test suite ships a helper at tests/DynamoDbLite.Tests/Fixtures/FileBasedTestHelper.cs that encapsulates the safe pattern:

using Microsoft.Data.Sqlite;
using DynamoDbLite;

public sealed class FileBasedFixture : IAsyncLifetime
{
    public DynamoDbClient Client { get; private set; } = null!;
    public string DbPath { get; private set; } = null!;

    public ValueTask InitializeAsync()
    {
        DbPath = Path.Combine(Path.GetTempPath(), $"dynamo_test_{Guid.NewGuid():N}.db");
        Client = new DynamoDbClient(new DynamoDbLiteOptions($"Data Source={DbPath}"));
        return ValueTask.CompletedTask;
    }

    public ValueTask DisposeAsync()
    {
        Client.Dispose();

        // Clear only the pool for this connection — never call SqliteConnection.ClearAllPools()
        // in test cleanup; the static method nukes pools for every database in the process and
        // breaks any concurrently-running test fixture.
        var builder = new SqliteConnectionStringBuilder($"Data Source={DbPath}")
        {
            ForeignKeys = true,
        };
        using (var conn = new SqliteConnection(builder.ToString()))
            SqliteConnection.ClearPool(conn);

        TryDelete(DbPath);
        TryDelete(DbPath + "-wal");
        TryDelete(DbPath + "-shm");
        return ValueTask.CompletedTask;
    }

    private static void TryDelete(string path)
    {
        try
        {
            if (File.Exists(path))
                File.Delete(path);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // best-effort cleanup; the temp file gets cleaned by the OS eventually
        }
    }
}

The .wal and .shm companion files exist only when WAL is enabled on the store (UseWriteAheadLog = true, or WithWriteAheadLog() on the builder). The cleanup above deletes them defensively so the fixture works whether or not WAL is on; if your fixture leaves WAL at its default (off), the TryDelete calls for .wal and .shm are no-ops.

Picking a pattern

  • One small set of tables, fast tests → Pattern 1 (per-test instantiation). Simplest setup, no shared state to reason about.
  • Larger table set, dozens of tests per class → Pattern 2 (IClassFixture). Pay table creation once per class.
  • Need persistence across the run, or want to inspect the .db post-run → Pattern 3 (file-based with cleanup). Slower; only worth it when a real file is the requirement.

In all three, the GUID-suffixed Data Source is what guarantees isolation across classes. Without it, the wiki's old fixed Data Source=DynamoDbLite literal would silently share databases across every test class in the process.

Next steps

Clone this wiki locally