-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.
- 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.
- Recipe: DynamoDBContext for tests — POCO mapping with the same isolation patterns
-
Recipe: ASP.NET Core integration test fixture —
WebApplicationFactorywith a DynamoDbLite-backedIAmazonDynamoDB - FAQ — the foot-gun this recipe defends against
Repo · NuGet · API Parity
Getting started
Reference
- Table Operations
- Item Operations
- Query and Scan
- Batch Operations
- Transactions
- Secondary Indexes
- TTL
- Tags and Admin
- DI and Configuration
- Concurrency
- Performance
- API Parity
- FAQ
Recipes
- DynamoDBContext for tests
- xUnit per-test isolation
- ASP.NET Core integration test fixture
- Migrating tests off DynamoDB Local
Internals