# Stop fighting your integration tests: two patterns for tests that work
Table of Contents
Let me guess where you are with integration tests right now. You tried building them, spent two days fighting database setup and random failures, and now they either can’t run in parallel (because they corrupt each other’s data) or they’re so slow you never run them.
It’s not about writing perfect steps (they should be focused, reusable, and have clear names though 😉). It’s solving two problems that kill most integration test attempts: how steps communicate and how tests run in parallel without interfering with each other.
The storage problem: how do steps talk to each other?
Your CreateUser step generates a user ID. Your BookVenue step needs that same user ID. How do you pass data between independent steps without coupling them?
Most people try this:
[Fact]public async Task Test_Everything_In_One_Giant_Method(){ var venue = await CreateVenue("Conference Room"); var booking = await BookVenue(venue.Id, user.Id, DateTime.Now);
// 50 more lines of coupled code...}This is where a scenario context comes in. It’s your shared storage that lets steps stay independent while sharing data within your test scenario:
public class ScenarioContext{ private readonly Dictionary<string, object> _storage = new();
public void Store(string key, object value) => _storage[key] = value; public T Get<T>(string key) => (T)_storage[key];}Now your steps can communicate without knowing about each other:
// CreateUser step stores the resultcontext.Store("UserId", user.Id);
// BookVenue step retrieves itvar userId = context.Get<string>("UserId");Each step stays focused on its single responsibility. Each step can be reused across different test scenarios. Each step can be tested in isolation. But they can still share the data they need.
First problem solved. Now your steps can share data without knowing about each other. But your tests still run sequentially…
The parallel execution challenge: achieving test isolation
Some options are:
- Keep the sequential execution (tests take forever)
- Database transactions with rollback (possibly inconsistent)
- Unique GUIDs everywhere (still conflicts on business rules)
Here’s what actually works for multi-tenant apps: each test gets its own tenant.
Give every test its own tenant + API key. Now they can run in parallel without stepping on each other’s data. The trick? Use AsyncLocal to flow the tenant context automatically through every HTTP call in that test thread:
public static class ApiKeyContext{ private static readonly AsyncLocal<string> _apikey = new();
public static string? Current { get => _apikey.Value; set => _apikey.Value = value; }}Your HTTP client picks it up automatically:
services .AddHttpClient<EventsHttpClient>() .ConfigureHttpClient(client => { if (ApiKeyContext.Current is not null) { client.DefaultRequestHeaders.Add("X-Api-Key", ApiKeyContext.Current); } });No manual header management. No test interference. True parallel execution.
Tenant-isolated parallel tests
public class BookVenueTests(ServiceFixture serviceFixture, ITestOutputHelper testOutputHelper){ private readonly ScenarioContext _scenarioContext = new ScenarioContext(serviceFixture, testOutputHelper) .Background( new SeedOrganization().WithName("TechCorp Solutions"), new UseApiKey(), // Sets tenant context for this test new SeedVenue().WithName("Main Conference Room"), );
[Fact] public Task WhenBookingAvailableVenue_Then_EventIsCreatedWithBooking() { return new Scenario(_scenarioContext) .Act(new BookVenue { VenueName = "Main Conference Room", Date = "2025-10-15" }) .Assert(new RequestSucceeded(), new VenueIsBooked { Date = "2025-10-15" }); }
[Fact] public Task WhenBookingAlreadyBookedVenue_Then_ErrorIsReturned() { return new Scenario(_scenarioContext) .Arrange(new SeedBooking { VenueName = "Main Conference Room", Date = "2025-10-15" }) .Act(new BookVenue { VenueName = "Main Conference Room", Date = "2025-10-15" }) .Assert(new RequestFailed().WithError("VENUE-ALREADY-BOOKED")); }}The background is set up once on the ScenarioContext and applies to both tests - it seeds the organization (the tenant), sets the API key context, and creates the basic venue and user data. Each individual test then adds its specific arrange steps and actions.
No test cleanup needed either - each tenant is isolated, so dirty data from failed tests doesn’t affect other tests. And your tenant isolation gets tested too.
What if your app doesn’t have tenants?
Use your natural boundaries. Users? Organizations? Projects? Accounts? There’s usually something that already provides data ownership - each test operates within its own boundary context.
Keep your components reasonably sized. You can run different test suites in parallel (booking tests, user management tests, reporting tests) while each suite runs its own tests sequentially. This trades some local development speed for simpler test management, but your CI pipeline still gets faster overall execution.
Efficient database resets for legacy apps. If your application truly has no natural isolation boundaries, tools like Respawn can efficiently reset your database between tests. It’s particularly useful for legacy applications where adding tenant isolation would require significant architectural changes.
The result: fast, reliable tests
This is the difference between tests that fight your development process and tests that support it. You go from “I can’t run these locally because they take forever and break randomly” to “I run the full suite every time I make a change because it’s fast and reliable.”