Testing Microservices — Unit, Integration & Contract Tests
The testing pyramid for microservices: unit testing handlers and domain logic, integration testing with Testcontainers, consumer-driven contract tests with Pact, end-to-end testing trade-offs, and testing async messaging.
The Testing Pyramid for Microservices
The classic pyramid still applies, but the shapes shift:
┌─────────────────────┐
│ End-to-End (E2E) │ Few — slow, brittle, expensive
│ (Docker Compose / │
│ staging env) │
├─────────────────────┤
│ Contract Tests │ Some — fast, service boundaries
│ (Pact) │
├─────────────────────┤
│ Integration Tests │ Some — real DB, single service
│ (Testcontainers) │
├─────────────────────┤
│ Unit Tests │ Many — fast, isolated, no I/O
│ (handlers, domain) │
└─────────────────────┘The key insight for microservices: contract tests replace most E2E tests. Instead of spinning up all services to verify that Order Service and Inventory Service communicate correctly, you define a contract and verify each side independently.
Unit Tests — Handlers and Domain Logic
Unit tests should be fast (sub-millisecond), isolated (no database, no network), and focused on business logic.
In a CQRS architecture, the natural unit-test targets are command handlers, query handlers, and domain entities.
Testing a command handler
// Application/Orders/PlaceOrderHandler.cs
public class PlaceOrderHandler(
IOrderRepository orders,
IInventoryService inventory,
IPublisher publisher) : IRequestHandler<PlaceOrderCommand, OrderId>
{
public async Task<OrderId> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var stock = await inventory.CheckAvailabilityAsync(cmd.ProductId, cmd.Quantity, ct);
if (!stock.IsAvailable)
throw new DomainException($"Insufficient stock for product {cmd.ProductId}.");
var order = Order.Create(cmd.CustomerId, cmd.ProductId, cmd.Quantity, cmd.UnitPrice);
await orders.AddAsync(order, ct);
await publisher.Publish(new OrderPlacedEvent(order.Id, cmd.ProductId, cmd.Quantity), ct);
return order.Id;
}
}// Tests/Unit/Orders/PlaceOrderHandlerTests.cs
public class PlaceOrderHandlerTests
{
private readonly IOrderRepository _orders = Substitute.For<IOrderRepository>();
private readonly IInventoryService _inventory = Substitute.For<IInventoryService>();
private readonly IPublisher _publisher = Substitute.For<IPublisher>();
private readonly PlaceOrderHandler _handler;
public PlaceOrderHandlerTests()
{
_handler = new PlaceOrderHandler(_orders, _inventory, _publisher);
}
[Fact]
public async Task Handle_WhenStockAvailable_CreatesOrderAndPublishesEvent()
{
// Arrange
var cmd = new PlaceOrderCommand(
CustomerId: Guid.NewGuid(),
ProductId: Guid.NewGuid(),
Quantity: 2,
UnitPrice: 49.99m);
_inventory
.CheckAvailabilityAsync(cmd.ProductId, cmd.Quantity, Arg.Any<CancellationToken>())
.Returns(StockAvailability.Available(cmd.Quantity));
// Act
var orderId = await _handler.Handle(cmd, CancellationToken.None);
// Assert
orderId.Should().NotBe(OrderId.Empty);
await _orders.Received(1).AddAsync(
Arg.Is<Order>(o => o.ProductId == cmd.ProductId && o.Quantity == cmd.Quantity),
Arg.Any<CancellationToken>());
await _publisher.Received(1).Publish(
Arg.Is<OrderPlacedEvent>(e => e.ProductId == cmd.ProductId),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_WhenStockUnavailable_ThrowsDomainException()
{
// Arrange
var cmd = new PlaceOrderCommand(
CustomerId: Guid.NewGuid(),
ProductId: Guid.NewGuid(),
Quantity: 100,
UnitPrice: 49.99m);
_inventory
.CheckAvailabilityAsync(cmd.ProductId, cmd.Quantity, Arg.Any<CancellationToken>())
.Returns(StockAvailability.Unavailable("only 5 in stock"));
// Act & Assert
await Assert.ThrowsAsync<DomainException>(
() => _handler.Handle(cmd, CancellationToken.None));
await _orders.DidNotReceive().AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
await _publisher.DidNotReceive().Publish(Arg.Any<object>(), Arg.Any<CancellationToken>());
}
}Testing domain entities
Domain logic lives in entities, not handlers. Test it directly — no mocks needed:
public class OrderTests
{
[Fact]
public void Create_WithValidInputs_SetsStatusToPending()
{
var order = Order.Create(
customerId: Guid.NewGuid(),
productId: Guid.NewGuid(),
quantity: 3,
unitPrice: 29.99m);
order.Status.Should().Be(OrderStatus.Pending);
order.TotalAmount.Should().Be(89.97m);
order.DomainEvents.Should().ContainSingle(e => e is OrderCreatedEvent);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Create_WithInvalidQuantity_Throws(int quantity)
{
var act = () => Order.Create(Guid.NewGuid(), Guid.NewGuid(), quantity, 10m);
act.Should().Throw<ArgumentException>();
}
}Integration Tests — Single Service with Testcontainers
Integration tests verify that your service works correctly with its real dependencies (PostgreSQL, Redis, RabbitMQ). Testcontainers spins up real Docker containers for each test run — no mocking, no shared state between runs.
dotnet add package Testcontainers.PostgreSql
dotnet add package Testcontainers.Redis
dotnet add package Microsoft.AspNetCore.Mvc.TestingWebApplicationFactory with real containers
// Tests/Integration/OrdersApiFactory.cs
public class OrdersApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithDatabase("orders_test")
.WithUsername("test")
.WithPassword("test")
.Build();
private readonly RedisContainer _redis = new RedisBuilder().Build();
public async Task InitializeAsync()
{
await _postgres.StartAsync();
await _redis.StartAsync();
}
public new async Task DisposeAsync()
{
await _postgres.DisposeAsync();
await _redis.DisposeAsync();
await base.DisposeAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Replace DB connection with the test container
services.RemoveAll<DbContextOptions<OrdersDbContext>>();
services.AddDbContext<OrdersDbContext>(opts =>
opts.UseNpgsql(_postgres.GetConnectionString()));
// Replace Redis with the test container
services.RemoveAll<IConnectionMultiplexer>();
services.AddSingleton<IConnectionMultiplexer>(
_ => ConnectionMultiplexer.Connect(_redis.GetConnectionString()));
});
}
}Integration test
// Tests/Integration/Orders/PlaceOrderEndpointTests.cs
public class PlaceOrderEndpointTests(OrdersApiFactory factory)
: IClassFixture<OrdersApiFactory>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task POST_orders_WithValidRequest_Returns201AndOrderId()
{
// Arrange
var request = new
{
customerId = Guid.NewGuid(),
productId = Guid.NewGuid(),
quantity = 2,
unitPrice = 49.99m,
};
// Act
var response = await _client.PostAsJsonAsync("/api/orders", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<PlaceOrderResponse>();
body!.OrderId.Should().NotBeEmpty();
// Verify it was actually persisted
var getResponse = await _client.GetAsync($"/api/orders/{body.OrderId}");
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task POST_orders_WithMissingQuantity_Returns400WithValidationErrors()
{
var request = new { customerId = Guid.NewGuid(), productId = Guid.NewGuid() };
var response = await _client.PostAsJsonAsync("/api/orders", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Should().ContainKey("quantity");
}
}Contract Tests with Pact
The problem with integration/E2E tests for service boundaries
If Order Service calls Inventory Service, you could write an E2E test that boots both services. But:
- The test is slow (two services, two databases)
- Changing Inventory's response schema breaks the test, but you don't know who to blame
- You need both teams present to debug failures
Consumer-driven contract tests solve this. The consumer (Order Service) defines what it expects from the provider (Inventory Service). This expectation is the contract. Both sides are tested independently against the contract.
Order Service (consumer) Inventory Service (provider)
│ │
│ 1. Write consumer test │
│ → Pact creates contract.json │
│ │
│ 2. Publish contract.json to │
│ Pact Broker │
│ │
│ 3. Provider │
│ verification│
│ runs against│
│ contract │Consumer test (Order Service)
dotnet add package PactNet// Tests/Contract/Consumer/InventoryClientContractTests.cs
public class InventoryClientContractTests : IDisposable
{
private readonly IPactBuilderV4 _pact;
public InventoryClientContractTests()
{
var config = new PactConfig
{
PactDir = Path.Combine(Directory.GetCurrentDirectory(), "pacts"),
LogLevel = PactLogLevel.Warn,
};
_pact = Pact.V4("orders-service", "inventory-service", config).WithHttpInteractions();
}
[Fact]
public async Task CheckAvailability_WhenProductExists_ReturnsAvailability()
{
var productId = new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6");
_pact
.UponReceiving("a request to check stock availability")
.WithRequest(HttpMethod.Get, $"/api/inventory/{productId}/availability")
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.WithHeader("Content-Type", "application/json; charset=utf-8")
.WithJsonBody(new
{
productId = Match.Type(productId),
available = Match.Type(true),
quantity = Match.Integer(50),
});
await _pact.VerifyAsync(async ctx =>
{
// Point the InventoryClient at the Pact mock server
var client = new InventoryClient(
new HttpClient { BaseAddress = ctx.MockServerUri });
var result = await client.CheckAvailabilityAsync(productId, 2);
result.IsAvailable.Should().BeTrue();
result.AvailableQuantity.Should().Be(50);
});
}
public void Dispose()
{
// Pact file written to /pacts/orders-service-inventory-service.json
}
}Pact Broker — sharing contracts in CI
# Publish contracts after consumer tests pass
docker run --rm \
-e PACT_BROKER_BASE_URL=https://your-pact-broker.pactflow.io \
-e PACT_BROKER_TOKEN=$PACT_TOKEN \
-v "$(pwd)/pacts:/pacts" \
pactfoundation/pact-cli publish /pacts \
--consumer-app-version $(git rev-parse HEAD) \
--branch $(git branch --show-current)Provider verification (Inventory Service)
// Tests/Contract/Provider/InventoryProviderContractTests.cs
public class InventoryProviderContractTests : IClassFixture<InventoryApiFactory>
{
private readonly InventoryApiFactory _factory;
public InventoryProviderContractTests(InventoryApiFactory factory)
{
_factory = factory;
}
[Fact]
public void InventoryService_HonorsContractsWithAllConsumers()
{
var config = new PactVerifierConfig
{
LogLevel = PactLogLevel.Warn,
};
new PactVerifier("inventory-service", config)
.WithHttpEndpoint(_factory.Server.BaseAddress)
.WithPactBrokerSource(new Uri("https://your-pact-broker.pactflow.io"), opts =>
{
opts.TokenAuthentication(Environment.GetEnvironmentVariable("PACT_TOKEN")!);
opts.ConsumerVersionSelectors(
new ConsumerVersionSelector { MainBranch = true },
new ConsumerVersionSelector { DeployedOrReleased = true });
})
.WithProviderStateUrl(new Uri(_factory.Server.BaseAddress, "/_pact/provider-states"))
.Verify();
}
}
// Provider state setup endpoint (reset DB state for each contract interaction)
app.MapPost("/_pact/provider-states", async (
ProviderState state,
OrdersDbContext db) =>
{
if (state.State == "product 3fa85f64 has 50 units in stock")
{
db.InventoryItems.Add(new InventoryItem
{
ProductId = new Guid("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
Quantity = 50,
});
await db.SaveChangesAsync();
}
return Results.Ok();
});End-to-End Tests with Docker Compose
E2E tests verify complete user journeys across all services. Use them sparingly — only for the most critical paths (place an order, process a payment).
# docker-compose.test.yml
services:
gateway: { build: ./gateway, ports: ["5000:8080"] }
orders: { build: ./services/orders }
inventory: { build: ./services/inventory }
catalog: { build: ./services/catalog }
rabbitmq: { image: rabbitmq:3-management }
postgres: { image: postgres:16-alpine }
redis: { image: redis:7-alpine }
e2e-tests:
build: ./tests/e2e
environment:
- GATEWAY_URL=http://gateway:8080
depends_on:
gateway:
condition: service_healthy
command: dotnet test --filter Category=E2E// Tests/E2E/OrderFlowTests.cs
[Trait("Category", "E2E")]
public class OrderFlowTests
{
private readonly HttpClient _gateway = new()
{
BaseAddress = new Uri(Environment.GetEnvironmentVariable("GATEWAY_URL")
?? "http://localhost:5000")
};
[Fact]
public async Task PlaceOrder_FullFlow_CompletesWithinSLA()
{
var sw = Stopwatch.StartNew();
// 1. Register and authenticate
var token = await AuthenticateAsync();
_gateway.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
// 2. Get a product from catalog
var product = await GetFirstProductAsync();
// 3. Place an order
var orderResponse = await _gateway.PostAsJsonAsync("/api/orders", new
{
productId = product.Id,
quantity = 1,
});
orderResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var order = await orderResponse.Content.ReadFromJsonAsync<OrderDto>();
// 4. Wait for async processing (Saga + inventory reservation)
await WaitForOrderStatusAsync(order!.Id, "Confirmed", timeout: TimeSpan.FromSeconds(10));
sw.Stop();
sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(5), "SLA exceeded");
}
private async Task WaitForOrderStatusAsync(Guid orderId, string status, TimeSpan timeout)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (DateTimeOffset.UtcNow < deadline)
{
var r = await _gateway.GetFromJsonAsync<OrderDto>($"/api/orders/{orderId}");
if (r?.Status == status) return;
await Task.Delay(200);
}
throw new TimeoutException($"Order {orderId} did not reach status {status}.");
}
}The problems with E2E tests in microservices
| Problem | Impact | |---------|--------| | Slow setup/teardown | Minutes per run vs milliseconds for unit tests | | Brittle — any service can break the suite | False failures unrelated to the change | | Hard to isolate failures | Which service caused the failure? | | Hard to test edge cases | Can't easily inject a 503 from Inventory in E2E | | State pollution | Tests share databases; order matters |
Rule: if you can verify a behavior with a contract test + unit test, don't write an E2E test for it.
Testing Async Messaging
Testing that Order Service publishes OrderPlacedEvent and that Inventory Service handles it requires a different approach — you can't make an HTTP assertion on an async message.
Testing the publisher (unit)
[Fact]
public async Task Handle_WhenOrderCreated_PublishesOrderPlacedEvent()
{
var publisher = Substitute.For<IPublisher>();
var handler = new PlaceOrderHandler(_orders, _inventory, publisher);
await handler.Handle(new PlaceOrderCommand(...), CancellationToken.None);
await publisher.Received(1).Publish(
Arg.Is<OrderPlacedEvent>(e => e.ProductId == cmd.ProductId),
Arg.Any<CancellationToken>());
}Testing the consumer (integration with Testcontainers + RabbitMQ)
dotnet add package Testcontainers.RabbitMqpublic class OrderPlacedConsumerTests : IAsyncLifetime
{
private readonly RabbitMqContainer _rabbitmq = new RabbitMqBuilder().Build();
private IBusControl? _bus;
public async Task InitializeAsync()
{
await _rabbitmq.StartAsync();
_bus = Bus.Factory.CreateUsingRabbitMq(cfg =>
{
cfg.Host(_rabbitmq.Hostname, _rabbitmq.GetMappedPublicPort(5672), "/",
h => { h.Username("guest"); h.Password("guest"); });
cfg.ReceiveEndpoint("inventory-order-placed", e =>
e.Consumer<OrderPlacedConsumer>());
});
await _bus.StartAsync();
}
[Fact]
public async Task OrderPlacedConsumer_WhenMessageReceived_ReservesStock()
{
// Arrange
var repository = Substitute.For<IInventoryRepository>();
var consumer = new OrderPlacedConsumer(repository);
var harness = new ConsumerTestHarness<OrderPlacedConsumer>(
new InMemoryTestHarness(), _ => consumer);
await harness.Start();
// Act — publish the event
await harness.Bus.Publish(new OrderPlacedEvent(
OrderId: Guid.NewGuid(),
ProductId: Guid.NewGuid(),
Quantity: 3));
// Assert — consumer received and handled the message
(await harness.Consumed.Any<OrderPlacedEvent>()).Should().BeTrue();
await repository.Received(1)
.ReserveAsync(Arg.Any<Guid>(), 3, Arg.Any<CancellationToken>());
await harness.Stop();
}
public async Task DisposeAsync()
{
if (_bus is not null) await _bus.StopAsync();
await _rabbitmq.DisposeAsync();
}
}MassTransit in-memory test harness (simpler for unit-style tests)
[Fact]
public async Task OrderPlacedConsumer_WithInMemoryHarness_ProcessesMessage()
{
await using var provider = new ServiceCollection()
.AddMassTransitTestHarness(cfg =>
cfg.AddConsumer<OrderPlacedConsumer>())
.BuildServiceProvider(true);
var harness = provider.GetRequiredService<ITestHarness>();
await harness.Start();
await harness.Bus.Publish(new OrderPlacedEvent(
OrderId: Guid.NewGuid(), ProductId: Guid.NewGuid(), Quantity: 2));
(await harness.Consumed.Any<OrderPlacedEvent>()).Should().BeTrue();
var consumerHarness = harness.GetConsumerHarness<OrderPlacedConsumer>();
(await consumerHarness.Consumed.Any<OrderPlacedEvent>()).Should().BeTrue();
}Summary
| Test type | Tool | Speed | Scope | |-----------|------|-------|-------| | Unit | xUnit + NSubstitute + FluentAssertions | <1ms | Handler, domain logic | | Integration | Testcontainers + WebApplicationFactory | ~1–5s | Single service + real dependencies | | Contract (consumer) | PactNet | ~100ms | API contract between services | | Contract (provider) | PactNet + Pact Broker | ~2–10s | Provider verifies all consumer contracts | | Async messaging | MassTransit test harness | ~200ms | Message consumer logic | | E2E | Docker Compose + HttpClient | ~10–60s | Critical user journeys only |
Write many unit tests, a reasonable number of integration tests, contract tests for every service-to-service boundary, and as few E2E tests as you can get away with.
Enjoyed this article?
Explore the System Design learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.