Skip to content

Instantly share code, notes, and snippets.

@dazz
Last active June 15, 2026 10:27
Show Gist options
  • Select an option

  • Save dazz/58a3251e431f2608674fab4a4b1488dd to your computer and use it in GitHub Desktop.

Select an option

Save dazz/58a3251e431f2608674fab4a4b1488dd to your computer and use it in GitHub Desktop.

Mocking vs. Fakes

Why Mocks Are Slowly Destroying Your Test Suite – And How Fakes Save It


TL;DR

Mocks test implementation details. Fakes test behavior.

In hexagonal architecture, ports are promises, adapters are implementations. Mocks break this promise. Fakes keep it.

When you use mocks in application services, you're not testing your business logic – you're testing whether your code still looks the same as yesterday.


Why Developers Reach for Mocks: Understanding the Mock Obsession

Before diving into technical details, we need to understand: Why is mock culture so persistent?

The Psychology Behind Mock Obsession

1. Misunderstood Unit Test Principles

Many developers learn:

"Unit tests should be isolated. Each class is tested separately."

What they make of it:

"I must mock all dependencies, otherwise I'm not testing in isolation."

The truth:

  • "Unit" doesn't mean "class", it means "behavior" (use case, feature)
  • "Isolated" doesn't mean "no real objects", it means "without external systems" (DB, network, filesystem)

A test with an in-memory repository is isolated. It needs no DB, no fixtures, no setup.


2. Cargo Cult from Blog Posts and Tutorials

Google "how to test in PHP" → 90% of tutorials show mocks.

Why?
Because mocks:

  • Are quick to explain (5 lines of code)
  • Look fancy (->expects($this->once()))
  • Are built into most testing frameworks

What's missing?
The explanation of when mocks make sense and when they don't.

The result: Junior developers blindly copy the pattern and mock everything.


3. False Prestige: "Sophisticated Engineering"

$mock->expects($this->exactly(2))
    ->method('save')
    ->withConsecutive(
        [$this->callback(fn($o) => $o->status === 'pending')],
        [$this->callback(fn($o) => $o->status === 'confirmed')]
    )
    ->willReturnOnConsecutiveCalls($order1, $order2);

This looks like sophisticated engineering.
This feels like control over every aspect of the system.
This is actually just syntactic sugar for a fragile test setup.

The illusion: "The more complex my mock, the better my test."
The reality: "The more complex my mock, the more often it breaks."


4. Lack of Experience with the Pain

When you're new to a project:

  • Mocks feel fast (3 minutes setup)
  • Mocks feel flexible (configurable)
  • Mocks feel safe (isolated)

After 6 months:

  • Mocks are slow (20 minutes per refactoring to fix all tests)
  • Mocks are fragile (change one method, 15 tests break)
  • Mocks are unsafe (tests green, feature broken)

But: The person who wrote the mock is already on the next team.
The pain hits the maintainer, not the author.


5. Fear of "Too Much Code"

A common argument:

"A fake is 50 lines of code. A mock is 5 lines. Mocks are more efficient."

The math doesn't add up:

// Fake: write once
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
    private array $orders = [];
    
    public function save(Order $order): void {
        $this->orders[$order->id] = $order;
    }
    
    public function findById(string $id): ?Order {
        return $this->orders[$id] ?? null;
    }
}
// 15 lines, written once

// In 10 tests:
$orders = new InMemoryOrderRepository();
// 1 line per test = 10 lines

vs.

// Mock: new in every test
$orders = $this->createMock(OrderRepositoryInterface::class);
$orders->expects($this->once())
    ->method('save')
    ->with($this->isInstanceOf(Order::class));
$orders->expects($this->once())
    ->method('findById')
    ->willReturn($order);
// 8 lines per test

// In 10 tests = 80 lines

Fake: 15 + 10 = 25 lines
Mocks: 80 lines

And then comes maintenance:

  • Fake: Interface changes → update 1 place
  • Mock: Interface changes → update 10 tests

The Core Emotional Problem: Loss of Control

Mocks give you the feeling of control:

"I tell the system what to do. I control every call."

This is an illusion.

You don't control the system – you control your expectations of the system.

Example:

// With mock: "I expect save() to be called"
$mock->expects($this->once())->method('save');

// With fake: "I verify the order was saved"
$this->assertNotNull($repo->findById($order->id));

The mock tests your assumption.
The fake tests reality.

Which is more trustworthy?


The Mock Justification: "But Martin Fowler Says..."

Yes, Martin Fowler wrote about mocks. But he also wrote:

"Mockist TDD leads to a different style of testing than classic TDD. Classicists prefer to test the state of the object, mockists prefer to test the behavior."

And later:

"The danger of mockist testing is that it can lead to tests that are too focused on implementation details rather than on the behavior of the system."

Fowler warns against mock overuse.


The Core Problem: Mocks Police Your Implementation

Mocks Tell You HOW to Write Your Code

When you write a mock, you're not just testing your function's API – you're dictating exactly what happens inside that function:

public function testCreatesOrder(): void
{
    $repo = $this->createMock(OrderRepositoryInterface::class);
    
    // This mock says: "You MUST call save() exactly once"
    $repo->expects($this->once())->method('save');
    
    // This mock says: "You MUST call findById() with this exact parameter"
    $repo->expects($this->once())
        ->method('findById')
        ->with('cust-123');
    
    $service = new CreateOrderService($repo);
    $service->execute('cust-123', $items);
}

What does this test care about?

  • That save() is called exactly once (not zero, not twice)
  • That findById() is called with the exact parameter
  • The order of these calls
  • The number of these calls

What should this test care about?

  • That an order is created
  • That the order can be retrieved later

The mock is micromanaging your implementation.


The Refactoring Trap

Imagine you want to optimize your code:

// Version 1: Call repository twice
public function execute(string $customerId, array $items): Order
{
    $customer = $this->customers->findById($customerId);
    $order = Order::create($customer, $items);
    $this->orders->save($order);
    
    $payment = $this->gateway->charge($customer->paymentMethodId, $order->total);
    $order->markAsPaid($payment->transactionId);
    $this->orders->save($order); // Second save
    
    return $order;
}

// Version 2: Cache in object, call repository once
public function execute(string $customerId, array $items): Order
{
    $customer = $this->customers->findById($customerId);
    $order = Order::create($customer, $items);
    
    $payment = $this->gateway->charge($customer->paymentMethodId, $order->total);
    $order->markAsPaid($payment->transactionId);
    
    $this->orders->save($order); // Only one save
    
    return $order;
}

With mocks: Your test breaks. It expected exactly(2) calls to save().
With fakes: Your test stays green. The behavior is identical.

The problem: The mock is policing your implementation, not your API.


Mocks Make You Fear Refactoring

When tests break during refactoring, you face a choice:

  1. Revert the refactoring – "The tests are red, so my change must be wrong"
  2. Fix all the mocks – "I need to update 15 test files to change one line of code"

Both options are wrong. The real problem is: Your tests are testing the wrong thing.

// This is what you SHOULD be testing:
public function testCreatesOrderSuccessfully(): void
{
    $orders = new InMemoryOrderRepository();
    $service = new CreateOrderService($orders);
    
    $order = $service->execute('cust-123', $items);
    
    // Test the API contract, not the implementation
    $this->assertNotNull($orders->findById($order->id));
    $this->assertTrue($order->isPaid());
}

This test doesn't care:

  • How many times you call save()
  • Whether you cache objects
  • What internal optimizations you make

It only cares: Does the feature work?


Personal Experience: Why Mocks Make Me Angry

"The first thing I delete when refactoring is the mock expectations."

When I see a test with mock expectations, I know:

  • Someone is telling me HOW to write my code
  • This test will break if I change ANYTHING internally
  • I'm not free to improve the implementation

Mocks are code reviewers that never sleep.
They judge every line you write, forever.

And unlike a real code reviewer, they can't be convinced that your change is better.


Questions to Ask Yourself

These aren't accusations – they're reflections. If you find yourself nodding, it might be time to reconsider your approach.

"Does this test check the feature or the code?"

public function testCreatesOrder(): void
{
    $repo = $this->createMock(OrderRepositoryInterface::class);
    $repo->expects($this->once())->method('save');
    
    $service = new CreateOrderService($repo);
    $service->execute($customerId, $items);
}

What does this test verify?

  • ✅ That save() is called
  • ❌ That an order is created
  • ❌ That the order is saved
  • ❌ That the order is retrievable

It tests the code, not the feature.


"Would this test work in production?"

// Mock test
$repo = $this->createMock(OrderRepositoryInterface::class);
$repo->method('findById')->willReturn($order);

// Replace with Doctrine:
$repo = new DoctrineOrderRepository($entityManager);

// Question: Is the test still meaningful?

Answer: No. The mock always returns $order, even if it doesn't exist.


"How long does it take to debug this test?"

public function testComplexBusinessLogic(): void
{
    $orders = $this->createMock(OrderRepositoryInterface::class);
    $customers = $this->createMock(CustomerRepositoryInterface::class);
    $payments = $this->createMock(PaymentGatewayInterface::class);
    $discounts = $this->createMock(DiscountRepositoryInterface::class);
    
    // 30 lines of mock setup...
    
    $service->execute('cust-123', $items);
    
    // Test fails: Expected 89.99, got 99.99
}

How do you figure out why the discount wasn't applied?

With mocks: Read code, guess, write more mocks
With fakes: XDebug → Step Into → See state → Bug found


"What happens with this refactoring?"

// Before
public function execute(string $customerId, array $items): Order
{
    $customer = $this->customers->findById($customerId);
    $order = Order::create($customer, $items);
    $this->orders->save($order);
    return $order;
}

// After (performance optimization: batch save)
public function execute(string $customerId, array $items): Order
{
    $customer = $this->customers->findById($customerId);
    $order = Order::create($customer, $items);
    // We don't save immediately, only on flush()
    return $order;
}

public function flush(): void
{
    $this->orders->saveAll(); // Batch insert
}

Mock test: ❌ Breaks because save() is no longer called
Fake test: ✅ Works because the behavior is the same

The question: Why should improved code break the test?


"What's the goal?"

If the goal is:

  • "I want to ensure my methods are called" → Mocks

If the goal is:

  • "I want to ensure my features work" → Fakes

What matters more for your business?


The Problem with Mocks: A Realistic Scenario

Starting Point

// Application Service
final class CreateOrderService
{
    public function __construct(
        private OrderRepositoryInterface $orders,
        private CustomerRepositoryInterface $customers,
        private PaymentGatewayInterface $paymentGateway,
    ) {}

    public function execute(string $customerId, array $items): Order
    {
        $customer = $this->customers->findById($customerId);
        if (!$customer) {
            throw new CustomerNotFoundException();
        }

        $order = Order::create($customer, $items);
        $this->orders->save($order);
        
        $payment = $this->paymentGateway->charge(
            $customer->paymentMethodId,
            $order->total
        );
        
        $order->markAsPaid($payment->transactionId);
        $this->orders->save($order);
        
        return $order;
    }
}

Mock-Based Test

final class CreateOrderServiceTest extends TestCase
{
    public function testCreatesOrderAndChargesCustomer(): void
    {
        $customers = $this->createMock(CustomerRepositoryInterface::class);
        $orders = $this->createMock(OrderRepositoryInterface::class);
        $gateway = $this->createMock(PaymentGatewayInterface::class);

        $customer = new Customer('cust-123', 'pm-456');
        
        $customers->expects($this->once())
            ->method('findById')
            ->with('cust-123')
            ->willReturn($customer);

        $orders->expects($this->exactly(2))
            ->method('save')
            ->with($this->isInstanceOf(Order::class));

        $payment = new Payment('txn-789', 99.99);
        $gateway->expects($this->once())
            ->method('charge')
            ->with('pm-456', 99.99)
            ->willReturn($payment);

        $service = new CreateOrderService($orders, $customers, $gateway);
        $order = $service->execute('cust-123', [['sku' => 'ABC', 'qty' => 2]]);

        $this->assertInstanceOf(Order::class, $order);
    }
}

What Happens Now?

Scenario 1: You optimize performance

// You cache the customer in the order to avoid a second save()
public function execute(string $customerId, array $items): Order
{
    $customer = $this->customers->findById($customerId);
    if (!$customer) {
        throw new CustomerNotFoundException();
    }

    $order = Order::create($customer, $items);
    
    $payment = $this->paymentGateway->charge(
        $customer->paymentMethodId,
        $order->total
    );
    
    $order->markAsPaid($payment->transactionId);
    $this->orders->save($order); // Only 1x save() now
    
    return $order;
}

Result: ❌ Test breaks. You made an improvement, but the mock expects exactly(2).


Scenario 2: You change the order

// You check payment availability BEFORE creating the order
public function execute(string $customerId, array $items): Order
{
    $customer = $this->customers->findById($customerId);
    if (!$customer) {
        throw new CustomerNotFoundException();
    }

    // Check payment first, then create order
    $total = $this->calculateTotal($items);
    $canPay = $this->paymentGateway->canCharge($customer->paymentMethodId, $total);
    
    if (!$canPay) {
        throw new InsufficientFundsException();
    }

    $order = Order::create($customer, $items);
    $this->orders->save($order);
    
    $payment = $this->paymentGateway->charge($customer->paymentMethodId, $order->total);
    $order->markAsPaid($payment->transactionId);
    $this->orders->save($order);
    
    return $order;
}

Result: ❌ Test breaks. The order of calls is different. But the behavior is identical (or even better).


Scenario 3: A real bug sneaks in

public function execute(string $customerId, array $items): Order
{
    $customer = $this->customers->findById($customerId);
    if (!$customer) {
        throw new CustomerNotFoundException();
    }

    $order = Order::create($customer, $items);
    $this->orders->save($order);
    
    // BUG: We're using the wrong paymentMethodId
    $payment = $this->paymentGateway->charge(
        'hardcoded-pm-999', // ← Bug here!
        $order->total
    );
    
    $order->markAsPaid($payment->transactionId);
    $this->orders->save($order);
    
    return $order;
}

Result: ✅ Test is green. The mock only expects one call to charge() with some parameters (or with 'pm-456', but that's only compared in the mock, not validated in real execution).


When Mocks Stay Green Despite Bugs

Mocks can miss real bugs because they only verify method calls, not correct behavior. Here are common scenarios:

1. Wrong data used

// Bug: Using wrong customer ID
$payment = $this->gateway->charge('wrong-id', $order->total);

// Mock stays green because it only checks that charge() was called
$gateway->expects($this->once())->method('charge');

2. Missing validation

// Bug: No null check
$customer = $this->customers->findById($customerId);
$order = Order::create($customer, $items); // ← Crash if customer is null!

// Mock stays green because findById() was called
$customers->expects($this->once())->method('findById');

3. Wrong calculation

// Bug: Wrong total calculation
$payment = $this->gateway->charge(
    $customer->paymentMethodId,
    $order->total * 0.5 // ← Only charging 50%!
);

// Mock stays green if it only checks method signature
$gateway->expects($this->once())
    ->method('charge')
    ->with($this->isType('string'), $this->isType('float'));

4. Silent failures

// Bug: Exception swallowed
try {
    $this->orders->save($order);
} catch (\Exception $e) {
    // ← Bug: Exception ignored, order not saved!
}

// Mock stays green because save() was called
$orders->expects($this->once())->method('save');

5. Side effects ignored

// Bug: Order state inconsistent
$order->markAsPaid($payment->transactionId);
// ← Forgot to save after marking as paid!

return $order; // Returns object with isPaid() = true, but not saved

// Mock stays green because it only checks method calls, not persistence

The pattern: Mocks verify "Did you call the method?" but not "Did it work correctly?"


The Fake Solution: Behavior Instead of Interaction

In-Memory Implementations

final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
    /** @var array<string, Customer> */
    private array $customers = [];

    public function save(Customer $customer): void
    {
        $this->customers[$customer->id] = $customer;
    }

    public function findById(string $id): ?Customer
    {
        return $this->customers[$id] ?? null;
    }
}

final class InMemoryOrderRepository implements OrderRepositoryInterface
{
    /** @var array<string, Order> */
    private array $orders = [];

    public function save(Order $order): void
    {
        $this->orders[$order->id] = $order;
    }

    public function findById(string $id): ?Order
    {
        return $this->orders[$id] ?? null;
    }

    public function all(): array
    {
        return array_values($this->orders);
    }
}

final class InMemoryPaymentGateway implements PaymentGatewayInterface
{
    /** @var array<string, Payment> */
    private array $payments = [];
    
    /** @var array<string, float> */
    private array $balances = [];

    public function setBalance(string $paymentMethodId, float $amount): void
    {
        $this->balances[$paymentMethodId] = $amount;
    }

    public function charge(string $paymentMethodId, float $amount): Payment
    {
        $balance = $this->balances[$paymentMethodId] ?? 0;
        
        if ($balance < $amount) {
            throw new InsufficientFundsException();
        }

        $payment = new Payment(
            'txn-' . uniqid(),
            $amount,
            $paymentMethodId
        );
        
        $this->payments[$payment->id] = $payment;
        $this->balances[$paymentMethodId] -= $amount;
        
        return $payment;
    }

    public function getPayment(string $transactionId): ?Payment
    {
        return $this->payments[$transactionId] ?? null;
    }
}

The Test with Fakes

final class CreateOrderServiceTest extends TestCase
{
    private InMemoryCustomerRepository $customers;
    private InMemoryOrderRepository $orders;
    private InMemoryPaymentGateway $gateway;
    private CreateOrderService $service;

    protected function setUp(): void
    {
        $this->customers = new InMemoryCustomerRepository();
        $this->orders = new InMemoryOrderRepository();
        $this->gateway = new InMemoryPaymentGateway();
        
        $this->service = new CreateOrderService(
            $this->orders,
            $this->customers,
            $this->gateway
        );
    }

    public function testCreatesOrderAndChargesCustomer(): void
    {
        // Arrange
        $customer = new Customer('cust-123', 'pm-456');
        $this->customers->save($customer);
        $this->gateway->setBalance('pm-456', 1000.00);

        // Act
        $order = $this->service->execute('cust-123', [
            ['sku' => 'ABC', 'price' => 49.99, 'qty' => 2]
        ]);

        // Assert
        $this->assertSame(99.98, $order->total);
        $this->assertTrue($order->isPaid());
        
        $savedOrder = $this->orders->findById($order->id);
        $this->assertNotNull($savedOrder);
        $this->assertTrue($savedOrder->isPaid());
        
        $payment = $this->gateway->getPayment($order->transactionId);
        $this->assertNotNull($payment);
        $this->assertSame(99.98, $payment->amount);
        $this->assertSame('pm-456', $payment->paymentMethodId);
    }

    public function testThrowsExceptionWhenCustomerNotFound(): void
    {
        $this->expectException(CustomerNotFoundException::class);
        
        $this->service->execute('nonexistent', []);
    }

    public function testThrowsExceptionWhenInsufficientFunds(): void
    {
        $customer = new Customer('cust-123', 'pm-456');
        $this->customers->save($customer);
        $this->gateway->setBalance('pm-456', 10.00); // Not enough

        $this->expectException(InsufficientFundsException::class);
        
        $this->service->execute('cust-123', [
            ['sku' => 'ABC', 'price' => 49.99, 'qty' => 2]
        ]);
        
        // Assert: Order was not created
        $this->assertCount(0, $this->orders->all());
    }
}

What's Different Now?

Scenario 1 (Performance optimization): ✅ Test stays green. Behavior is the same.

Scenario 2 (Change order): ✅ Test stays green. Behavior is the same.

Scenario 3 (Bug with wrong payment method): ❌ Test turns red! The fake verifies that 'pm-456' is actually used, because that's real behavior.


The Time-Travel Problem: Mocks Become Archaeology

Today: You Know the Use Case

When you write a mock today, you understand the use case:

public function testCreatesOrderWithDiscount(): void
{
    $discounts = $this->createMock(DiscountRepositoryInterface::class);
    
    // You know WHY this must be called exactly once
    $discounts->expects($this->once())
        ->method('findForCustomer')
        ->willReturn(new Discount('GOLD10', 0.10));
    
    // You know WHY this sequence matters
    $orders->expects($this->exactly(2))
        ->method('save');
    
    $service = new CreateOrderService($orders, $customers, $gateway, $discounts);
    $service->execute('cust-123', $items);
}

You wrote this. You know the business logic. The mock makes sense to you.


Two Years Later: Nobody Knows the Use Case

A new developer needs to refactor CreateOrderService:

// The refactoring looks safe...
public function execute(string $customerId, array $items): Order
{
    $customer = $this->customers->findById($customerId);
    
    // New optimization: batch discount lookups
    $discount = $this->discountCache->get($customer->tier);
    
    // ... rest of the code
}

Test result: 💥 15 tests break.

Expected method 'findForCustomer' to be called 1 time(s), called 0 time(s)

Questions the new developer asks:

  • Why does it expect exactly 1 call?
  • What breaks if it's called 0 times?
  • What breaks if it's called 2 times?
  • Is this a real constraint or just test artifact?
  • Should I fix the test or revert my change?

The answer: Nobody knows. The person who wrote the test left 18 months ago.


The Mock Is Now Technical Debt

The mock has become archaeology:

  • It documents implementation details that may no longer be relevant
  • It enforces constraints that may not be actual requirements
  • It blocks refactoring because nobody dares to change it
  • It's a riddle wrapped in an enigma wrapped in expects($this->once())

With a fake:

public function testCreatesOrderWithDiscount(): void
{
    $discounts = new InMemoryDiscountRepository();
    $discounts->save(new Discount('GOLD10', CustomerTier::GOLD, 0.10));
    
    $customer = new Customer('cust-123', CustomerTier::GOLD, 'pm-456');
    $this->customers->save($customer);
    
    $order = $this->service->execute('cust-123', $items);
    
    // Clear business rule: GOLD customers get 10% off
    $this->assertSame(89.99, $order->total); // 99.99 - 10%
}

Two years later, the new developer sees:

  • What the feature does (GOLD discount)
  • What the expected outcome is (10% off)
  • No implementation details to decode

The test stays green after refactoring, or it turns red for the right reason (the discount broke).


Mocks Don't Scale Over Time

                    Mock Test              Fake Test
───────────────────────────────────────────────────────
Day 1              Clear                   Clear
Month 6            Still clear             Clear
Year 2             Confusing               Clear
Year 5             Archaeology             Clear
After refactor     Broken (false alarm)    Broken (real bug) or Green

The pattern:

  • Mocks decay. Their meaning erodes as code evolves.
  • Fakes endure. They test behavior, which is timeless.

Mocks as Broken Windows: The Wrong Example for New Developers

The Broken Window Theory in Testing

When a new developer joins a codebase with mock-heavy tests, they learn:

"This is how we test. This is what good tests look like."

They see:

public function testSomething(): void
{
    $mock1 = $this->createMock(Interface1::class);
    $mock2 = $this->createMock(Interface2::class);
    $mock3 = $this->createMock(Interface3::class);
    
    $mock1->expects($this->once())->method('doThing');
    $mock2->expects($this->exactly(2))->method('doOther');
    $mock3->expects($this->never())->method('doLast');
    
    // ... 30 more lines of mock setup ...
}

What they learn (wrong):

  • "Testing means mocking all dependencies"
  • "Good tests are isolated = everything is a mock"
  • "Unit tests = one class, all else mocked"
  • "Complex mocks = thorough testing"

What they DON'T learn:

  • How components actually work together
  • What the integration contracts are
  • How to test behavior vs. implementation
  • How to write maintainable tests

The Fallacy: "Everything Must Be a Unit Test"

The mock culture reinforces a false dichotomy:

Unit Tests (fast, isolated, MOCK EVERYTHING)
                    ↕
Integration Tests (slow, flaky, AVOID AT ALL COSTS)

This creates a religion: "Thou shalt mock thy dependencies."

The truth:

Domain Tests (pure logic, no doubles)
                    ↓
Application Tests (use case tests, in-memory fakes)
                    ↓
Infrastructure Tests (real adapters, TestContainers)
                    ↓
System Tests (end-to-end, real systems)

In-memory fakes are NOT integration tests.
They're unit tests of behavior, not unit tests of classes.


Mocks Don't Teach Integration

When a new developer looks at mock-based tests, they can't answer:

"How do these components actually work together?"

// Mock test: What's the actual contract?
$repo->expects($this->once())
    ->method('save')
    ->with($this->isInstanceOf(Order::class));

// Questions:
// - Does save() validate the order?
// - Can save() throw exceptions?
// - What happens if I call save() twice with the same ID?
// - Is the order retrievable after save()?

The mock hides all this.


With a fake test:

// Fake test: The contract is clear
$repo = new InMemoryOrderRepository();
$repo->save($order);

$retrieved = $repo->findById($order->id);
$this->assertSame($order->id, $retrieved->id);

// The test shows:
// - save() makes the order retrievable
// - The order is stored by ID
// - The order is the same object (or equivalent)

The fake documents the integration.


The Example You Set

When you write fakes, new developers see:

final class InMemoryOrderRepository implements OrderRepositoryInterface
{
    private array $orders = [];

    public function save(Order $order): void
    {
        if (isset($this->orders[$order->id])) {
            throw new DuplicateOrderException();
        }
        $this->orders[$order->id] = $order;
    }

    public function findById(string $id): ?Order
    {
        return $this->orders[$id] ?? null;
    }
}

What they learn:

  • This is how a repository should behave
  • Duplicate IDs throw exceptions
  • Find returns null when not found
  • The contract is simple and clear

This is documentation in code.

When the time comes to implement DoctrineOrderRepository, they already know what it should do.


Broken Windows Stay Broken

Once a codebase is full of mocks, new developers:

  1. Copy the pattern (cargo cult)
  2. Add more mocks (technical debt compounds)
  3. Resist fakes ("that's not how we do it here")
  4. Never learn proper integration testing

The broken window (mocks) becomes the standard.

The fix: Start writing fakes. One service at a time. Show the alternative.

New developers will notice: "This test is easier to understand."

And slowly, the culture shifts.


The Typical Counter-Arguments – And Why They're Wrong

"Fakes are too much effort to write"

Counter-argument:

You don't write a mock in one line either. Compare:

// Mock setup (for EVERY test)
$repo = $this->createMock(OrderRepositoryInterface::class);
$repo->expects($this->once())
    ->method('save')
    ->with($this->callback(fn($o) => $o->status === 'pending'));
$repo->expects($this->once())
    ->method('findById')
    ->willReturn($mockOrder);

vs.

// Fake setup (written once, used everywhere)
$repo = new InMemoryOrderRepository();
$repo->save($order);
$result = $repo->findById($order->id);

A fake is written once and reused in all tests.
A mock is reconfigured in every test.

Math:

  • 1 Fake = 30 lines of code
  • 10 Tests with mocks = 10 × 15 lines = 150 lines
  • 10 Tests with fake = 30 + (10 × 3 lines) = 60 lines

Fakes are lower maintenance, not more effort.


"Fakes need to be tested themselves"

Counter-argument:

No. Fakes are validated through contract tests of the interface.

abstract class CustomerRepositoryContractTest extends TestCase
{
    abstract protected function createRepository(): CustomerRepositoryInterface;

    public function testSaveAndFind(): void
    {
        $repo = $this->createRepository();
        $customer = new Customer('cust-1', 'pm-1');
        
        $repo->save($customer);
        
        $found = $repo->findById('cust-1');
        $this->assertNotNull($found);
        $this->assertSame('cust-1', $found->id);
    }

    public function testFindReturnsNullWhenNotFound(): void
    {
        $repo = $this->createRepository();
        
        $this->assertNull($repo->findById('nonexistent'));
    }
}

// Doctrine implementation tests against the contract
final class DoctrineCustomerRepositoryTest extends CustomerRepositoryContractTest
{
    protected function createRepository(): CustomerRepositoryInterface
    {
        return new DoctrineCustomerRepository($this->entityManager);
    }
}

// InMemory fake tests against the same contract
final class InMemoryCustomerRepositoryTest extends CustomerRepositoryContractTest
{
    protected function createRepository(): CustomerRepositoryInterface
    {
        return new InMemoryCustomerRepository();
    }
}

The contract guarantees that both implementations behave the same.
You're not testing "the fake", you're testing "the interface".


"Fakes are not realistic"

Counter-argument:

Mocks are even less realistic. A mock does nothing. It returns what you tell it.

$mock->method('findById')->willReturn($customer);

This mock doesn't verify:

  • Whether the ID exists
  • Whether save() was called before
  • Whether constraints are violated
  • Whether race conditions can occur

A fake, however:

final class InMemoryOrderRepository implements OrderRepositoryInterface
{
    public function save(Order $order): void
    {
        // Checks unique constraint
        if (isset($this->orders[$order->id])) {
            throw new DuplicateOrderException();
        }
        
        $this->orders[$order->id] = $order;
    }

    public function findById(string $id): ?Order
    {
        // Behaves like real DB: null if not found
        return $this->orders[$id] ?? null;
    }
}

The fake simulates real behavior of the database. The mock simulates nothing.


"Mocks are faster"

Counter-argument:

Measurement doesn't lie.

// Mock test: ~0.015s per test
// Fake test: ~0.003s per test

Why? Because PHPUnit for each mock:

  1. Uses reflection
  2. Generates a proxy class
  3. Performs expectation matching
  4. Builds stack traces

A fake is simply normal PHP code. No magic. No overhead.

With 500 tests:

  • Mocks: 7.5 seconds
  • Fakes: 1.5 seconds

Fakes are faster.


"Mocks decouple better"

Counter-argument:

This is the most dangerous misconception.

Decoupling means: "My code doesn't depend on implementation details."

But a mock tests exactly that:

$mock->expects($this->once())
    ->method('save')
    ->with($this->isInstanceOf(Order::class));

This says: "I expect save() to be called exactly once."

This is not decoupling. This is maximum coupling to:

  • The number of calls
  • The order of calls
  • The parameter types

A fake, however, only cares about the result:

$order = $service->execute('cust-123', $items);

$savedOrder = $repo->findById($order->id);
$this->assertTrue($savedOrder->isPaid());

You test: "After execute(), the order is paid."
You don't test: "After execute(), save() was called twice."

Which test is more decoupled?


Hexagonal Architecture: Why Fakes Are the Natural Choice

The Promise of Ports

In hexagonal architecture, ports (interfaces) define behavior:

// Port = Promise
interface OrderRepositoryInterface
{
    /** Saves an order. Throws exception on constraint violation. */
    public function save(Order $order): void;
    
    /** Finds order by ID. Null if not found. */
    public function findById(string $id): ?Order;
}

The interface is a contract: "If you save an order, you can find it later."

Adapters Fulfill the Promise

// Adapter 1: Doctrine
final class DoctrineOrderRepository implements OrderRepositoryInterface
{
    public function save(Order $order): void
    {
        $this->entityManager->persist($order);
        $this->entityManager->flush();
    }

    public function findById(string $id): ?Order
    {
        return $this->entityManager->find(Order::class, $id);
    }
}

// Adapter 2: InMemory (Fake)
final class InMemoryOrderRepository implements OrderRepositoryInterface
{
    private array $orders = [];

    public function save(Order $order): void
    {
        $this->orders[$order->id] = $order;
    }

    public function findById(string $id): ?Order
    {
        return $this->orders[$id] ?? null;
    }
}

Both fulfill the same promise. The test should work for both.

Mocks Break the Promise

$mock = $this->createMock(OrderRepositoryInterface::class);
$mock->method('findById')->willReturn(null);

This mock says: "findById() returns null."

But: It doesn't say why. Did save() not work? Does the ID not exist? Is the database down?

A mock is not an adapter. It doesn't implement the port – it fakes it.


The Litmus Test: Can I Run This Test Against Production?

The ultimate question for any test:

If I run this test against the real database, does it stay green?

Fake Test

public function testCreatesOrder(): void
{
    $repo = new InMemoryOrderRepository();
    $service = new CreateOrderService($repo);
    
    $order = $service->execute('cust-123', $items);
    
    $saved = $repo->findById($order->id);
    $this->assertNotNull($saved);
}

Replace the fake with Doctrine:

$repo = new DoctrineOrderRepository($entityManager);

Result: ✅ Test stays green.


Mock Test

public function testCreatesOrder(): void
{
    $repo = $this->createMock(OrderRepositoryInterface::class);
    $repo->expects($this->once())->method('save');
    
    $service = new CreateOrderService($repo);
    $order = $service->execute('cust-123', $items);
}

Replace the mock with Doctrine:

$repo = new DoctrineOrderRepository($entityManager);

Result: ❌ Test has no assertions anymore. It checks nothing.


When Are Mocks OK?

There are exactly three legitimate use cases for mocks:

1. Testing External Services with Side Effects

// We don't want to actually send emails
final class OrderConfirmationServiceTest extends TestCase
{
    public function testSendsConfirmationEmail(): void
    {
        $mailer = $this->createMock(MailerInterface::class);
        $mailer->expects($this->once())
            ->method('send')
            ->with($this->callback(fn($email) => 
                $email->to === 'customer@example.com' &&
                str_contains($email->subject, 'Order Confirmation')
            ));

        $service = new OrderConfirmationService($mailer);
        $service->sendConfirmation($order);
    }
}

Why OK? Because a fake here would be more complicated (would need to simulate SMTP) and because we're only testing the message, not the sending.

But better would be:

final class InMemoryMailer implements MailerInterface
{
    private array $sent = [];

    public function send(Email $email): void
    {
        $this->sent[] = $email;
    }

    public function getSent(): array
    {
        return $this->sent;
    }
}

And then:

$mailer = new InMemoryMailer();
$service->sendConfirmation($order);

$sent = $mailer->getSent();
$this->assertCount(1, $sent);
$this->assertSame('customer@example.com', $sent[0]->to);

Even here: Fake > Mock.


2. Testing Legacy Code

When working with a codebase where refactoring is impossible:

// Legacy: direct DB calls, no interfaces
final class LegacyOrderService
{
    public function createOrder($customerId): void
    {
        $db = Database::getInstance();
        $db->query("INSERT INTO orders...");
    }
}

Here you must mock (or replace the global Database). But that's a code smell, not a test pattern.


3. Performance Tests

When you want to test how often a method is called (e.g., caching):

public function testCachesCustomerLookup(): void
{
    $repo = $this->createMock(CustomerRepositoryInterface::class);
    $repo->expects($this->once()) // Only 1x, then cache
        ->method('findById')
        ->willReturn($customer);

    $service = new CachedCustomerService($repo);
    
    $service->getCustomer('cust-123'); // DB hit
    $service->getCustomer('cust-123'); // Cache hit
}

But even here:

final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
    private int $findByIdCallCount = 0;

    public function findById(string $id): ?Customer
    {
        $this->findByIdCallCount++;
        return $this->customers[$id] ?? null;
    }

    public function getFindByIdCallCount(): int
    {
        return $this->findByIdCallCount;
    }
}

Fake with metrics is better than mock.


The Decision Matrix for Test Doubles

What am I testing?Which test type?Which tool?

Domain Layer (Pure Logic)
  → Unit Test
  → No test doubles needed
  → Instantiate directly

Application Layer (Use Cases)
  → Integration Test
  → Fakes for ports
  → In-memory implementations

Infrastructure Layer (Adapters)
  → Contract Test or Integration Test
  → Real systems (TestContainers, SQLite)
  → Only in CI: Mocks for 3rd-party APIs

Legacy Code / External Services with Side Effects
  → Workaround
  → Mocks
  → With comment "TODO: Build fake"

Rule of thumb:

  • Domain: No doubles
  • Application: Fakes
  • Infrastructure: Real systems
  • Mocks: Only when unavoidable

The Debugging Nightmare: Why Mocks Sabotage Troubleshooting

Scenario: A Test Fails

public function testCreatesOrderWithDiscount(): void
{
    // ... Mock setup with 20 lines of expectations ...
    
    $service = new CreateOrderService($orders, $customers, $discounts);
    $order = $service->execute('cust-123', $items);
    
    $this->assertSame(89.99, $order->total);
}

// Error:
// Failed asserting that 99.99 is identical to 89.99

With Mocks:

You want to debug why the discount wasn't applied. What do you do?

  1. Set a breakpoint? ❌ Can't in the mock - it's generated proxy code
  2. Check mock return value? ❌ The mock returns what you told it - not what happened
  3. Look at expectations? ❌ They only tell you if methods were called - not why the result is wrong
  4. var_dump() in the mock? ❌ You can't edit the generated code

You're in the dark. The only option: Read the code and guess.


With Fakes and XDebug

public function testCreatesOrderWithDiscount(): void
{
    $orders = new InMemoryOrderRepository();
    $customers = new InMemoryCustomerRepository();
    $discounts = new InMemoryDiscountRepository();
    
    // Setup
    $customer = new Customer('cust-123', CustomerTier::GOLD);
    $customers->save($customer);
    
    $discount = new Discount('GOLD10', 0.10);
    $discounts->save($discount);
    
    $service = new CreateOrderService($orders, $customers, $discounts);
    $order = $service->execute('cust-123', $items);
    
    $this->assertSame(89.99, $order->total);
}

Debugging Workflow:

  1. Set breakpoint in fake:
final class InMemoryDiscountRepository implements DiscountRepositoryInterface
{
    public function findForCustomer(Customer $customer): ?Discount
    {
        // Breakpoint here! ← XDebug stops here
        foreach ($this->discounts as $discount) {
            if ($discount->appliesToTier($customer->tier)) {
                return $discount; // ← You see: not returned!
            }
        }
        return null;
    }
}
  1. Inspect variables:

    • $customer->tier = CustomerTier::GOLD
    • $this->discounts = [Discount('GOLD10', 0.10)]
    • $discount->appliesToTier() = false← AHA!
  2. Root cause found:

// The bug was here:
final class Discount
{
    public function appliesToTier(CustomerTier $tier): bool
    {
        return $this->tier === $tier; // ← String comparison instead of enum comparison!
    }
}

Time to debug:

  • With mock: 30 minutes reading code, guessing, testing
  • With fake: 2 minutes stepping through with XDebug

Concrete Example: Finding Race Conditions

public function testConcurrentOrderCreation(): void
{
    $orders = new InMemoryOrderRepository();
    $inventory = new InMemoryInventoryRepository();
    
    $inventory->setStock('SKU-123', 1); // Only 1 in stock
    
    $service = new CreateOrderService($orders, $inventory);
    
    // Two simultaneous orders for the same product
    $order1 = $service->execute('cust-1', [['sku' => 'SKU-123', 'qty' => 1]]);
    
    $this->expectException(InsufficientStockException::class);
    $order2 = $service->execute('cust-2', [['sku' => 'SKU-123', 'qty' => 1]]);
}

With mock: The test is green because the mock simply returns what you want. You're not testing behavior.

With fake:

final class InMemoryInventoryRepository implements InventoryRepositoryInterface
{
    private array $stock = [];

    public function reserveStock(string $sku, int $quantity): void
    {
        // Breakpoint here - you see the state!
        $available = $this->stock[$sku] ?? 0;
        
        if ($available < $quantity) {
            throw new InsufficientStockException();
        }
        
        $this->stock[$sku] -= $quantity;
        // ← After order1: stock = 0
        // ← After order2: Exception!
    }
}

You can:

  • Set breakpoints
  • See $this->stock during execution
  • Check state after each step
  • Understand why it works or doesn't

The Mock Problem: Debugging Blindness

A mock is opaque. It's a black box that simulates answers.

$mock = $this->createMock(InventoryRepositoryInterface::class);
$mock->method('reserveStock')->willReturn(null);

Questions you CANNOT answer:

  • What happens in the mock when reserveStock() is called?
  • How many times was it called?
  • With which concrete values?
  • What would have happened with a real DB?

You're testing blind. You hope your expectations are correct.


The Fake Promise: Transparency

A fake is normal PHP code. No magic, no codegen.

final class InMemoryInventoryRepository implements InventoryRepositoryInterface
{
    private array $stock = [];
    private array $operations = []; // ← Audit log for tests

    public function reserveStock(string $sku, int $quantity): void
    {
        $this->operations[] = ['reserve', $sku, $quantity]; // ← You see everything
        
        $available = $this->stock[$sku] ?? 0;
        
        if ($available < $quantity) {
            throw new InsufficientStockException();
        }
        
        $this->stock[$sku] -= $quantity;
    }

    public function getOperations(): array
    {
        return $this->operations; // ← For debugging and assertions
    }
}

In the test:

$inventory = new InMemoryInventoryRepository();
// ... test runs ...

// Debugging helper:
dump($inventory->getOperations());
// [
//   ['reserve', 'SKU-123', 1],
//   ['reserve', 'SKU-456', 2],
// ]

dump($inventory->getStock('SKU-123')); // 4 (instead of 5)

You see the complete state. Always. In real-time.


The XDebug Advantage: Step-by-Step Through the System

Typical debug workflow with fake:

  1. Test fails
  2. Set breakpoint in Application Service
  3. Step Into → lands in Fake Repository
  4. Step through fake code like real code
  5. Inspect state ($this->orders, $this->stock, etc.)
  6. Bug found

With mock:

  1. Test fails
  2. Set breakpoint in Application Service
  3. Step Into → lands in PHPUnit Mock Proxy
  4. ❌ Unreadable Generated Code
  5. Step Out, guess, repeat
  6. Give up frustrated, read code

Conclusion: Mocks Are Development Blockers

A test should help find bugs – not hide them.

Mocks hide bugs behind opaque magic.
Fakes show you exactly what's happening in the system.

When your test fails with a mock, you're as smart as before.
When your test fails with a fake, you can debug like any other code.

Developer experience is part of code quality.
And mocks have the worst DX of all testing tools.


The Hidden Cost Factor: Maintenance Over Time

Initial development time is similar for mocks and fakes. The difference shows in maintenance:

Typical events in a project year:

Refactorings (improving code)
  → Mock tests: Often break
  → Fake tests: Stay green

Bug fixes (correcting logic)
  → Mock tests: Can stay green despite bug
  → Fake tests: Turn red on real bugs

Feature extensions (new methods on interface)
  → Mock tests: All must be adjusted
  → Fake tests: Extend fake once, tests run

Onboarding new developers
  → Mock tests: Hard to understand (expectations, matchers)
  → Fake tests: Normal code, easy to debug

The true costs of mocks are not in development, but in the next 2-3 years.


Technical Assessment Based on Software Engineering Principles

The Foundation: Not Opinion, But Principles

Fakes are the right choice in 80-90% of cases in the Application Layer. This isn't opinion – it follows from established engineering principles:

1. Liskov Substitution Principle (LSP)

"Objects of a supertype should be replaceable with objects of a subtype without altering the correctness of the program."

  • A Fake is a real adapter that implements the port interface
  • It can substitute the production adapter (e.g., Doctrine) without changing behavior
  • A Mock is NOT a real adapter – it's a test stub that only simulates calls
  • Violating LSP: Mocks pretend to implement the interface but don't honor the contract

2. Open/Closed Principle (OCP)

"Software entities should be open for extension, but closed for modification."

  • With Fakes: Refactor the service → tests stay green (closed for modification)
  • With Mocks: Refactor the service → tests break (must modify tests)
  • Tests with mocks are NOT closed for modification – they break on implementation changes

3. Behavior Testing > Implementation Testing

// Mock tests implementation:
$mock->expects($this->once())->method('save');
// "Did you call save()?"

// Fake tests behavior:
$this->assertNotNull($repo->findById($order->id));
// "Is the order actually saved?"

The goal of testing is to verify what the system does, not how it does it.


The Pragmatic View: Gray Areas Exist

Not all situations are black and white. There are legitimate cases where other approaches make sense:

1. Legacy Code

When refactoring is impossible or too risky:

// Global state, tight coupling, no interfaces
final class LegacyOrderService
{
    public function create($customerId): void
    {
        $db = Database::getInstance();
        $db->query("INSERT...");
    }
}

Verdict: Mock or wrap – but acknowledge it as technical debt, not best practice.


2. External APIs with Rate Limits or Costs

interface PaymentGatewayInterface
{
    public function charge(string $token, float $amount): Payment;
}

Options:

  • Best: InMemory fake that simulates success/failure scenarios
  • OK: Mock in unit tests, real API in integration tests
  • Worst: Only mocks, no integration testing

Rule: If you can build a fake in 30 minutes, do it. If the protocol is AWS-level complex, mock temporarily.


3. Very Complex Protocols

// Simulating Kafka, RabbitMQ, Elasticsearch in-memory might be overkill
interface MessageBusInterface
{
    public function publish(string $topic, Message $msg): void;
    public function subscribe(string $topic, callable $handler): void;
    public function acknowledge(string $messageId): void;
}

Pragmatic approach:

  • Application tests: Simple InMemory fake (stores messages in array)
  • Integration tests: TestContainers with real Kafka
  • NOT: Mock everywhere with no integration tests

The Dogmatic Trap: Avoid Both Extremes

"NEVER use mocks" → Wrong

  • Some situations genuinely need mocks (legacy, external services)
  • Being dogmatic makes you inflexible

"ALWAYS use mocks" → Wrong

  • Most situations DON'T need mocks
  • This creates fragile, unmaintainable test suites

The balanced position:

Use the simplest tool that tests behavior. Prefer fakes in Application Layer, use real systems in Infrastructure Layer, mock only when unavoidable.


Decision Tree: What Should I Use?

┌─ Am I testing Domain Logic?
│  └─ No test doubles needed (pure functions/objects)
│
┌─ Am I testing an Application Service (Use Case)?
│  ├─ Does it depend on repositories/ports?
│  │  └─ Use IN-MEMORY FAKES
│  └─ Does it depend on external APIs?
│     ├─ Can I build a fake in <30min? → FAKE
│     └─ Too complex? → MOCK (with integration tests later)
│
┌─ Am I testing Infrastructure (Adapter)?
│  ├─ Database? → REAL DB (SQLite/TestContainers)
│  ├─ File system? → TEMP DIRECTORY
│  ├─ HTTP API? → MOCK SERVER (WireMock) or REAL API (CI only)
│  └─ Message Queue? → TESTCONTAINERS
│
└─ Am I testing Legacy Code?
   └─ Mock carefully, plan refactoring to remove mocks later

Why This Matters: Principles Over Dogma

Software engineering isn't about rigid rules – it's about understanding why certain approaches work better.

The "why" behind fakes:

  • They honor the interface contract (LSP)
  • They're resilient to refactoring (OCP)
  • They test behavior, not implementation
  • They're debuggable with standard tools
  • They document expected behavior

The "why" behind mocks (when appropriate):

  • They avoid side effects (sending real emails)
  • They isolate from unavailable systems (3rd party APIs)
  • They're a temporary workaround for bad architecture

Know the "why" and you'll make the right decision.


Conclusion: The Hard Truth

Mocks feel fast. They're not.
Mocks feel simple. They're not.
Mocks feel professional. They're not.

Mocks are technical debt in test form.

Every mock is a bet:
"I bet this implementation will never change."

You lose this bet. Always.


Fakes are boring code. But they work.

And in 2 years, when you refactor and the tests stay green,
you'll be glad you wrote boring code.

A green fake test is a promise.
A green mock test is a lie.


Version 2.0 – For discussions about test strategy in hexagonal architectures
© dazz

Sources

Links

Books

🧩 1. Growing Object-Oriented Software, Guided by Tests (Steve Freeman & Nat Pryce, 2010)

Kapitel 1–2 („What is the point of TDD?“ / „Test-driven development with objects“) → Hier wird ausdrücklich gesagt, dass Tests das Verhalten eines Systems prüfen sollen, nicht einzelne Klassen. Freeman & Pryce betonen:

“We test behavior, not methods. A ‘unit’ in unit testing is a coherent piece of behavior, not necessarily a single class.”

“Isolation means isolation from unpredictable external systems, not from collaborating objects.”

Sie gelten als die Begründer des „London School TDD“ (Mockist) – und selbst sie schreiben, dass zu viel Mocking schadet, wenn man Verhalten verliert.


🧩 2. Martin Fowler – Mocks Aren’t Stubs (2007)

https://martinfowler.com/articles/mocksArentStubs.html Fowler definiert:

“A unit is a coherent piece of functionality. You isolate it from slow or unpredictable external systems, not from every collaborating object.”

“Mockist tests couple to implementation. Classical tests focus on results — the behavior.”

Er trennt damit klar: Isolation ≠ keine echten Objekte.


🧩 3. Ian Cooper – TDD, Where Did It All Go Wrong? (Talk, 2013)

https://youtu.be/EZ05e7EMOLM Cooper erklärt, dass viele Entwickler TDD falsch verstehen:

“They thought ‘unit’ meant ‘class’, so they started mocking every dependency. But the unit of test is the behavior of the system under test — not a single class.”

Er nennt genau das Missverständnis, das du beschreibst.


🧩 4. Robert C. Martin (Uncle Bob) – When to Mock (2014)

https://blog.cleancoder.com/uncle-bob/2014/05/10/WhenToMock.html

“Mocks are for boundaries you don’t control — databases, web services, hardware. They are not for every collaboration within your own system.”

Uncle Bob verortet „Isolation“ also auf Systemgrenzen, nicht innerhalb der Domäne.


🧩 5. Sebastian Bergmann – Avoiding Mockageddon (PHP fwdays, 2023)

Er bestätigt dieselbe Linie:

“Test pain — too many mocks — means your code has too many responsibilities. Isolation means isolation from infrastructure, not from other code.”

Quelle: https://www.youtube.com/watch?v=RbSqXFUfRMU


🧩 6. Matthias Noback – Advanced Web Application Architecture (2019)

Kapitel 7.6 „Testing Application Services“

“Application-layer tests are integration tests between objects, isolated from the outside world. They test use-case behavior, not class internals.”

Noback überträgt die Idee auf die PHP-/Symfony-Welt: Isolation heißt vom System entkoppelt, nicht vom eigenen Code.


🧠 Zusammenfassung

Begriff Falsch verstanden als Tatsächlich gemeint laut Literatur
Unit einzelne Klasse kohärentes Verhalten / Use Case
Isoliert ohne echte Objekte ohne externe Systeme (DB, Netzwerk, Filesystem)
Mocking für jede Abhängigkeit nur für externe oder unkontrollierbare Systeme
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment