Overview
Integration testing is critical for verifying that your application components work together correctly. It's especially important for API endpoints, database operations, and service interactions. Understanding integration testing strategies is essential for mid-level to senior positions.
Core Concepts
WebApplicationFactory Basics
// Custom WebApplicationFactory for tests
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Remove real database
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
// Add in-memory database
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb");
});
// Seed test data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
SeedTestData(context);
});
}
private static void SeedTestData(AppDbContext context)
{
context.Users.AddRange(
new User { Id = 1, Name = "John Doe", Email = "john@test.com" },
new User { Id = 2, Name = "Jane Smith", Email = "jane@test.com" }
);
context.SaveChanges();
}
}
// Use in tests
public class ApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public ApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetUsers_ReturnsSeededUsers()
{
var response = await _client.GetAsync("/api/users");
response.EnsureSuccessStatusCode();
var users = await response.Content.ReadFromJsonAsync<List<User>>();
Assert.Equal(2, users.Count);
}
}
Database Strategies
// Strategy 1: In-Memory Database (Fast, but limited)
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// Pros: Very fast, no cleanup needed
// Cons: Doesn't test real DB behavior, missing constraints
// Strategy 2: SQLite In-Memory (Better compatibility)
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("DataSource=:memory:"));
// Pros: Fast, closer to real DB, supports more features
// Cons: Still not identical to production DB
// Strategy 3: Test Containers (Real database)
var container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
await container.StartAsync();
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(container.GetConnectionString()));
// Pros: Identical to production, tests real behavior
// Cons: Slower, requires Docker
Per-Test Database Isolation
public class DatabaseFixture : IAsyncLifetime
{
public AppDbContext Context { get; private set; }
private SqliteConnection _connection;
public async Task InitializeAsync()
{
// Create and open connection (keeps in-memory DB alive)
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
Context = new AppDbContext(options);
await Context.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await Context.DisposeAsync();
await _connection.DisposeAsync();
}
}
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public UserRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task AddUser_ValidUser_SavesToDatabase()
{
// Arrange
var repository = new UserRepository(_fixture.Context);
var user = new User { Name = "Test", Email = "test@example.com" };
// Act
await repository.AddAsync(user);
// Assert
var saved = await _fixture.Context.Users.FindAsync(user.Id);
Assert.NotNull(saved);
Assert.Equal("Test", saved.Name);
}
}
Authentication in Integration Tests
// Custom WebApplicationFactory with test authentication
public class AuthenticatedWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Add test authentication
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", options => { });
});
builder.Configure(app =>
{
app.UseAuthentication();
app.UseAuthorization();
});
}
}
// Test authentication handler
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Create test claims
var claims = new[]
{
new Claim(ClaimTypes.Name, "TestUser"),
new Claim(ClaimTypes.NameIdentifier, "123"),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
// Use in tests
[Fact]
public async Task GetProtectedResource_WithAuth_ReturnsData()
{
var client = _factory.CreateClient();
// Request will automatically be authenticated
var response = await client.GetAsync("/api/protected");
response.EnsureSuccessStatusCode();
}
Bad vs Good Examples
Bad: Shared Mutable State
// Bad - tests share database and can interfere with each other
public class BadIntegrationTests
{
private static AppDbContext _sharedContext;
public BadIntegrationTests()
{
if (_sharedContext == null)
{
_sharedContext = CreateContext();
}
}
[Fact]
public void Test1_AddsUser()
{
_sharedContext.Users.Add(new User { Id = 1, Name = "Test" });
_sharedContext.SaveChanges();
Assert.Equal(1, _sharedContext.Users.Count());
}
[Fact]
public void Test2_CountsUsers()
{
// Fails if Test1 runs first!
Assert.Equal(0, _sharedContext.Users.Count());
}
}
Good: Isolated Test Data
// Good - each test gets fresh database
public class GoodIntegrationTests : IClassFixture<DatabaseFixture>
{
private readonly AppDbContext _context;
public GoodIntegrationTests(DatabaseFixture fixture)
{
_context = fixture.Context;
// Clean database before each test
_context.Database.EnsureDeleted();
_context.Database.EnsureCreated();
}
[Fact]
public async Task Test1_AddsUser()
{
_context.Users.Add(new User { Name = "Test" });
await _context.SaveChangesAsync();
Assert.Equal(1, await _context.Users.CountAsync());
}
[Fact]
public async Task Test2_CountsUsers()
{
// Always starts with empty database
Assert.Equal(0, await _context.Users.CountAsync());
}
}
Bad: Testing Through Multiple Layers
// Bad - too much setup, testing too many layers
[Fact]
public async Task ComplexScenario_BadApproach()
{
var client = _factory.CreateClient();
// Create user
await client.PostAsJsonAsync("/api/users", new { name = "Test" });
// Create order
await client.PostAsJsonAsync("/api/orders", new { userId = 1, items = "..." });
// Create payment
await client.PostAsJsonAsync("/api/payments", new { orderId = 1 });
// Get invoice
var response = await client.GetAsync("/api/invoices/1");
// Complex assertions...
}
Good: Focused Integration Tests
// Good - test one integration point at a time
[Fact]
public async Task CreateOrder_ValidData_ReturnsCreatedOrder()
{
// Arrange - seed only what's needed
await SeedTestUser();
var client = _factory.CreateClient();
var order = new { userId = 1, items = new[] { new { productId = 1, qty = 2 } } };
// Act
var response = await client.PostAsJsonAsync("/api/orders", order);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<Order>();
Assert.Equal(1, created.UserId);
}
[Fact]
public async Task GetInvoice_ExistingOrder_ReturnsInvoice()
{
// Arrange - seed order directly in database
await SeedTestOrder();
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/invoices/1");
// Assert
response.EnsureSuccessStatusCode();
var invoice = await response.Content.ReadFromJsonAsync<Invoice>();
Assert.NotNull(invoice);
}
Interview Tips
Tip 1: Explain the trade-offs between in-memory databases (fast but not realistic) and test containers (slower but realistic).
Tip 2: Know how WebApplicationFactory works - it creates an in-memory TestServer, avoiding actual network calls.
Tip 3: Discuss test data management - seeding strategies, isolation, and cleanup are critical for reliable tests.
Common Interview Questions
-
What's the difference between unit tests and integration tests?
- Unit tests verify single components in isolation with mocked dependencies. Integration tests verify multiple components working together with real dependencies. Integration tests are slower but catch integration issues.
-
How does WebApplicationFactory work?
- Creates in-memory TestServer that hosts your ASP.NET Core app. Uses TestServer middleware pipeline. HttpClient makes requests without network I/O. Allows customization of services via ConfigureWebHost.
-
When should you use in-memory databases vs test containers?
- In-memory: Fast feedback, simple scenarios, CI/CD pipelines. Test containers: Testing DB-specific features (constraints, stored procedures), production-like behavior, critical data operations.
-
How do you handle test data in integration tests?
- Strategies: Seed in WebApplicationFactory setup, use fixtures for shared data, create per-test data in test method, use test data builders. Clean up after tests or use fresh database per test.
-
What are the challenges of integration testing?
- Slow execution, flaky tests (timing, external dependencies), test data management, environment setup, parallel execution conflicts, debugging failures.
-
How do you test authenticated endpoints?
- Create custom authentication handler for tests, use TestAuthHandler that returns test claims, inject via WebApplicationFactory, or use real auth with test credentials.
-
What's the difference between TestServer and real hosting?
- TestServer runs in-process, no real HTTP, no network latency, can access internal state. Real hosting uses Kestrel, actual network, separate process. TestServer is faster and more controllable.