| name | integration-testing-multi-tenant |
| description | Establishes patterns for creating test data in Entity Framework Core integration tests for multi-tenant applications. Use when writing integration tests that involve tenant isolation, foreign key relationships, or database context management with Testcontainers. |
Multi-Tenant Integration Testing Patterns
Patterns for creating test data in EF Core integration tests with multi-tenant architecture and proper foreign key relationship handling.
Problem Statement
Integration tests in multi-tenant applications fail with foreign key constraint violations when test data creation methods span multiple database contexts. This occurs because Entity Framework Core contexts are independent units of work - entities saved in one context are not visible to another context until committed and reloaded.
Core Principle: Single-Context Entity Graphs
Rule: Create complete entity relationship graphs within a single DbContext instance to ensure foreign key relationships are valid when SaveChangesAsync() is called.
Entity Dependency Chain
In a multi-tenant architecture with the following relationships:
Tenant (root)
├── Venue (FK → Tenant)
├── Act (FK → Tenant)
└── Show (FK → Venue, FK → Act)
All related entities must be created in the same context.
Patterns
❌ Anti-Pattern: Context Mismatch
Problem: Tenant created in one context, child entities in another
using (var context = _fixture.CreateDbContext(_connectionString, _tenantId))
{
var tenant = await _fixture.CreateTestTenantAsync(_connectionString, _tenantId);
var venue = new Venue
{
TenantId = tenant.Id,
Name = "Test Venue"
};
context.Venues.Add(venue);
await context.SaveChangesAsync();
}
Why it fails:
CreateTestTenantAsync() creates a new DbContext, saves the tenant, and disposes
- The calling
context has no knowledge of that tenant entity
- When trying to insert
Venue, SQL Server validates the FK and finds no matching TenantId
✅ Correct Pattern: Inline Entity Creation with Navigation Properties
Solution: Create all entities in the same context using navigation properties
using var setupContext = _fixture.CreateDbContext(_connectionString, tenantId);
var tenant = new Tenant
{
TenantIdentifier = $"test-tenant-{tenantId}-{uniqueId}",
Name = $"Test Tenant {tenantId}",
Slug = $"test-tenant-{tenantId}",
IsActive = true
};
setupContext.Tenants.Add(tenant);
await setupContext.SaveChangesAsync();
var venue = new Venue
{
Tenant = tenant,
VenueGuid = Guid.NewGuid(),
Name = "Test Venue",
Address = "123 Test St",
SeatingCapacity = 1000
};
setupContext.Venues.Add(venue);
await setupContext.SaveChangesAsync();
var act = new Act
{
Tenant = tenant,
ActGuid = Guid.NewGuid(),
Name = "Test Act"
};
setupContext.Acts.Add(act);
await setupContext.SaveChangesAsync();
var show = new Show(act, venue)
{
ShowGuid = Guid.NewGuid(),
TicketCount = 500,
StartTime = DateTimeOffset.UtcNow.AddDays(30)
};
setupContext.Shows.Add(show);
await setupContext.SaveChangesAsync();
return show.ShowGuid;
Note: The TenantId property has a private setter in MultiTenantEntity to enforce this pattern at compile time.
Test Helper Method Design
Bad: Helper Creates Own Context
public async Task<Tenant> CreateTestTenantAsync(string connectionString, int tenantId)
{
using var context = CreateDbContext(connectionString, null);
var tenant = new Tenant { ... };
context.Tenants.Add(tenant);
await context.SaveChangesAsync();
return tenant;
}
Good: Helper Uses Caller's Context
public static Tenant CreateTenant(int seedId)
{
return new Tenant
{
TenantIdentifier = $"test-tenant-{seedId}-{Guid.NewGuid().ToString()[..8]}",
Name = $"Test Tenant {seedId}",
Slug = $"test-tenant-{seedId}",
IsActive = true
};
}
using var context = _fixture.CreateDbContext(_connectionString, null);
var tenant = DatabaseHelpers.CreateTenant(_tenantId);
context.Tenants.Add(tenant);
await context.SaveChangesAsync();
Multi-Tenant Test Context Management
Tenant ID vs Tenant Entity ID
Important distinction:
- Test Tenant ID: Random integer (1000-9999) for test isolation, generated by
GenerateRandomTenantId()
- Database Tenant ID: Auto-generated primary key (
tenant.Id) after SaveChangesAsync()
✅ PREFERRED: Use navigation property - EF Core handles the FK automatically:
var testTenantId = _fixture.GenerateRandomTenantId();
using var context = _fixture.CreateDbContext(_connectionString, testTenantId);
var tenant = new Tenant { ... };
context.Tenants.Add(tenant);
await context.SaveChangesAsync();
var venue = new Venue
{
Tenant = tenant,
Name = "Venue"
};
var badVenue = new Venue
{
TenantId = tenant.Id,
Name = "Bad Venue"
};
var worseVenue = new Venue
{
Tenant = new Tenant { Id = testTenantId },
Name = "Worse Venue"
};
Why navigation properties are preferred:
- EF Core automatically maintains FK consistency
- More expressive of domain relationships
- Enforced at compile time (TenantId has private setter)
- Prevents accidental use of wrong ID values
Context Tenant Filtering
When creating a DbContext with a specific tenant ID, EF Core's global query filters apply:
using var tenantContext = _fixture.CreateDbContext(_connectionString, _testTenantId);
var shows = await tenantContext.Shows.ToListAsync();
using var adminContext = _fixture.CreateDbContext(_connectionString, null);
var allShows = await adminContext.Shows.ToListAsync();
Pattern: Create entities with null tenant context, verify with specific tenant context:
using (var setupContext = _fixture.CreateDbContext(_connectionString, null))
{
var tenant = CreateTenant();
setupContext.Tenants.Add(tenant);
await setupContext.SaveChangesAsync();
var venue = new Venue { Tenant = tenant, ... };
setupContext.Venues.Add(venue);
await setupContext.SaveChangesAsync();
}
using var testContext = _fixture.CreateDbContext(_connectionString, _testTenantId);
var service = new VenueService(testContext);
var result = await service.GetByGuidAsync(venueGuid);
Thread-Safe Database Migrations
When using Testcontainers with parallel test execution:
private static readonly object _migrationLock = new object();
public GloboTicketDbContext CreateDbContext(string connectionString, int? tenantId)
{
var options = new DbContextOptionsBuilder<GloboTicketDbContext>()
.UseSqlServer(connectionString, sqlOptions => sqlOptions.UseNetTopologySuite())
.Options;
var tenantContext = new TestTenantContext(tenantId);
var context = new GloboTicketDbContext(options, tenantContext);
lock (_migrationLock)
{
context.Database.Migrate();
}
return context;
}
Note: Database.Migrate() is preferred over EnsureCreated() because it applies actual EF Core migrations, matching production behavior. EnsureCreated() bypasses migrations and directly creates the schema.
Common Test Scenarios
Cross-Tenant Isolation Test
[Fact]
public async Task GetShow_FromOtherTenant_Returns404()
{
var tenantAId = _fixture.GenerateRandomTenantId();
var tenantBId = _fixture.GenerateRandomTenantId();
Guid showGuid;
using (var contextA = _fixture.CreateDbContext(_connectionString, null))
{
var tenantA = CreateTenant(tenantAId);
contextA.Tenants.Add(tenantA);
await contextA.SaveChangesAsync();
var venue = new Venue { Tenant = tenantA, ... };
contextA.Venues.Add(venue);
var act = new Act { Tenant = tenantA, ... };
contextA.Acts.Add(act);
await contextA.SaveChangesAsync();
var show = new Show(act, venue) { ... };
contextA.Shows.Add(show);
await contextA.SaveChangesAsync();
showGuid = show.ShowGuid;
}
using var contextB = _fixture.CreateDbContext(_connectionString, tenantBId);
var service = new ShowService(contextB);
var result = await service.GetByGuidAsync(showGuid);
result.Should().BeNull();
}
Checklist for Integration Test Methods
When writing integration test helper methods:
References