Test Isolation — Preventing Test Interference
Ensure integration tests don't interfere with each other: database cleanup strategies, transaction rollback, test data builders, unique identifiers per test, and avoiding shared mutable state.
Why Test Isolation Matters
Non-isolated tests:
Test A creates a patient with MRN "MRN001"
Test B queries for "all patients" — finds 1 patient (from Test A)
Test B asserts: result count == 0 → FAILS
Run order changes (xUnit randomises by default):
Test B runs first → passes
Test A runs first → Test B fails
This is a flaky test — it passes sometimes and fails sometimes.
Flaky tests destroy team confidence in the test suite.
Eventually, "it's probably a flaky test" becomes the default assumption — bugs slip through.
Isolated tests:
Each test sets up its own data, verifies its own state, cleans up.
Tests can run in any order, in parallel, and always produce the same result.Strategy 1 — Transaction Rollback (Fast)
// Each test wraps its DB operations in a transaction that is rolled back on dispose
// Data created by the test never commits — next test starts with clean state
public abstract class TransactionalIntegrationTest : IAsyncDisposable
{
protected readonly PrescriptionsDbContext Db;
private readonly IDbContextTransaction _tx;
protected TransactionalIntegrationTest(SqlServerFixture fixture)
{
var options = new DbContextOptionsBuilder<PrescriptionsDbContext>()
.UseSqlServer(fixture.ConnectionString)
.Options;
Db = new PrescriptionsDbContext(options);
_tx = Db.Database.BeginTransaction();
}
public async ValueTask DisposeAsync()
{
await _tx.RollbackAsync();
await Db.DisposeAsync();
}
}
[Collection("SqlServer")]
public sealed class PrescriptionQueryTests : TransactionalIntegrationTest
{
public PrescriptionQueryTests(SqlServerFixture fixture) : base(fixture) { }
[Fact]
public async Task GetByWard_ReturnsOnlyPrescriptionsForThatWard()
{
// Any data created here is rolled back after the test
var wardId = Guid.NewGuid();
await Db.Prescriptions.AddAsync(BuildPrescription(wardId));
await Db.Prescriptions.AddAsync(BuildPrescription(Guid.NewGuid())); // different ward
await Db.SaveChangesAsync();
var repo = new PrescriptionRepository(Db);
var result = await repo.GetByWardAsync(wardId, CancellationToken.None);
result.Should().HaveCount(1);
}
}Strategy 2 — Unique Per-Test Identifiers
// When transaction rollback isn't feasible (e.g. multiple DbContext instances),
// use unique identifiers per test run to avoid collisions
public abstract class IsolatedIntegrationTest
{
// Unique key for this test run — avoids collision with parallel tests
protected readonly string TestRunId = Guid.NewGuid().ToString("N")[..8];
protected PatientMrn UniqueMrn(string suffix = "") =>
PatientMrn.Of($"MRN-{TestRunId}{suffix}");
protected string UniqueEmail(string prefix = "test") =>
$"{prefix}-{TestRunId}@clinical-test.local";
}
[Fact]
public async Task GetByMrn_ReturnsCorrectPatient()
{
var mrn = UniqueMrn();
// Seed with unique MRN — no collision with other tests
await Db.Patients.AddAsync(Patient.Create(mrn, FullName.Of("Jane", "Doe")).Value);
await Db.SaveChangesAsync();
var result = await _repository.GetByMrnAsync(mrn, CancellationToken.None);
result.Should().NotBeNull();
result!.Mrn.Should().Be(mrn);
}Strategy 3 — Test Data Builders
// Builder pattern for test data — readable, composable, avoids "magic values"
public sealed class PrescriptionBuilder
{
private Guid _patientId = Guid.NewGuid();
private string _medicationName = "Warfarin";
private decimal _doseAmount = 5m;
private string _doseUnit = "mg";
private Guid? _wardId = null;
public PrescriptionBuilder WithPatientId(Guid id) { _patientId = id; return this; }
public PrescriptionBuilder WithMedication(string name) { _medicationName = name; return this; }
public PrescriptionBuilder WithDose(decimal amount, string unit)
{
_doseAmount = amount;
_doseUnit = unit;
return this;
}
public PrescriptionBuilder InWard(Guid wardId) { _wardId = wardId; return this; }
public Prescription Build() =>
Prescription.CreateDraft(
PatientId.Of(_patientId),
MedicationName.Of(_medicationName),
DosageValue.Of(_doseAmount, _doseUnit));
}
// Usage:
var prescription = new PrescriptionBuilder()
.WithMedication("Heparin")
.WithDose(5000m, "IU")
.InWard(wardId)
.Build();
// Clear intent: every relevant property named, defaults for everything else
// Tests are readable without understanding the full Prescription constructorAvoiding Shared Mutable State
// BAD: static shared state between tests
public sealed class PatientServiceTests
{
private static readonly List<Patient> SharedPatients = new(); // SHARED — tests interfere
[Fact]
public void Test1_AddsPatient()
{
SharedPatients.Add(new Patient(...));
SharedPatients.Should().HaveCount(1); // passes if Test1 runs first
}
[Fact]
public void Test2_StartsEmpty()
{
SharedPatients.Should().BeEmpty(); // fails if Test1 ran first
}
}
// GOOD: instance-level state only, re-created per test
public sealed class PatientServiceTests
{
// xUnit creates a new instance per [Fact] — no shared state
private readonly List<Patient> _patients = new();
[Fact]
public void Test1_AddsPatient()
{
_patients.Add(new Patient(...));
_patients.Should().HaveCount(1); // always passes
}
[Fact]
public void Test2_StartsEmpty()
{
_patients.Should().BeEmpty(); // always passes
}
}Respawn — Fast Database Cleanup
// Respawn (NuGet: Respawn) resets only modified tables
// Faster than DROP/CREATE — only deletes rows that were inserted during the test
public sealed class SqlServerFixture : IAsyncLifetime
{
private Respawner _respawner = null!;
public async Task InitializeAsync()
{
await _container.StartAsync();
ConnectionString = _container.GetConnectionString();
// Run migrations once
var db = CreateContext();
await db.Database.MigrateAsync();
// Configure Respawn to reset specific schemas
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
_respawner = await Respawner.CreateAsync(conn, new RespawnerOptions
{
SchemasToInclude = new[] { "prescriptions", "patients", "lab_results" },
DbAdapter = DbAdapter.SqlServer
});
}
public async Task ResetAsync()
{
await using var conn = new SqlConnection(ConnectionString);
await conn.OpenAsync();
await _respawner.ResetAsync(conn);
}
}
// Each test class calls ResetAsync before running:
public async Task InitializeAsync() => await _fixture.ResetAsync();Production issue I've seen: A test suite had 400 integration tests that all ran against a shared SQL Server fixture. Nobody had implemented test isolation. Tests would pass on the developer's machine (running sequentially) and fail in CI (running in parallel). The failure pattern was random — different tests failing on each CI run. The root cause: tests were reading data created by other parallel tests. Adding transaction rollback per test reduced the suite from "500 random failures per day in CI" to zero, without changing a single test assertion.
Key Takeaway
Test isolation means each test sets up its own data, verifies its own state, and cannot be affected by other tests. Use transaction rollback (fast, no cleanup code), unique per-test identifiers (when rollback isn't feasible), or Respawn (bulk table reset between tests). Never use static shared state in test classes — xUnit creates a new instance per test. Test builders make setup readable and guard against changes in constructor signatures affecting dozens of tests.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.