Microservices Integration Testing: Strategies That Actually Work

Microservices Integration Testing: Strategies That Actually Work

Integration testing for microservices is where good intentions go to die. Teams start with aspirations of comprehensive test coverage across service boundaries, then hit the reality: standing up five services, three databases, a message queue, and a cache just to run a single test is slow, fragile, and expensive to maintain. So they scale back to pure unit tests and hope for the best. Then production breaks in exactly the ways integration tests would have caught.

The mistake is treating integration testing as an all-or-nothing proposition. The services that need to be tested together are not all of them — they're the ones that share a boundary. The infrastructure that needs to be real is not all of it — only the parts where mock behavior diverges meaningfully from real behavior. Get these distinctions right and you can build a fast, reliable integration test suite that actually runs in CI.

This guide covers the strategies that work in practice: the integration test pyramid for microservices, Testcontainers for realistic dependency management, service boundary testing, API gateway testing, and data consistency verification.

The Microservices Integration Test Pyramid

The classic test pyramid (many unit tests, fewer integration tests, few E2E tests) needs adaptation for microservices. The key insight is that service boundaries are the primary integration points that matter.

For each service, structure tests as follows:

Level 1 — In-process integration tests (fast, numerous): Test each service with real implementations of its own dependencies (database, cache) but mock external services. These run in under a second each and cover the majority of your integration surface.

Level 2 — Boundary integration tests (medium speed, targeted): Test the interface between two specific services — real implementations of both, mocked everything else. These are your contract-verification tests that confirm two services can actually communicate.

Level 3 — Slice integration tests (slower, few): Test a meaningful vertical slice end-to-end: one user journey through three or four services. Not a full E2E test — just enough to verify the critical path works when the pieces are assembled.

Here's the distribution target: for every 1 slice integration test, write 5-10 boundary tests, and 20-50 in-process integration tests. This gives comprehensive coverage without the overhead of spinning up your full system for every test.

Testcontainers: Real Dependencies Without Infrastructure Overhead

Testcontainers is the most important tool in the microservices integration testing toolkit. It spins up Docker containers for your actual dependencies — PostgreSQL, Redis, Kafka, Elasticsearch — on demand, isolated per test run, and tears them down automatically. No shared test databases, no state leaking between runs, no "works on my machine" problems.

Here's a complete in-process integration test for an order service with a real PostgreSQL database:

// OrderServiceIntegrationTest.java
@SpringBootTest
@Testcontainers
@Transactional
class OrderServiceIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("orders_test")
        .withUsername("test")
        .withPassword("test")
        .withInitScript("db/schema.sql");  // Run schema migrations on startup

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    // Mock the external payment service — we're testing order logic, not payment
    @MockBean
    private PaymentServiceClient paymentServiceClient;

    @MockBean
    private InventoryServiceClient inventoryServiceClient;

    @BeforeEach
    void setUp() {
        // Set up consistent mock behavior
        when(paymentServiceClient.charge(any(), any()))
            .thenReturn(new PaymentResult("txn_test_123", "success"));
        when(inventoryServiceClient.reserve(any(), anyInt()))
            .thenReturn(new ReservationResult(true, "res_test_456"));
    }

    @Test
    void createOrder_persistsOrderAndReturnsId() {
        CreateOrderRequest request = CreateOrderRequest.builder()
            .customerId("cust_123")
            .items(List.of(
                new OrderItemRequest("PROD-1", 2, new BigDecimal("29.99")),
                new OrderItemRequest("PROD-2", 1, new BigDecimal("49.99"))
            ))
            .shippingAddress(new Address("123 Main St", "Springfield", "IL", "62701"))
            .build();

        Order created = orderService.createOrder(request);

        assertThat(created.getId()).isNotNull();
        assertThat(created.getStatus()).isEqualTo(OrderStatus.PENDING);
        assertThat(created.getTotalAmount()).isEqualByComparingTo(new BigDecimal("109.97"));

        // Verify persistence — reload from database
        Order persisted = orderRepository.findById(created.getId()).orElseThrow();
        assertThat(persisted.getCustomerId()).isEqualTo("cust_123");
        assertThat(persisted.getItems()).hasSize(2);
    }

    @Test
    void createOrder_rollsBackOnPaymentFailure() {
        when(paymentServiceClient.charge(any(), any()))
            .thenThrow(new PaymentFailedException("Card declined"));

        CreateOrderRequest request = CreateOrderRequest.builder()
            .customerId("cust_declined")
            .items(List.of(new OrderItemRequest("PROD-1", 1, new BigDecimal("29.99"))))
            .build();

        assertThatThrownBy(() -> orderService.createOrder(request))
            .isInstanceOf(PaymentFailedException.class);

        // Critical: verify the order was NOT persisted (transaction rolled back)
        long orderCount = orderRepository.countByCustomerId("cust_declined");
        assertThat(orderCount).isZero();

        // And inventory reservation should have been released
        verify(inventoryServiceClient).releaseReservation(any());
    }

    @Test
    void getOrders_returnsPagedResults_sortedByCreatedAtDesc() {
        // Create 15 orders with distinct timestamps
        for (int i = 0; i < 15; i++) {
            orderRepository.save(Order.builder()
                .customerId("cust_paged")
                .status(OrderStatus.PENDING)
                .totalAmount(new BigDecimal("10.00"))
                .createdAt(Instant.now().minusSeconds(i * 60))
                .build());
        }

        Page<Order> page1 = orderService.getOrdersByCustomer("cust_paged", 0, 10);
        Page<Order> page2 = orderService.getOrdersByCustomer("cust_paged", 1, 10);

        assertThat(page1.getContent()).hasSize(10);
        assertThat(page2.getContent()).hasSize(5);
        assertThat(page1.getTotalElements()).isEqualTo(15);

        // Verify ordering: most recent first
        List<Instant> timestamps = page1.getContent().stream()
            .map(Order::getCreatedAt)
            .toList();
        for (int i = 0; i < timestamps.size() - 1; i++) {
            assertThat(timestamps.get(i)).isAfterOrEqualTo(timestamps.get(i + 1));
        }
    }
}

The @Transactional annotation rolls back all database changes after each test, keeping the database clean without truncating tables between tests. This is significantly faster than setup/teardown scripts.

For tests that need Redis (caching, rate limiting, session storage):

@Container
static GenericContainer<?> redis = new GenericContainer<>(
    DockerImageName.parse("redis:7-alpine")
)
    .withExposedPorts(6379)
    .withCommand("redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru");

@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.data.redis.host", redis::getHost);
    registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}

Testing Service Boundaries

Boundary tests verify that two specific services communicate correctly. The key challenge is schema compatibility — the data one service sends must be parseable and meaningful to the other.

Here's a boundary test between the API gateway and the order service:

# test_gateway_order_boundary.py
import pytest
import requests
from testcontainers.compose import DockerCompose

@pytest.fixture(scope="session")
def services():
    """Start API gateway + order service with a test database."""
    compose = DockerCompose(
        "tests/fixtures/",
        compose_file_name="docker-compose.boundary-test.yml",
        pull=False,
    )
    with compose:
        compose.wait_for("http://localhost:8080/health")
        compose.wait_for("http://localhost:8081/health")
        yield {"gateway": "http://localhost:8080", "order_service": "http://localhost:8081"}

class TestGatewayOrderBoundary:
    def test_gateway_correctly_translates_request_to_order_service(self, services):
        """
        The gateway accepts a public API request and forwards it to the
        order service with the correct internal format.
        """
        # Public API request (what clients send to the gateway)
        public_request = {
            "cart": {
                "items": [
                    {"productId": "prod-abc-123", "qty": 2}
                ]
            },
            "deliveryAddress": {
                "street": "123 Main St",
                "city": "Springfield"
            }
        }
        
        resp = requests.post(
            f"{services['gateway']}/v1/orders",
            json=public_request,
            headers={"Authorization": "Bearer test-token"}
        )
        
        assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
        
        body = resp.json()
        # Gateway should return public-facing order representation
        assert "orderId" in body
        assert "status" in body
        assert "estimatedDelivery" in body
        # Should NOT expose internal fields
        assert "internalOrderId" not in body
        assert "paymentGatewayRef" not in body

    def test_gateway_maps_order_service_errors_to_public_error_format(self, services):
        """
        Order service 422 (validation error) should be translated to a
        public-friendly error response, not leaked as-is.
        """
        invalid_request = {
            "cart": {"items": []},  # Empty cart — invalid
            "deliveryAddress": {"street": "123 Main St", "city": "Springfield"}
        }
        
        resp = requests.post(
            f"{services['gateway']}/v1/orders",
            json=invalid_request,
            headers={"Authorization": "Bearer test-token"}
        )
        
        assert resp.status_code == 400
        error = resp.json()
        assert "error" in error
        assert "message" in error
        # Public error format, not internal Spring validation format
        assert "timestamp" in error
        assert "path" in error
        # Should NOT expose internal service details
        assert "stackTrace" not in str(error)
        assert "internal" not in str(error).lower()

    def test_gateway_enforces_authentication(self, services):
        """Requests without valid tokens must be rejected at the gateway."""
        resp = requests.post(
            f"{services['gateway']}/v1/orders",
            json={"cart": {"items": [{"productId": "prod-1", "qty": 1}]}}
            # No Authorization header
        )
        assert resp.status_code == 401

    def test_gateway_rate_limiting_per_customer(self, services):
        """Gateway should rate-limit individual customers, not block all traffic."""
        headers = {"Authorization": "Bearer rate-limit-test-token"}
        
        # Send 20 rapid requests from the same customer
        responses = [
            requests.post(
                f"{services['gateway']}/v1/orders",
                json={"cart": {"items": [{"productId": "prod-1", "qty": 1}]}},
                headers=headers
            )
            for _ in range(20)
        ]
        
        status_codes = [r.status_code for r in responses]
        
        # Some should succeed, some should be rate-limited (429)
        assert 429 in status_codes, "Rate limiting not enforced"
        # But not all should fail — other customers should still work
        assert 201 in status_codes or 202 in status_codes, "All requests blocked (too aggressive)"

Data Consistency Testing Across Services

Distributed data consistency is one of the hardest problems in microservices. When an order is created, the inventory must be reserved, the payment must be charged, and the notification must be sent — all consistently. Testing this end-to-end requires verifying the state across multiple data stores.

# test_order_data_consistency.py
import pytest
import time
import psycopg2
import redis
import requests

class TestOrderDataConsistency:
    """
    Tests that verify data consistency across the order, inventory, and payment
    services after a completed order workflow.
    """

    def test_successful_order_consistent_across_all_services(
        self, gateway_url, order_db, inventory_db, redis_client
    ):
        """
        After a successful order:
        - order-service DB: order exists with status 'confirmed'
        - inventory-service DB: stock reduced by ordered quantity  
        - Redis: order summary cached with correct data
        """
        initial_stock = self._get_stock(inventory_db, "PROD-1")
        
        # Create an order
        resp = requests.post(
            f"{gateway_url}/v1/orders",
            json={
                "cart": {"items": [{"productId": "PROD-1", "qty": 2}]},
                "deliveryAddress": {"street": "123 Main St", "city": "Springfield"}
            },
            headers={"Authorization": "Bearer test-user-token"}
        )
        assert resp.status_code == 201
        order_id = resp.json()["orderId"]
        
        # Wait for async processing to complete (inventory reservation, etc.)
        self._wait_for_order_status(order_db, order_id, "confirmed", timeout=15)
        
        # Verify order service state
        order_row = self._get_order(order_db, order_id)
        assert order_row["status"] == "confirmed"
        assert order_row["total_amount"] is not None
        
        # Verify inventory service state
        final_stock = self._get_stock(inventory_db, "PROD-1")
        assert final_stock == initial_stock - 2, (
            f"Expected stock to decrease by 2: {initial_stock} -> {final_stock}"
        )
        
        # Verify cache is consistent with database
        cached = redis_client.hgetall(f"order:{order_id}")
        if cached:  # Cache is eventually consistent — may not exist yet
            assert cached[b"status"] == b"confirmed"
            assert cached[b"order_id"] == order_id.encode()

    def test_failed_payment_leaves_no_partial_state(
        self, gateway_url, order_db, inventory_db
    ):
        """
        When payment fails, the system must be in a consistent state:
        - No order record in order-service DB
        - Inventory reservation released (stock unchanged)
        - No partial payment record
        """
        initial_stock = self._get_stock(inventory_db, "PROD-1")
        
        resp = requests.post(
            f"{gateway_url}/v1/orders",
            json={
                "cart": {"items": [{"productId": "PROD-1", "qty": 1}]},
                "deliveryAddress": {"street": "123 Main St", "city": "Springfield"}
            },
            headers={"Authorization": "Bearer declined-card-token"}  # Token mapped to failing payment
        )
        
        assert resp.status_code in [402, 400], f"Expected payment failure, got {resp.status_code}"
        
        # Wait for any async cleanup
        time.sleep(3)
        
        # No order should exist
        order_id = resp.json().get("orderId")
        if order_id:
            order = self._get_order(order_db, order_id)
            assert order is None or order["status"] == "failed"
        
        # Stock must be restored
        final_stock = self._get_stock(inventory_db, "PROD-1")
        assert final_stock == initial_stock, (
            f"Inventory not restored after payment failure: {initial_stock} -> {final_stock}"
        )

    def _wait_for_order_status(self, db_conn, order_id, expected_status, timeout):
        deadline = time.time() + timeout
        while time.time() < deadline:
            order = self._get_order(db_conn, order_id)
            if order and order["status"] == expected_status:
                return
            time.sleep(0.5)
        raise AssertionError(
            f"Order {order_id} did not reach status '{expected_status}' within {timeout}s"
        )

    def _get_order(self, db_conn, order_id):
        with db_conn.cursor() as cur:
            cur.execute("SELECT * FROM orders WHERE id = %s", (order_id,))
            row = cur.fetchone()
            if row:
                return dict(zip([desc[0] for desc in cur.description], row))
        return None

    def _get_stock(self, db_conn, sku):
        with db_conn.cursor() as cur:
            cur.execute("SELECT available_quantity FROM inventory WHERE sku = %s", (sku,))
            row = cur.fetchone()
            return row[0] if row else 0

API Gateway Testing

The API gateway deserves its own test focus because it's the single point through which all external traffic passes. Beyond the boundary tests above, there are gateway-specific concerns worth testing independently:

// api-gateway.test.js
const supertest = require('supertest');
const { createGateway } = require('../src/gateway');

describe('API Gateway', () => {
  let gateway;

  beforeAll(async () => {
    gateway = await createGateway({
      orderServiceUrl: 'http://order-service-mock:8080',
      inventoryServiceUrl: 'http://inventory-service-mock:8080',
    });
  });

  describe('Request routing', () => {
    it('routes /v1/orders to order service', async () => {
      const resp = await supertest(gateway)
        .post('/v1/orders')
        .set('Authorization', 'Bearer valid-token')
        .send({ cart: { items: [] } });
      
      // Even an empty cart validation error confirms routing worked
      expect([400, 422]).toContain(resp.status);
    });

    it('routes /v1/products to product service, not order service', async () => {
      const resp = await supertest(gateway)
        .get('/v1/products/prod-123')
        .set('Authorization', 'Bearer valid-token');
      
      // Response should come from product service (different response shape)
      if (resp.status === 200) {
        expect(resp.body).toHaveProperty('sku');
        expect(resp.body).not.toHaveProperty('orderId');
      }
    });
  });

  describe('Timeout handling', () => {
    it('returns 504 when upstream service exceeds timeout', async () => {
      // order-service-mock configured to delay 10s for /orders/slow
      const resp = await supertest(gateway)
        .get('/v1/orders/slow-endpoint')
        .set('Authorization', 'Bearer valid-token')
        .timeout(15000);
      
      expect(resp.status).toBe(504);
      expect(resp.body.error).toBe('Gateway Timeout');
    });
  });

  describe('Response transformation', () => {
    it('removes internal fields from upstream responses', async () => {
      const resp = await supertest(gateway)
        .get('/v1/orders/ord_public_123')
        .set('Authorization', 'Bearer valid-token');
      
      if (resp.status === 200) {
        // These internal fields must never reach external clients
        expect(resp.body).not.toHaveProperty('internalId');
        expect(resp.body).not.toHaveProperty('paymentGatewayRef');
        expect(resp.body).not.toHaveProperty('_internal');
      }
    });

    it('adds correlation ID to all responses', async () => {
      const resp = await supertest(gateway)
        .get('/v1/orders/ord_123')
        .set('Authorization', 'Bearer valid-token');
      
      expect(resp.headers['x-correlation-id']).toBeDefined();
      expect(resp.headers['x-correlation-id']).toMatch(/^[0-9a-f-]{36}$/);
    });
  });

  describe('Circuit breaker', () => {
    it('returns 503 when order service circuit is open', async () => {
      // Trigger circuit breaker by making many failing requests
      for (let i = 0; i < 10; i++) {
        await supertest(gateway)
          .get('/v1/orders/force-error')
          .set('Authorization', 'Bearer valid-token');
      }
      
      // Circuit should now be open
      const resp = await supertest(gateway)
        .get('/v1/orders/ord_normal')
        .set('Authorization', 'Bearer valid-token');
      
      expect(resp.status).toBe(503);
      expect(resp.body.error).toContain('Service temporarily unavailable');
    });
  });
});

Keeping Integration Tests Fast

The biggest killer of integration test adoption is speed. Tests that take 20 minutes to run get disabled. Some techniques that keep Testcontainers-based tests fast:

Shared containers across tests — Use @ClassRule (JUnit 4) or static @Container (JUnit 5) to start containers once per test class, not once per test. The overhead of starting a PostgreSQL container (2-5 seconds) multiplied by 100 tests becomes significant.

Parallel test execution — Testcontainers allocates separate ports per container instance, so parallel test classes don't conflict. Configure Maven Surefire or Gradle to run test classes in parallel.

Reuse containers across test runs (development only) — Testcontainers supports withReuse(true) for development, where the container persists between runs and isn't torn down. This cuts startup time to near zero for local development. Disable in CI to ensure isolation.

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
    .withReuse(Boolean.parseBoolean(System.getenv().getOrDefault("TC_REUSE", "false")));

Seed data via SQL scripts — Prefer withInitScript("schema.sql") over application-layer data setup. SQL scripts run at container startup and are an order of magnitude faster than ORM-level inserts.

What to Test, What to Skip

Integration tests are expensive per-test compared to unit tests. Make deliberate choices about what earns integration test coverage:

Worth testing at the integration level:

  • Database query correctness (indexes, joins, transactions)
  • Cache interaction patterns (cache hits, misses, eviction)
  • Message serialization/deserialization round-trips
  • External API response parsing (mock the external API, but test the parsing code)
  • Transaction rollback behavior
  • Data consistency across service boundaries

Better tested at the unit level:

  • Business logic (calculations, state machines, validation rules)
  • Error handling branches
  • Data transformation logic

Better tested at the E2E level:

  • Full user journeys
  • Browser-specific behavior
  • Performance under load

The integration test pyramid for microservices is not about coverage percentage — it's about testing the right things at the right level. Get the layer right, and your tests will be fast enough to run on every commit and reliable enough to trust when they pass.

Read more