Microservices Testing Pyramid

Philip Rehberger Jan 31, 2026 7 min read

Build a testing strategy for microservices. Balance unit, integration, contract, and end-to-end tests effectively.

Microservices Testing Pyramid

The testing pyramid guides test distribution across different levels: many unit tests at the base, fewer integration tests in the middle, fewer end-to-end tests at the top. In microservices architectures, this pyramid gets more complex. Service boundaries create new testing challenges, and the interactions between services require their own testing strategies.

Microservices testing must verify that individual services work correctly, that services integrate properly with their dependencies, and that the entire system functions together. Each level catches different types of bugs with different tradeoffs between speed, reliability, and scope.

Unit Tests: The Foundation

Unit tests verify individual components in isolation. They're fast, reliable, and provide precise feedback about what's broken. In microservices, unit tests cover business logic within each service.

The key is isolating the code under test from external dependencies. Database calls, HTTP clients, message queues, and other services are mocked or stubbed. This isolation makes tests fast and deterministic. When a unit test fails, you know exactly where the problem is.

The following test class demonstrates how to mock external dependencies while testing business logic. You'll create mock objects for the inventory and payment clients, allowing you to test the order service's behavior without actual network calls.

class OrderServiceTest extends TestCase
{
    private OrderService $service;
    private MockObject $inventoryClient;
    private MockObject $paymentClient;

    protected function setUp(): void
    {
        $this->inventoryClient = $this->createMock(InventoryClient::class);
        $this->paymentClient = $this->createMock(PaymentClient::class);

        $this->service = new OrderService(
            $this->inventoryClient,
            $this->paymentClient
        );
    }

    public function test_creates_order_when_inventory_available(): void
    {
        $this->inventoryClient
            ->expects($this->once())
            ->method('checkAvailability')
            ->with(['SKU-123' => 2])
            ->willReturn(true);

        $this->paymentClient
            ->expects($this->once())
            ->method('authorize')
            ->willReturn(new PaymentAuthorization('auth-123'));

        $order = $this->service->createOrder(
            customerId: 1,
            items: [['sku' => 'SKU-123', 'quantity' => 2]],
            paymentMethod: 'card-token'
        );

        $this->assertEquals('pending', $order->status);
        $this->assertEquals('auth-123', $order->paymentAuthorizationId);
    }

    public function test_rejects_order_when_inventory_unavailable(): void
    {
        $this->inventoryClient
            ->method('checkAvailability')
            ->willReturn(false);

        $this->expectException(InsufficientInventoryException::class);

        $this->service->createOrder(
            customerId: 1,
            items: [['sku' => 'SKU-123', 'quantity' => 100]],
            paymentMethod: 'card-token'
        );
    }
}

Unit tests should cover business rules, edge cases, and error handling. They form the bulk of your test suite because they're cheap to write and run. A microservice might have hundreds of unit tests that run in seconds.

Integration Tests: Service Internals

Integration tests verify that a service works correctly with its direct dependencies: databases, caches, message queues, and external APIs. They test the integration between your code and real infrastructure.

Unlike unit tests, integration tests use real implementations of dependencies. This means you need a database running, which makes tests slower but catches real-world issues that mocks would hide.

class OrderRepositoryIntegrationTest extends TestCase
{
    use RefreshDatabase;

    public function test_persists_order_with_items(): void
    {
        $repository = new OrderRepository();

        $order = $repository->create([
            'customer_id' => 1,
            'status' => 'pending',
            'items' => [
                ['sku' => 'SKU-123', 'quantity' => 2, 'price' => 1999],
                ['sku' => 'SKU-456', 'quantity' => 1, 'price' => 2999],
            ],
        ]);

        $this->assertDatabaseHas('orders', [
            'id' => $order->id,
            'customer_id' => 1,
        ]);

        $this->assertDatabaseHas('order_items', [
            'order_id' => $order->id,
            'sku' => 'SKU-123',
        ]);
    }

    public function test_finds_orders_by_customer(): void
    {
        Order::factory()->count(3)->create(['customer_id' => 1]);
        Order::factory()->count(2)->create(['customer_id' => 2]);

        $repository = new OrderRepository();
        $orders = $repository->findByCustomer(1);

        $this->assertCount(3, $orders);
    }
}

Integration tests are slower than unit tests because they use real databases and infrastructure. They catch bugs that unit tests miss: SQL errors, constraint violations, and configuration problems. Run them in CI but not necessarily on every save.

Contract Tests: Service Boundaries

Contract tests verify that services communicate correctly. When Service A calls Service B, both sides must agree on the API contract: request format, response format, and behavior. Contract tests ensure this agreement holds.

Consumer-driven contract testing starts from the consumer's expectations. The consumer (Service A) defines what it expects from the provider (Service B). The provider runs tests verifying it meets those expectations. This approach catches breaking changes before they reach production.

// Consumer test (Order Service expects this from Inventory Service)
class InventoryContractTest extends TestCase
{
    public function test_check_availability_contract(): void
    {
        $pact = new PactBuilder();
        $pact
            ->given('SKU-123 has 10 items in stock')
            ->uponReceiving('a request to check availability')
            ->with([
                'method' => 'POST',
                'path' => '/api/inventory/check',
                'body' => ['sku' => 'SKU-123', 'quantity' => 5],
            ])
            ->willRespondWith([
                'status' => 200,
                'body' => [
                    'available' => true,
                    'quantity_available' => 10,
                ],
            ]);

        $client = new InventoryClient($pact->getMockServerUrl());
        $result = $client->checkAvailability('SKU-123', 5);

        $this->assertTrue($result->available);
        $pact->verify();
    }
}

// Provider test (Inventory Service verifies it meets the contract)
class InventoryProviderTest extends TestCase
{
    public function test_meets_order_service_contract(): void
    {
        $pactVerifier = new PactVerifier();
        $pactVerifier
            ->providerState('SKU-123 has 10 items in stock', function () {
                Inventory::factory()->create(['sku' => 'SKU-123', 'quantity' => 10]);
            })
            ->verify();
    }
}

Contract tests catch breaking changes before deployment. If the Inventory Service changes its API in a way that breaks the Order Service's expectations, the contract test fails. This feedback is faster and cheaper than discovering integration failures in production.

Component Tests: Service in Isolation

Component tests verify a complete service from its external interface, but with dependencies mocked. They test the service as a black box: HTTP requests in, HTTP responses out. Internal implementation details are hidden.

This approach tests the entire request lifecycle within a service without requiring external services to be running. You can verify routing, middleware, validation, and response formatting all work together correctly.

class OrderServiceComponentTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Mock external services
        Http::fake([
            'inventory-service/*' => Http::response(['available' => true], 200),
            'payment-service/*' => Http::response(['authorization_id' => 'auth-123'], 200),
        ]);
    }

    public function test_create_order_endpoint(): void
    {
        $response = $this->postJson('/api/orders', [
            'customer_id' => 1,
            'items' => [
                ['sku' => 'SKU-123', 'quantity' => 2],
            ],
            'payment_method' => 'card-token',
        ]);

        $response->assertStatus(201);
        $response->assertJsonStructure([
            'data' => ['id', 'status', 'items', 'total'],
        ]);
    }

    public function test_returns_error_when_inventory_unavailable(): void
    {
        Http::fake([
            'inventory-service/*' => Http::response(['available' => false], 200),
        ]);

        $response = $this->postJson('/api/orders', [
            'customer_id' => 1,
            'items' => [['sku' => 'SKU-123', 'quantity' => 2]],
            'payment_method' => 'card-token',
        ]);

        $response->assertStatus(422);
        $response->assertJson(['error' => 'insufficient_inventory']);
    }
}

Component tests cover the full request lifecycle within a service: routing, middleware, validation, business logic, and response formatting. They catch integration issues within the service without needing external dependencies running.

End-to-End Tests: The Complete System

End-to-end tests verify that the entire system works together. Real services communicate through real infrastructure. These tests catch issues that only appear when everything runs together.

E2E tests are the closest simulation of actual user behavior. They start from a user action and verify the complete flow through all involved services. This is valuable but comes with significant tradeoffs.

class CheckoutE2ETest extends TestCase
{
    public function test_complete_checkout_flow(): void
    {
        // Create test data
        $customer = $this->createCustomer();
        $this->addToCart($customer->id, 'SKU-123', 2);

        // Execute checkout
        $response = $this->actingAs($customer)
            ->postJson('/api/checkout', [
                'payment_method' => $this->getTestPaymentMethod(),
                'shipping_address' => $this->getTestAddress(),
            ]);

        $response->assertStatus(201);
        $orderId = $response->json('data.id');

        // Verify order was created
        $this->assertDatabaseHas('orders', [
            'id' => $orderId,
            'status' => 'pending',
        ]);

        // Wait for async processing
        $this->waitFor(function () use ($orderId) {
            return Order::find($orderId)->status === 'confirmed';
        }, timeout: 30);

        // Verify inventory was decremented
        $this->assertEquals(
            8, // Started with 10, ordered 2
            $this->getInventoryQuantity('SKU-123')
        );

        // Verify payment was captured
        $this->assertNotNull(Order::find($orderId)->payment_capture_id);
    }
}

E2E tests are slow, flaky, and expensive to maintain. They should be few in number, covering critical user journeys rather than edge cases. Use them to verify that services integrate correctly, not to test business logic.

Test Distribution

The testing pyramid suggests a distribution: many unit tests, fewer integration tests, fewer E2E tests. In microservices, this might look like:

  • 70% unit tests: Fast, focused, comprehensive coverage of business logic
  • 20% integration/component tests: Verify database operations, internal integrations
  • 8% contract tests: Verify service boundaries and API agreements
  • 2% E2E tests: Verify critical paths through the entire system

This distribution provides fast feedback for most bugs while still catching integration issues. Adjust based on your architecture's complexity and failure patterns.

Conclusion

Microservices testing requires multiple strategies working together. Unit tests verify business logic in isolation. Integration tests verify database and infrastructure interactions. Contract tests verify service-to-service agreements. Component tests verify services as black boxes. E2E tests verify the complete system.

Each level catches different bugs with different tradeoffs. Build a test suite that's heavy on fast, reliable tests (unit and component) and light on slow, flaky tests (E2E). Use contract tests to catch integration issues early. The goal is confidence that your system works, achieved through the right mix of testing strategies.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.