Advanced mock configuration and where the line to real test logic runs
willReturn() covers the vast majority of all mocking needs, return a fixed value, done. Sometimes that is not enough though: a mock needs to react differently depending on the argument it receives, or its behavior needs to change dynamically across several calls. That is exactly what willReturnMap and willReturnCallback are for. This article explains how both work and where the line sits, past which a mock already contains too much logic of its own.
Table of Contents
- 1. Why willReturn alone is sometimes not enough
- 2. willReturnMap: return values depending on the input argument
- 3. willReturnCallback: custom logic for dynamic behavior
- 4. Different behavior across multiple calls
- 5. The line: when the mock itself contains too much logic
- 6. Alternative: a hand written test double instead of a complex callback
- 7. Combining willReturnMap and willReturnCallback in the same test class
- 8. Common mistakes with willReturnMap and willReturnCallback
- 9. Takeaway: use the power deliberately instead of making it the default
- 10. Summary
- 11. FAQ
1. Why willReturn alone is sometimes not enough
The vast majority of mock configurations in a test suite need nothing more than willReturn(), a fixed return value for a method, regardless of which arguments are passed. That is a good thing, because a mock is primarily meant to be a controlled, predictable substitute implementation of a dependency, not to contain its own business logic that would itself need testing.
There are situations, however, where a simple fixed return value does not adequately represent the logic under test: a method being tested calls the same mocked method multiple times with different arguments and expects different answers each time, or the return value needs to change across consecutive calls, for example to test a retry mechanism that fails on the first attempt and succeeds on the second.
2. willReturnMap: return values depending on the input argument
willReturnMap() takes an array of rows, each row consisting of the expected input arguments followed by the associated return value as the last element. When the code under test calls the mocked method with arguments that exactly match one of these rows, the mock returns the matching value, for argument combinations not covered, PHPUnit returns null by default.
The classic use case is a repository or service mock that should return different objects depending on a passed ID, for example in a test that checks how a method retrieves and processes several customer records one after another by their IDs. Without willReturnMap, the mock would either need to be reconfigured repeatedly or a more complex callback based solution would be needed.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CustomerBatchLoaderTest extends TestCase
{
#[Test]
public function itLoadsMultipleCustomersByTheirIds(): void
{
$repository = $this->createMock(CustomerRepositoryInterface::class);
$repository->method('findById')->willReturnMap([
// [argument, return value]
[1, new Customer(1, 'Anna Schmidt')],
[2, new Customer(2, 'Bernd Mueller')],
[3, null], // customer 3 does not exist
]);
$loader = new CustomerBatchLoader($repository);
$result = $loader->loadMany([1, 2, 3]);
self::assertCount(2, $result);
self::assertSame('Anna Schmidt', $result[0]->getName());
}
}
3. willReturnCallback: custom logic for dynamic behavior
willReturnCallback() goes a step further and accepts any callable, which runs on every call to the mocked method with the same arguments the real method would have been called with. The return value of that callable becomes the return value of the mock, which allows arbitrary, even multi step or state dependent logic that could not be expressed with willReturnMap alone.
A typical case is a mock that does not discriminate on an exact argument value but on a condition, for example a range or a pattern. A payment gateway mock could simulate either success or a specific error message depending on the amount passed, logic that cannot be cleanly covered with a fixed map, because it is not based on exact equality but on a condition.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PaymentProcessorTest extends TestCase
{
#[Test]
public function itDeclinesAmountsThatAreTooHigh(): void
{
$gateway = $this->createMock(PaymentGatewayInterface::class);
$gateway->method('charge')->willReturnCallback(
function (int $amountInCents): PaymentResult {
if ($amountInCents > 1_000_00) {
return PaymentResult::declined('Amount exceeds gateway limit');
}
return PaymentResult::approved();
}
);
$processor = new PaymentProcessor($gateway);
self::assertTrue($processor->charge(50_00)->isApproved());
self::assertFalse($processor->charge(2_000_00)->isApproved());
}
}
4. Different behavior across multiple calls
Another use case for willReturnCallback is simulating behavior that changes across multiple calls to the same method, for example a retry mechanism that only succeeds after two failed attempts. Since the callable runs fresh on every call, a captured variable can serve as a counter to track how many times it has been called.
PHPUnit also offers willReturnOnConsecutiveCalls() for exactly this purpose, which takes a list of return values for consecutive calls. For simple, fixed sequences that is often the more readable choice, willReturnCallback pays off once the logic between calls becomes more complex than a plain value list, for example when the passed argument also has to factor into the decision.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RetryingHttpClientTest extends TestCase
{
#[Test]
public function itSucceedsOnlyOnTheSecondAttempt(): void
{
$client = $this->createMock(HttpClientInterface::class);
$attempt = 0;
$client->method('get')->willReturnCallback(function () use (&$attempt): Response {
$attempt++;
if ($attempt === 1) {
throw new NetworkTimeoutException();
}
return new Response(200, 'OK');
});
$retryingClient = new RetryingHttpClient($client, maxAttempts: 3);
$response = $retryingClient->get('/health');
self::assertSame(200, $response->getStatusCode());
}
}
5. The line: when the mock itself contains too much logic
willReturnCallback is powerful, and that is exactly where the danger lies: a callback that itself contains several if branches, loops, or complex conditions is essentially a second implementation of the logic under test, just hidden inside the test code. Such a mock no longer tests the behavior of the real class, it implicitly compares two parallel implementations of the same idea against each other, which undermines the actual purpose of the test.
As a rule of thumb: a willReturnCallback should contain at most a single condition or a simple case distinction. If the callback logic becomes more complex than that, it is a strong signal to either split the test into several focused individual tests with simpler mock setups each, or to check whether a real, hand written test double would be the better choice instead of a dynamically configured mock.
6. Alternative: a hand written test double instead of a complex callback
As soon as a willReturnCallback outgrows a simple condition, it is worth considering a hand written test double, a simple class that directly implements the interface instead of being generated through PHPUnit's mock builder. Such a double is regular, readable PHP code that can be debugged like any other class, secured with its own test suite, and reused across multiple test files.
The trade off is extra upfront effort compared to the one line configuration of a mock, which pays for itself quickly with repeated use. An InMemoryCustomerRepository that uses an array as storage and implements the real interface logic naively but correctly is often more maintainable than a willReturnCallback replicating complex stateful logic, precisely because it requires no PHPUnit specific mock API knowledge to understand.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Double;
final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
/** @var array<int, Customer> */
private array $customers = [];
public function save(Customer $customer): void
{
$this->customers[$customer->getId()] = $customer;
}
public function findById(int $id): ?Customer
{
return $this->customers[$id] ?? null;
}
}
// Usage in the test: no callback, no map, just real behavior
final class CustomerServiceTest extends \PHPUnit\Framework\TestCase
{
public function testItRenamesAnExistingCustomer(): void
{
$repository = new InMemoryCustomerRepository();
$repository->save(new Customer(1, 'Anna Schmidt'));
$service = new CustomerService($repository);
$service->renameCustomer(1, 'Anna Meier');
self::assertSame('Anna Meier', $repository->findById(1)->getName());
}
}
7. Combining willReturnMap and willReturnCallback in the same test class
In practice, the two techniques do not exclude each other, different methods of the same mock can be configured differently. A findById method suits willReturnMap well because it discriminates on fixed ID values, while a validate method on the same mock might need willReturnCallback because it checks a real condition instead of a fixed value list.
It matters to pick the simplest sufficient technique per method, rather than reaching for willReturnCallback everywhere out of habit just because it is the most powerful option. A mock setup that consistently uses the simplest applicable configuration, willReturn where possible, willReturnMap where argument dependence exists, willReturnCallback only when truly necessary, stays the easiest for other team members to follow.
8. Common mistakes with willReturnMap and willReturnCallback
A common mistake with willReturnMap is a type mismatch between the arguments stored in the map and the arguments actually passed, for example a string '1' in the map while the tested code passes a real integer 1. Since PHPUnit compares strictly internally, the mock then returns null instead of the expected object, and the failure often only shows up as a confusing null pointer exception further down in the tested code, not directly at the mock configuration.
With willReturnCallback, the most common mistake is that the callable's signature does not exactly match the method of the mocked interface, for example a missing or wrongly typed parameter count. PHP then throws a TypeError only at runtime during the actual call, not already when configuring the mock, which is why a careful look at the interface signature pays off when writing the callback.
9. Takeaway: use the power deliberately instead of making it the default
willReturnMap and willReturnCallback solve real problems that plain willReturn cannot express, argument dependent return values and dynamic, stateful behavior across multiple calls. Both tools should be used deliberately and sparingly though, as soon as the callback logic itself becomes complex, that is a signal to either split the test or build a hand written test double.
The table below summarizes which technique is the right choice for which use case, from a simple fixed answer to a complex, state dependent simulation.
| Technique | Use case | Complexity | Alternative once exceeded |
|---|---|---|---|
| willReturn | Fixed return value, regardless of argument | Minimal | None needed |
| willReturnMap | Return value depending on exact argument value | Low | willReturnCallback for conditions |
| willReturnOnConsecutiveCalls | Fixed sequence across multiple calls | Low | willReturnCallback for stateful logic |
| willReturnCallback | Conditional or stateful logic | Medium | Hand written test double |
Mironsoft
Test automation, Magento quality assurance, and CI integration
Tests that catch real bugs instead of just turning green?
We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.
Test Audit
Reviewing existing suites for mocking antipatterns and blind spots.
Test Strategy
Meaningfully combining unit, integration, and MFTF tests for Magento projects.
CI Integration
Setting up fast, reliable test runs in GitLab CI or GitHub Actions.
10. Summary
Advanced Mocks: The Essentials at a Glance
Core idea
willReturnMap covers argument dependent, willReturnCallback covers dynamic or stateful mock responses.
Biggest risk
An overly complex callback becomes a second implementation of the logic under test inside the mock.
Rule of thumb
At most one condition inside the callback, beyond that split the test or write a double instead.
Most common mistake
A type mismatch between map arguments and actual passed values leads to a silent null.