Testing microservices is fundamentally different from testing a monolith. The distributed nature of the system means you need multiple testing strategies working together to build confidence. Here is the approach I have refined over several large-scale microservice deployments.
The Testing Diamond
Forget the traditional testing pyramid for microservices. I advocate for a testing diamond: a thin layer of unit tests for pure business logic, a thick layer of integration tests, contract tests for service boundaries, and a thin layer of E2E tests for critical paths.
/\ E2E tests (few, critical paths only)
/ \
/ \ Contract tests (service boundaries)
/ \
/--------\ Integration tests (thick layer)
\ /
\ / Unit tests (pure domain logic)
\ /
\/
Unit Tests: Domain Logic Only
In microservices, unit tests should focus exclusively on domain logic. Testing controller-to-database flows with mocks creates brittle tests that do not catch real bugs.
public class OrderTests
{
[Fact]
public void Submit_WithEmptyOrder_ThrowsDomainException()
{
var order = Order.Create(new CustomerId("cust-123"));
var act = () => order.Submit();
act.Should().Throw<DomainException>()
.WithMessage("Cannot submit an empty order.");
}
[Fact]
public void AddLine_ToSubmittedOrder_ThrowsDomainException()
{
var order = CreateOrderWithOneLine();
order.Submit();
var act = () => order.AddLine(new ProductId("prod-456"), 1, Money.From(10));
act.Should().Throw<DomainException>()
.WithMessage("Cannot modify a submitted order.");
}
}
Integration Tests: The Real Workhorse
Integration tests are where most of your testing budget should go. Use WebApplicationFactory with real databases via Testcontainers.
public class OrdersApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public OrdersApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateOrder_ReturnsCreatedWithLocation()
{
var command = new { CustomerId = "cust-123", Lines = new[]
{
new { ProductId = "prod-1", Quantity = 2, UnitPrice = 29.99m }
}};
var response = await _client.PostAsJsonAsync("/api/orders", command);
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
}
}
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(_sqlContainer.GetConnectionString()));
});
}
}
Contract Tests: Guarding Service Boundaries
Contract tests verify that services can communicate correctly. I use Pact for consumer-driven contract testing.
// Consumer side (Order Service tests what it expects from Inventory Service)
[Fact]
public async Task GetStock_ReturnsExpectedFormat()
{
_pactBuilder
.UponReceiving("a request for product stock")
.Given("product prod-123 exists with stock 50")
.WithRequest(HttpMethod.Get, "/api/stock/prod-123")
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.WithJsonBody(new
{
productId = "prod-123",
available = 50,
reserved = 5
});
await _pactBuilder.VerifyAsync(async ctx =>
{
var client = new InventoryClient(ctx.MockServerUri);
var stock = await client.GetStockAsync("prod-123");
stock.Available.Should().Be(50);
});
}
The contract is then shared with the provider (Inventory Service), which verifies it can fulfill the contract. This catches breaking changes before they reach production.
E2E Tests: Critical Paths Only
End-to-end tests across microservices are expensive to maintain and slow to run. Reserve them for your most critical business flows.
# docker-compose.e2e.yml
services:
order-service:
build: ./src/OrderService
depends_on: [sql-server, rabbitmq]
inventory-service:
build: ./src/InventoryService
depends_on: [sql-server, rabbitmq]
sql-server:
image: mcr.microsoft.com/mssql/server:2022-latest
rabbitmq:
image: rabbitmq:3-management
[Fact]
public async Task PlaceOrder_FullFlow_ReducesInventory()
{
// Place order via Order Service
var orderResponse = await _orderClient.PostAsJsonAsync("/api/orders", orderPayload);
var orderId = await orderResponse.Content.ReadFromJsonAsync<Guid>();
// Wait for async processing
await WaitForConditionAsync(async () =>
{
var stock = await _inventoryClient.GetFromJsonAsync<StockDto>($"/api/stock/prod-1");
return stock!.Available == 48; // Was 50, ordered 2
}, timeout: TimeSpan.FromSeconds(30));
}
Key Lessons
Keep unit tests focused on domain invariants. Invest heavily in integration tests with real infrastructure. Use contract tests to protect service boundaries. Run E2E tests sparingly for critical paths. And remember that flaky tests are worse than no tests because they erode trust in the entire test suite.
Comments (3)
Could you elaborate on this topic in a follow-up post?
Interesting thought, thanks for adding that.
Exactly! I had the same thought.
This is exactly what I was looking for, thank you!
I have a question: does this also apply to older versions?
Leave a Comment