Microservices promise independent deployability. But how do you test services that depend on each other without deploying everything together? Bad testing strategies negate microservices benefits.
Here are testing strategies that work.
The Testing Challenge
Why Microservices Testing Is Hard
challenges:
integration_complexity:
- Multiple services interact
- Network boundaries
- Async communication
- Data consistency
environment_issues:
- Need dependent services running
- Expensive to replicate production
- Configuration drift
- Data management
ownership_boundaries:
- Who tests the integration?
- Blame game when tests fail
- Different release cycles
Testing Pyramid for Microservices
┌─────────────┐
│ E2E Tests │ Few, critical paths only
├─────────────┤
┌──┴─────────────┴──┐
│ Integration Tests │ Service + dependencies
├───────────────────┤
┌──┴───────────────────┴──┐
│ Contract Tests │ API contracts
├─────────────────────────┤
┌──┴─────────────────────────┴──┐
│ Component Tests │ Service in isolation
├───────────────────────────────┤
┌──┴───────────────────────────────┴──┐
│ Unit Tests │ Functions, classes
└──────────────────────────────────────┘
Unit Tests
// Unit test: Test business logic in isolation
func TestOrderTotalCalculation(t *testing.T) {
order := Order{
Items: []OrderItem{
{ProductID: "A", Price: 100, Quantity: 2},
{ProductID: "B", Price: 50, Quantity: 1},
},
DiscountPercent: 10,
}
total := order.CalculateTotal()
expected := 225.0 // (200 + 50) * 0.9
if total != expected {
t.Errorf("Expected %v, got %v", expected, total)
}
}
// Test edge cases
func TestOrderTotalWithNoItems(t *testing.T) {
order := Order{Items: []OrderItem{}}
total := order.CalculateTotal()
if total != 0 {
t.Errorf("Empty order should have zero total")
}
}
Component Tests
// Component test: Test service with mocked dependencies
func TestOrderService_CreateOrder(t *testing.T) {
// Mock dependencies
mockInventory := &MockInventoryClient{}
mockPayment := &MockPaymentClient{}
mockDB := setupTestDB(t)
service := NewOrderService(mockDB, mockInventory, mockPayment)
// Setup expectations
mockInventory.On("Reserve", mock.Anything).Return(nil)
mockPayment.On("Authorize", mock.Anything).Return("auth-123", nil)
// Execute
order, err := service.CreateOrder(context.Background(), CreateOrderRequest{
CustomerID: "cust-1",
Items: []OrderItem{{ProductID: "prod-1", Quantity: 1}},
})
// Assert
require.NoError(t, err)
assert.NotEmpty(t, order.ID)
assert.Equal(t, OrderStatusPending, order.Status)
mockInventory.AssertExpectations(t)
mockPayment.AssertExpectations(t)
}
Using Test Containers
// Component test with real database
func TestOrderRepository_Integration(t *testing.T) {
ctx := context.Background()
// Start PostgreSQL container
postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:14",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
},
Started: true,
})
require.NoError(t, err)
defer postgres.Terminate(ctx)
// Get connection string
host, _ := postgres.Host(ctx)
port, _ := postgres.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("postgres://postgres:test@%s:%s/testdb", host, port.Port())
// Run migrations and tests
db := setupDatabase(dsn)
repo := NewOrderRepository(db)
// Test actual database operations
order := &Order{CustomerID: "cust-1", Total: 100}
err = repo.Create(ctx, order)
require.NoError(t, err)
assert.NotEmpty(t, order.ID)
found, err := repo.GetByID(ctx, order.ID)
require.NoError(t, err)
assert.Equal(t, order.CustomerID, found.CustomerID)
}
Contract Testing
Consumer-Driven Contracts with Pact
// Consumer side: Define expected interactions
func TestOrderServiceClient_Consumer(t *testing.T) {
// Setup Pact
pact := dsl.Pact{
Consumer: "OrderService",
Provider: "InventoryService",
}
defer pact.Teardown()
// Define expected interaction
pact.
AddInteraction().
Given("Product ABC exists with stock").
UponReceiving("A request to check inventory").
WithRequest(dsl.Request{
Method: "GET",
Path: dsl.String("/inventory/ABC"),
}).
WillRespondWith(dsl.Response{
Status: 200,
Body: dsl.MapMatcher{
"productId": dsl.String("ABC"),
"available": dsl.Like(100),
"reserved": dsl.Like(10),
},
})
// Test
err := pact.Verify(func() error {
client := NewInventoryClient(pact.Server.URL)
inventory, err := client.GetInventory("ABC")
assert.Equal(t, "ABC", inventory.ProductID)
return err
})
require.NoError(t, err)
}
// Provider side: Verify contracts
func TestInventoryService_Provider(t *testing.T) {
// Setup provider
server := setupInventoryServer()
defer server.Close()
// Setup provider states
pact.ProviderState("Product ABC exists with stock", func() error {
// Setup test data
db.Exec("INSERT INTO inventory (product_id, available, reserved) VALUES ('ABC', 100, 10)")
return nil
})
// Verify
_, err := pact.VerifyProvider(t, types.VerifyRequest{
ProviderBaseURL: server.URL,
PactURLs: []string{"./pacts/orderservice-inventoryservice.json"},
StateHandlers: stateHandlers,
})
require.NoError(t, err)
}
Schema Validation
# OpenAPI schema for contract validation
openapi: 3.0.0
paths:
/inventory/{productId}:
get:
responses:
'200':
content:
application/json:
schema:
type: object
required:
- productId
- available
properties:
productId:
type: string
available:
type: integer
minimum: 0
Integration Testing
Service Integration Tests
// Integration test: Test with real dependent services
func TestOrderWorkflow_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
// Start all services with docker-compose
ctx := context.Background()
compose, err := testcompose.NewDockerCompose("./docker-compose.test.yml")
require.NoError(t, err)
defer compose.Down(ctx)
err = compose.Up(ctx)
require.NoError(t, err)
// Wait for services
waitForService(t, "http://localhost:8080/health")
waitForService(t, "http://localhost:8081/health")
// Run integration test
client := NewOrderClient("http://localhost:8080")
order, err := client.CreateOrder(CreateOrderRequest{
CustomerID: "test-customer",
Items: []OrderItem{
{ProductID: "test-product", Quantity: 1, Price: 100},
},
})
require.NoError(t, err)
assert.Equal(t, OrderStatusConfirmed, order.Status)
// Verify side effects
inventory := getInventory(t, "test-product")
assert.Equal(t, 99, inventory.Available) // One reserved
}
Testing Async Workflows
// Test event-driven workflows
func TestOrderCreated_EventHandling(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Setup Kafka consumer for verification
consumer := setupKafkaConsumer(t, "order-events")
// Create order
client := NewOrderClient(orderServiceURL)
order, err := client.CreateOrder(testOrder)
require.NoError(t, err)
// Wait for event
event := waitForEvent(t, consumer, ctx)
assert.Equal(t, "OrderCreated", event.Type)
assert.Equal(t, order.ID, event.OrderID)
}
func waitForEvent(t *testing.T, consumer *kafka.Consumer, ctx context.Context) Event {
for {
select {
case <-ctx.Done():
t.Fatal("Timeout waiting for event")
default:
msg, err := consumer.ReadMessage(100 * time.Millisecond)
if err != nil {
continue
}
var event Event
json.Unmarshal(msg.Value, &event)
return event
}
}
}
End-to-End Tests
e2e_strategy:
scope: Critical business paths only
frequency: Less frequent than other tests
environment: Production-like
examples:
- User registration → Email verification → Login
- Browse → Add to cart → Checkout → Payment
- Order → Inventory update → Shipping notification
// E2E test: Full user journey
func TestUserPurchaseJourney_E2E(t *testing.T) {
// This runs against staging environment
baseURL := os.Getenv("E2E_BASE_URL")
// Step 1: Register user
user := registerUser(t, baseURL)
// Step 2: Browse products
products := getProducts(t, baseURL)
require.NotEmpty(t, products)
// Step 3: Add to cart
cart := addToCart(t, baseURL, user.Token, products[0].ID)
// Step 4: Checkout
order := checkout(t, baseURL, user.Token, cart.ID)
assert.Equal(t, "confirmed", order.Status)
// Step 5: Verify email sent (check test mailbox)
email := waitForEmail(t, user.Email, "Order Confirmation")
assert.Contains(t, email.Body, order.ID)
}
Test Data Management
test_data_strategies:
fixtures:
use: Known data for deterministic tests
challenge: Maintenance burden
factories:
use: Generate test data programmatically
benefit: Flexible, less maintenance
snapshots:
use: Production data subsets
challenge: PII concerns, freshness
synthetic:
use: Generated realistic data
benefit: Safe, scalable
// Factory pattern for test data
type OrderFactory struct {
seq int
}
func (f *OrderFactory) Build(opts ...OrderOption) *Order {
f.seq++
order := &Order{
ID: fmt.Sprintf("order-%d", f.seq),
CustomerID: fmt.Sprintf("customer-%d", f.seq),
Status: OrderStatusPending,
CreatedAt: time.Now(),
}
for _, opt := range opts {
opt(order)
}
return order
}
// Options for customization
func WithStatus(status OrderStatus) OrderOption {
return func(o *Order) {
o.Status = status
}
}
func WithItems(items []OrderItem) OrderOption {
return func(o *Order) {
o.Items = items
}
}
// Usage
factory := &OrderFactory{}
order := factory.Build(
WithStatus(OrderStatusConfirmed),
WithItems([]OrderItem{{ProductID: "A", Quantity: 2}}),
)
CI/CD Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: go test -short ./...
component-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: test
steps:
- uses: actions/checkout@v3
- run: go test -tags=component ./...
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: go test -tags=contract ./...
- uses: pactflow/actions/publish-pact-files@v1
with:
pactfiles: ./pacts/*.json
integration-tests:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- run: docker-compose -f docker-compose.test.yml up -d
- run: go test -tags=integration ./...
Key Takeaways
- Unit tests for business logic, fast feedback
- Component tests for service behavior with mocked dependencies
- Contract tests to verify API compatibility between services
- Integration tests sparingly for critical paths
- E2E tests for critical business journeys only
- Use test containers for realistic component tests
- Manage test data with factories, not fixtures
- Run fast tests on every commit, slow tests less frequently
- Contract tests enable independent deployability
Testing microservices requires discipline. The goal: confidence to deploy independently.