Skip to main content

.NET Integration Testing

Introduction

Integration testing is a crucial phase in software testing where individual components or modules are combined and tested as a group. Unlike unit tests that focus on isolated pieces of code, integration tests verify that different parts of your application work together correctly.

In the .NET ecosystem, integration testing helps ensure that your application's components—such as databases, file systems, APIs, and services—interact properly. This is especially important in modern applications that often depend on multiple systems working in harmony.

Why Integration Testing Matters

While unit tests are valuable for verifying individual components, they don't capture issues that arise when these components interact. Integration tests help identify:

  • Interface defects between components
  • Communication problems between services
  • Issues with external dependencies like databases or APIs
  • Configuration problems in your application environment

Setting Up Integration Tests in .NET

Let's start by setting up an integration testing project for a typical .NET application.

Creating a Test Project

csharp
// Assuming you have a solution with a main project called "MyWebApp"
// Create a new test project named "MyWebApp.IntegrationTests"

Project Structure

A common structure for integration tests in a .NET solution:

MySolution/
├── src/
│ └── MyWebApp/
└── tests/
├── MyWebApp.UnitTests/
└── MyWebApp.IntegrationTests/

Required NuGet Packages

For ASP.NET Core applications, you'll typically need:

xml
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.5" />
</ItemGroup>

Testing Web APIs with WebApplicationFactory

ASP.NET Core provides the WebApplicationFactory class, which makes it easy to set up integration tests for web applications.

Basic API Test Example

csharp
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

namespace MyWebApp.IntegrationTests
{
public class BasicApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;

public BasicApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}

[Fact]
public async Task GetProducts_ReturnsSuccessStatusCode()
{
// Arrange
var client = _factory.CreateClient();

// Act
var response = await client.GetAsync("/api/products");

// Assert
response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
}

What's Happening:

  1. We create a test class that uses WebApplicationFactory<Program> (for .NET 6+ projects)
  2. The factory creates a test server hosting your application
  3. We use an HTTP client to make requests to our API endpoints
  4. We can test the responses to ensure they behave as expected

Testing with In-Memory Databases

For database integration tests, the Entity Framework Core in-memory provider is helpful:

csharp
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Linq;
using Xunit;

namespace MyWebApp.IntegrationTests
{
public class DatabaseTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;

public DatabaseTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remove the app's ApplicationDbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));

if (descriptor != null)
{
services.Remove(descriptor);
}

// Add ApplicationDbContext using an in-memory database for testing
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseInMemoryDatabase("InMemoryDbForTesting");
});
});
});
}

[Fact]
public async Task CreateProduct_AddsToDatabase()
{
// Arrange
var client = _factory.CreateClient();
var newProduct = new { Name = "Test Product", Price = 19.99 };

// Act
var response = await client.PostAsJsonAsync("/api/products", newProduct);

// Assert
response.EnsureSuccessStatusCode();

// Verify the product was added to the database
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var product = await dbContext.Products.FirstOrDefaultAsync(p => p.Name == "Test Product");

Assert.NotNull(product);
Assert.Equal(19.99, product.Price);
}
}
}
}

This example:

  1. Configures a custom WebApplicationFactory that replaces the real database with an in-memory one
  2. Tests that our API endpoint properly adds a product to the database
  3. Verifies the result by checking the database content directly

Testing with Real Dependencies

Sometimes you need to test with actual dependencies like databases. For these scenarios, you can use Docker containers.

Using TestContainers for .NET

Testcontainers for .NET is a library that helps you run Docker containers for integration tests:

csharp
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Threading.Tasks;
using Xunit;

namespace MyWebApp.IntegrationTests
{
public class PostgresDatabaseTests : IAsyncLifetime
{
private readonly TestcontainersContainer _dbContainer;
private WebApplicationFactory<Program> _factory;

public PostgresDatabaseTests()
{
// Create a PostgreSQL container
_dbContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("postgres:13")
.WithEnvironment("POSTGRES_USER", "test")
.WithEnvironment("POSTGRES_PASSWORD", "test")
.WithEnvironment("POSTGRES_DB", "testdb")
.WithPortBinding(5432, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432))
.Build();
}

public async Task InitializeAsync()
{
// Start the container before tests run
await _dbContainer.StartAsync();

string connectionString = $"Host=localhost;Port={_dbContainer.GetMappedPublicPort(5432)};Database=testdb;Username=test;Password=test";

// Configure the factory with the real PostgreSQL database
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));

if (descriptor != null)
{
services.Remove(descriptor);
}

services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseNpgsql(connectionString);
});
});
});
}

public async Task DisposeAsync()
{
// Clean up after tests
await _dbContainer.StopAsync();
_factory?.Dispose();
}

[Fact]
public async Task DatabaseOperations_WorkWithRealDatabase()
{
// Arrange
var client = _factory.CreateClient();

// Act & Assert - test your application with a real PostgreSQL database
// ...
}
}
}

This approach:

  1. Spins up a real PostgreSQL database in a Docker container
  2. Configures your application to use this database for tests
  3. Cleans up after tests are complete

Testing Communication Between Microservices

In microservice architectures, testing service-to-service communication is vital:

csharp
[Fact]
public async Task OrderService_CommunicatesWithPaymentService()
{
// Arrange
var client = _factory.CreateClient();
var order = new
{
CustomerId = "customer-123",
Items = new[]
{
new { ProductId = "product-1", Quantity = 2 }
},
TotalAmount = 39.98
};

// Act
var response = await client.PostAsJsonAsync("/api/orders", order);

// Assert
response.EnsureSuccessStatusCode();
var orderResult = await response.Content.ReadFromJsonAsync<OrderResult>();

// Verify that a payment was initiated
using (var scope = _factory.Services.CreateScope())
{
var paymentRepository = scope.ServiceProvider.GetRequiredService<IPaymentRepository>();
var payment = await paymentRepository.GetByOrderIdAsync(orderResult.OrderId);

Assert.NotNull(payment);
Assert.Equal(39.98, payment.Amount);
Assert.Equal("Pending", payment.Status);
}
}

Mocking External Services

Sometimes you need to mock external services for testing:

csharp
public class ExternalServiceTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;

public ExternalServiceTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace the real service with a mock
services.AddScoped<IWeatherService, MockWeatherService>();
});
});
}

[Fact]
public async Task WeatherForecast_ReturnsExpectedResults()
{
// Arrange
var client = _factory.CreateClient();

// Act
var response = await client.GetAsync("/api/weather");

// Assert
response.EnsureSuccessStatusCode();
var forecasts = await response.Content.ReadFromJsonAsync<WeatherForecast[]>();

Assert.NotNull(forecasts);
Assert.Equal(5, forecasts.Length);
// Further assertions based on the mock service's expected behavior
}

// Mock implementation of the external service
private class MockWeatherService : IWeatherService
{
public Task<WeatherForecast[]> GetForecastAsync()
{
// Return predictable test data
return Task.FromResult(new[]
{
new WeatherForecast { Date = DateTime.Now, Temperature = 22, Summary = "Mild" },
// Additional mock forecasts...
});
}
}
}

Best Practices for Integration Testing

1. Keep Tests Independent

Each test should run independently of others. Don't create tests that depend on the state left by previous tests.

csharp
[Fact]
public async Task GetProducts_AfterAddingProduct_ReturnsProducts()
{
// Arrange - Reset the database or use a unique database for this test
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await dbContext.Database.EnsureDeletedAsync();
await dbContext.Database.EnsureCreatedAsync();

// Add test data
dbContext.Products.Add(new Product { Name = "Test Product", Price = 19.99m });
await dbContext.SaveChangesAsync();
}

// Act
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/products");

// Assert
response.EnsureSuccessStatusCode();
var products = await response.Content.ReadFromJsonAsync<List<Product>>();
Assert.Single(products);
}

2. Use Test Categories

Organize tests by type to run them separately:

csharp
[Fact]
[Trait("Category", "Integration")]
public async Task Database_Integration_Test()
{
// Test code
}

[Fact]
[Trait("Category", "API")]
public async Task API_Integration_Test()
{
// Test code
}

3. Handle Authentication

If your API requires authentication, set up test users or mock the authentication:

csharp
[Fact]
public async Task SecureEndpoint_WithValidToken_ReturnsData()
{
// Arrange
var client = _factory.CreateClient();

// Add authentication token
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
GenerateTestToken());

// Act
var response = await client.GetAsync("/api/secure-data");

// Assert
response.EnsureSuccessStatusCode();
}

private string GenerateTestToken()
{
// Generate a test JWT token
// (Implementation depends on your authentication setup)
}

Common Integration Testing Scenarios

1. Testing API Endpoints

csharp
[Fact]
public async Task GetProducts_WithFiltering_ReturnsFilteredProducts()
{
// Arrange
var client = _factory.CreateClient();

// Seed the database with test data
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
dbContext.Products.AddRange(
new Product { Name = "Laptop", Category = "Electronics", Price = 999.99m },
new Product { Name = "Headphones", Category = "Electronics", Price = 99.99m },
new Product { Name = "Book", Category = "Books", Price = 19.99m }
);
await dbContext.SaveChangesAsync();
}

// Act
var response = await client.GetAsync("/api/products?category=Electronics");

// Assert
response.EnsureSuccessStatusCode();
var products = await response.Content.ReadFromJsonAsync<List<Product>>();

Assert.Equal(2, products.Count);
Assert.All(products, p => Assert.Equal("Electronics", p.Category));
}

2. Testing Error Handling

csharp
[Fact]
public async Task GetProduct_WithInvalidId_Returns404()
{
// Arrange
var client = _factory.CreateClient();

// Act
var response = await client.GetAsync("/api/products/999"); // Non-existent ID

// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

3. Testing Database Transactions

csharp
[Fact]
public async Task PlaceOrder_WithInsufficientInventory_RollsBackTransaction()
{
// Arrange
var client = _factory.CreateClient();

// Seed the database
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
dbContext.Products.Add(new Product
{
Id = 1,
Name = "Limited Product",
Price = 99.99m,
InventoryCount = 1 // Only one in stock
});
await dbContext.SaveChangesAsync();
}

// Act - Try to order 2 of the product
var orderRequest = new { ProductId = 1, Quantity = 2 };
var response = await client.PostAsJsonAsync("/api/orders", orderRequest);

// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

// Verify inventory wasn't changed
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var product = await dbContext.Products.FindAsync(1);
Assert.Equal(1, product.InventoryCount); // Still has 1 in stock
}
}

Summary

Integration testing is an essential practice in .NET development that helps ensure your application's components work together as expected. By testing interactions between services, databases, and external systems, you can catch issues that unit tests might miss.

Key takeaways:

  • Integration tests verify that different parts of your application work together correctly
  • The WebApplicationFactory class makes testing ASP.NET Core applications straightforward
  • You can test with in-memory databases or real databases using containers
  • Proper test independence and setup/teardown are crucial for reliable tests
  • Integration tests complement unit tests but don't replace them

Additional Resources

Exercises

  1. Create an integration test for a simple API that creates and retrieves users
  2. Write a test that verifies database constraints are enforced (e.g., unique email addresses)
  3. Test a workflow that spans multiple API endpoints (e.g., create a cart, add items, checkout)
  4. Implement a test that verifies error handling when external services fail
  5. Create a test that verifies authentication and authorization rules for your API endpoints


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)