The Microservice Testing Problem
Testing a monolith is relatively straightforward: everything runs in one process, you can mock at the class boundary, and your integration tests just need a database. Testing a microservice is harder. Your service calls an authentication service, a notification service, a billing service, and maybe three internal APIs. Testing your service in isolation means replacing all of those with something that behaves like the real thing without being the real thing.
Get this wrong and you end up with one of two failure modes: tests that are so tightly coupled to real services that they're flaky and slow, or tests that mock so aggressively they don't catch real integration bugs.
Stubs vs. Mocks: A Precise Distinction
These terms are often used interchangeably but they mean different things, and the distinction matters.
A stub returns predetermined responses. It doesn't care about how many times it's called or with what arguments. It just returns canned data.
A mock has expectations. It verifies that specific calls were made, with specific arguments, a specific number of times. It will fail the test if those expectations aren't met.
Use stubs when you care about the outcome of a dependency interaction. Use mocks when you care about whether the interaction happened at all.
In PHP:
// STUB: Returns canned data, no expectations
$billingService = $this->createStub(BillingServiceInterface::class);
$billingService->method('getBalance')
->willReturn(new Balance(cents: 5000, currency: 'USD'));
// MOCK: Has expectations about the call
$notificationService = $this->createMock(NotificationServiceInterface::class);
$notificationService->expects($this->once())
->method('sendInvoiceEmail')
->with($this->equalTo($invoice->id));
Use the right tool for each situation. Over-mocking (asserting on calls when you only care about outcomes) makes tests brittle because they break whenever implementation details change.
HTTP Client Stubs for External APIs
Most service-to-service communication happens over HTTP. Laravel's Http::fake() makes it easy to stub HTTP calls without changing your application code:
public function test_creates_invoice_and_notifies_billing(): void
{
Http::fake([
'billing.internal/api/accounts/*' => Http::response([
'id' => 'acct_123',
'balance' => 5000,
'currency' => 'USD',
], 200),
'notifications.internal/api/send' => Http::response([
'queued' => true,
'message_id' => 'msg_abc',
], 202),
]);
$service = new InvoiceCreationService(
new HttpBillingClient(),
new HttpNotificationClient(),
);
$invoice = $service->create($this->invoiceData);
$this->assertEquals('pending', $invoice->status);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'notifications.internal');
});
}
The fake intercepts real HTTP calls and returns your canned responses. Your production code (HttpBillingClient, HttpNotificationClient) runs unchanged; the HTTP layer is the seam being stubbed.
Wiremock for Persistent Service Stubs
For more complex scenarios—or when you need stubs to run across multiple test runs, in local development, or in staging—WireMock provides a standalone stub server.
Start WireMock in Docker:
docker run -p 8080:8080 wiremock/wiremock:latest
Define stubs via its REST API or JSON files:
{
"request": {
"method": "GET",
"urlPattern": "/api/accounts/[a-z0-9]+"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"id": "acct_123",
"balance": 5000,
"currency": "USD"
}
}
}
Point your service at WireMock in tests:
# .env.testing
BILLING_SERVICE_URL=http://localhost:8080
WireMock supports request matching on headers, query parameters, and request body patterns. It also has recording mode: point it at a real service, let it record real interactions, then replay them as stubs. This is invaluable for capturing complex API behaviors without hand-authoring every response.
Consumer-Driven Contract Testing (Revisited)
When multiple services stub each other, you risk stub drift: your stub for Service B's API stops matching what Service B actually does, so your tests pass but production breaks.
The solution is contract testing (covered in detail in our contract testing article). The key insight here is that stubs should be generated from contracts, not hand-authored.
With Pact:
- Service A (consumer) defines what it expects from Service B
- Pact generates a stub for Service A's tests from that expectation
- Service B verifies it can satisfy that expectation in its own tests
Now your stubs are always in sync with what the real service can deliver.
Testing Async Communication (Message Queues)
Microservices often communicate through message queues (RabbitMQ, SQS, Kafka) rather than synchronous HTTP. Testing this requires a different approach.
For testing the publisher side, assert that the right message was published without actually using a queue:
public function test_publishes_invoice_created_event(): void
{
Event::fake([InvoiceCreated::class]);
$service = new InvoiceService();
$service->create($this->invoiceData);
Event::assertDispatched(InvoiceCreated::class, function ($event) {
return $event->invoice->status === 'pending';
});
}
For testing the consumer side (a service that processes messages), test the handler directly without going through the queue:
public function test_marks_invoice_paid_when_payment_received(): void
{
$invoice = Invoice::factory()->pending()->create();
$handler = new PaymentReceivedHandler();
$handler->handle(new PaymentReceived(
invoice_id: $invoice->id,
amount: $invoice->total,
payment_method: 'card',
));
$this->assertEquals('paid', $invoice->fresh()->status);
}
For integration testing queue-based workflows, use a real queue in memory:
// phpunit.xml
<env name="QUEUE_CONNECTION" value="sync"/>
With sync, dispatched jobs run immediately in the same process, making queue-based flows testable without a running queue server.
Service Virtualization at Scale
For larger microservice ecosystems, individual stubs per test become unmaintainable. Service virtualization tools provide a shared stub layer for the entire test environment.
Hoverfly is a lightweight service virtualization tool that can run as a proxy:
hoverctl start
hoverctl mode capture
# Run through your workflows; Hoverfly records all service calls
hoverctl mode simulate
# Now Hoverfly replays recorded responses; no real services needed
Mountebank supports TCP-level virtualization, making it useful for services that communicate via protocols other than HTTP (gRPC, message queues, databases).
Service virtualization is most valuable in staging environments where you want to test against realistic data without calling real third-party APIs.
The Test Double Hierarchy
Choose the right type of test double for each situation:
| Scenario | Tool |
|---|---|
| Unit testing internal logic | PHPUnit mocks/stubs |
| Testing HTTP client code | Http::fake() |
| Integration tests needing realistic service behavior | WireMock |
| Preventing stub drift across services | Pact (contract testing) |
| Staging environment isolation | Hoverfly / Mountebank |
Don't reach for WireMock when Http::fake() is sufficient. Don't hand-author stubs when Pact can generate them from contracts.
Testing Resilience: What Happens When Services Fail?
The most overlooked aspect of microservice testing is failure scenarios. Your service will encounter downstream failures. Do you handle them gracefully?
Test circuit breakers and retry logic:
public function test_falls_back_to_cache_when_billing_service_unavailable(): void
{
Http::fake([
'billing.internal/*' => Http::response(null, 503),
]);
// Pre-warm the cache with last-known balance
Cache::put('billing.balance.client_42', 5000, now()->addHour());
$service = new InvoiceService();
$result = $service->getClientBalance(42);
// Should return cached value, not throw an exception
$this->assertEquals(5000, $result->cents);
$this->assertTrue($result->is_cached);
}
public function test_throws_service_exception_when_no_cache_available(): void
{
Http::fake([
'billing.internal/*' => Http::response(null, 503),
]);
Cache::flush();
$this->expectException(ServiceUnavailableException::class);
(new InvoiceService())->getClientBalance(42);
}
These tests ensure your service degrades gracefully rather than cascading failures to your users.
Practical Takeaways
- Use stubs for outcomes you don't care about testing; use mocks only when interaction verification matters
Http::fake()is sufficient for most HTTP service tests; reach for WireMock when you need persistent or shared stubs- Generate stubs from contracts using Pact to prevent stub drift
- Test async communication by testing handlers directly and using
QUEUE_CONNECTION=syncin tests - Always test failure scenarios: timeouts, 503s, malformed responses
Need help building reliable systems? We help teams architect software that scales. scopeforged.com