Your test passes, but production breaks. You just discovered you tested a lie. Without well-used test doubles, you're not testing real code — you're testing a faded photo of it.
We, at Meteora Web, have been there. We manage Laravel platforms, a clothing ERP, and e-commerce sites with dozens of integrations. Every time a test struggles to isolate an external dependency — an API call, a database, a file system — there's a double risk: either the test becomes slow and fragile, or it becomes useless. Test doubles (mock, stub, spy) are the answer. But they must be used in the right place.
This guide starts from a concrete problem: how to isolate the code you're testing without lying to yourself. No list of academic definitions. Real cases, copyable code, decisions you can make today.
Why Isolate Code with Test Doubles Instead of Testing Everything Together?
Imagine testing a function that calculates a discount on an order and calls an external shipping service for cost. If during the test we call the real shipping service, this happens:
- the test depends on the network (slow, 2-10 seconds per call)
- the test depends on the remote server (if it's down, the test fails for no reason)
- the test costs money (paid API calls counted)
You're not testing discount logic — you're testing infrastructure. Test doubles solve this: replace the shipping service with a fake object that responds in milliseconds and never fails. But be careful: over-isolating leads to tests that pass even when the real system is broken. The point is to isolate only external dependencies, not internal logic.
Common Isolation Mistakes
The first mistake: mock everything. If you mock even the User Repository to avoid touching the database, your test loses validity. Business logic emerges precisely from interaction with that data. We prefer to isolate only external services (API, mail, file system) and keep real database interactions when the test is fast and controlled. We come from a PMI: time is money, slow tests = tests nobody runs.
Sponsored Protocol
What to do now: in your next test, ask yourself: "Is this dependency under my control? If external, replace it. If internal (local DB, cache, local file), keep it real."
Which Test Double to Use: Stub, Mock, or Spy?
The confusion among these three is the main reason tests become brittle. Here's the operational difference, with PHPUnit examples.
Stub: When You Want a Predefined Response
A stub is an object that provides fixed data. Use it when you don't care how it's called, only that it returns a value to let the test proceed.
// Stub for a shipping service
$shippingStub = $this->createStub(ShippingService::class);
$shippingStub->method('getCost')->willReturn(12.50);
$calculator = new DiscountCalculator($shippingStub);
$total = $calculator->applyDiscount(100, 'VIP');
$this->assertEquals(87.50, $total);
Note: the stub does not verify how many times getCost is called. It returns 12.50 every time. Useful for isolation.
Mock: When You Need to Verify Interactions
A mock is like a stub, but with expectations on which methods are called. Use it when you must ensure the function correctly called a dependency. Classic example: sending an email after a purchase.
Sponsored Protocol
$mailerMock = $this->createMock(Mailer::class);
$mailerMock->expects($this->once())
->method('send')
->with('customer@example.com', 'Order confirmed');
$checkout = new Checkout($mailerMock);
$checkout->process($order);
If the send method is not called exactly once with those parameters, the test fails. Powerful, but use sparingly: too many expectations make tests fragile to harmless refactoring.
Spy: Records Calls for Later Verification
A spy is a more flexible mock. It has no predefined expectations: it records what happens, then you analyze. PHPUnit doesn't have a native spy, but you can create one with a mock without expectations and then use assertions.
$spy = $this->createMock(Logger::class);
// no expects
$system = new System($spy);
$system->doSomething();
// Verify later
$this->assertTrue($spy->logCalled); // if Logger has a logCalled property
In practice, tedious to write by hand. We use mocks almost always. Spies are reserved for situations where call order matters and we don't want to overspecify.
What to do now: take an existing test and identify every external dependency. For each, decide: stub for data, mock for interactions, spy for complex sequences. No other types.
Mocking in PHPUnit: Minimal Configuration and Realistic Assertions
PHPUnit offers createMock and createStub since version 9. The difference is subtle: createMock creates a mock that by default returns null for every method. createStub is identical but more explicit. We recommend createStub for data and createMock for expectations.
Concrete Example: Repository and External Service
Suppose we have an OrderProcessor that saves the order and calls a payment service. We want to test only the save logic.
Sponsored Protocol
class OrderProcessorTest extends TestCase
{
public function test_it_saves_order_and_calls_payment()
{
$paymentMock = $this->createMock(PaymentGateway::class);
$paymentMock->expects($this->once())
->method('charge')
->with(100.0)
->willReturn(true);
$repositoryStub = $this->createStub(OrderRepository::class);
$repositoryStub->method('save')->willReturn(123);
$processor = new OrderProcessor($paymentMock, $repositoryStub);
$result = $processor->process(100.0);
$this->assertEquals(123, $result);
}
}
Note: we are testing that the processor calls payment once and saves the order. We do not test real PaymentGateway behavior. That's fine because PaymentGateway is an external dependency (Stripe API).
Avoid Over-Mocking
If you need mocks for three different classes in a single test, you are probably testing too much. Isolate only the boundary: the class under test and one external collaborator. Others should be real objects or value objects.
What to do now: in your next test, limit mocks to a maximum of two. If you exceed, restructure the code. A test requiring three mocks is a design smell.
Test Isolation Without Faking Reality: When NOT to Use Test Doubles
Test doubles are surgical instruments, not hammers. There are cases where using them is wrong:
- Value objects: objects like Money, Address, Email. Never mock them. Create real ones.
- Internal Repositories: if you use a local in-memory database (SQLite in memory), do not mock the Repository. Write lightweight integration tests.
- Services with critical internal logic: if the tax calculation service contains business logic, do not mock it. Either test it integrated or test it as a separate unit.
We've seen projects where every test mocks even the catalog repository: the result is a test suite that always passes because it never touches real data. When in production a product field comes back null, the test won't catch it. Test isolation is not an end — it's a means. The goal is to know if the code works.
Sponsored Protocol
What to do now: review your test suite: do you find mocks of simple classes like DTO or Entity? Remove them. Use real objects with fake data arrays.
How to Set Up a Test Doubles Strategy for a PHP PMI
We work every day with Laravel and PHPUnit. Here's our practical rule, born from years of mistakes:
- Identify system boundaries: everything outside your PHP process (API, file system, email, time) deserves a double.
- Use stubs for response data (e.g., API returning JSON).
- Use mocks for mandatory interactions (e.g., guarantee an email is sent).
- Avoid spies unless debugging; prefer explicit mocks.
- Keep unit tests fast under 100ms. If a mock test exceeds 200ms, you're probably mocking wrong or testing integration.
- Use a trait or factory for common service mocks to avoid duplicated setup.
We, at Meteora Web, built an internal library to handle mocks of external services (shipping API, payment, SMS). It reduces boilerplate by 70% and keeps tests readable. The secret? Every mock must be easy to understand and easy to modify.
Sponsored Protocol
What to do now: open your Laravel project and create a helper in tests/Helpers/MockHelper.php with a method to create a mock of your most used service. Example:
function mockShippingService(float $cost = 10.0): ShippingService
{
$stub = \PHPUnit\Framework\TestCase::createStub(ShippingService::class);
$stub->method('getCost')->willReturn($cost);
return $stub;
}
Then use this helper in all tests that need to isolate shipping.
What to Do Now to Improve Your Tests with Mock, Stub, Spy
Operational summary to finish the guide and put it into practice immediately:
- Review a test that often fails due to external dependencies. Replace that dependency with a mock or stub using PHPUnit's createMock or createStub.
- Limit mocks to one per test for most cases. If more are needed, evaluate if the code has too many responsibilities.
- Add a test that verifies a key interaction (e.g., API call or email) with a mock and expects. Make it fail intentionally to confirm it works.
- Check your suite execution time: if a mock test takes more than 200ms, you're probably doing something wrong (e.g., loading the full app).
- Read the official PHPUnit documentation on test doubles for advanced options: PHPUnit Test Doubles.
And remember: test isolation is meant to protect real code, not to create a bubble. Use mock, stub, and spy with the same care you choose a business partner: only when needed, with clear criteria.
For a complete testing strategy, check our pillar guide on software testing.