Testing Microservices: Strategies That Scale

September 19, 2022

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

Testing microservices requires discipline. The goal: confidence to deploy independently.