Welcome to the module that will transform the way you write unit tests! Until now, you’ve been testing classes that were largely independent, but in the real world, classes collaborate with each other. A UserService
class might need an EmailClient
class to send emails, or a Logger
to record activities.
How can we test the logic of the UserService
without actually sending an email or writing to a log file every time the test runs? The answer lies in Test Doubles, a fundamental concept for test isolation.
1. What are Test Doubles?
A Test Double is a generic term for any object that pretends to be another object for testing purposes. Think of it like a movie stunt double: they step in to replace the main actor in specific situations, allowing the scene to be filmed safely and in a controlled manner.
In our case, we use doubles to replace the dependencies of the class we are testing (the “Unit Under Test”). This allows us to:
-
Isolate the Unit Under Test: We ensure
Welcome to the module that will transform the way you write unit tests. Until now, we have tested classes that were largely independent. In the real world, however, classes collaborate with each other. A
UserService
class might need anEmailClient
class to send emails, or aLogger
to record activities.How can we test the logic of the
UserService
without actually sending an email or writing to a log file every time the test runs? The answer lies in Test Doubles, a fundamental concept for test isolation.1. What are Test Doubles?
A Test Double is a generic term for any object that pretends to be another object for testing purposes. Think of it like a movie stunt double: they step in to replace the main actor in specific situations, allowing the scene to be filmed safely and in a controlled manner.
In our case, we use doubles to replace the dependencies of the class we are testing (the “Unit Under Test”). This allows us to:
-
Isolate the Unit Under Test: We ensure our test fails only if there is a problem in the class we are testing, not in its dependencies.
-
Control the Environment: We can force dependencies to behave in specific ways (e.g., simulate a database connection failure) to test all paths of our code.
-
Speed Up Tests: Replacing a slow dependency (like a network call) with an in-memory double makes test execution orders of magnitude faster.
The two most common types of Test Doubles you will use with PHPUnit are Stubs and Mocks.
2. Stubs: Simulating Behavior (State Control)
A Stub is a double that provides “canned answers” to method calls made during the test. The main purpose of a stub is to ensure your unit under test receives the data it needs to do its job, allowing the test to continue.
When to use a Stub? When you need to simulate the state of a dependency.
Practical Example:
Imagine a
WelcomeGenerator
class that depends on aTranslator
to get the correct greeting.// src/Translator.php interface Translator { public function getGreeting(): string; } // src/WelcomeGenerator.php class WelcomeGenerator { private $translator; public function __construct(Translator $translator) { $this->translator = $translator; } public function greet(string $name): string { $greeting = $this->translator->getGreeting(); return "{$greeting}, {$name}!"; } }
To test the
WelcomeGenerator
, we don’t want to depend on a real implementation ofTranslator
. Instead, we create a stub.// tests/WelcomeGeneratorTest.php use PHPUnit\Framework\TestCase; class WelcomeGeneratorTest extends TestCase { public function testGreetingInEnglish() { // 1. Create a stub for the Translator interface $translatorStub = $this->createStub(Translator::class); // 2. Configure the stub: When the 'getGreeting' method is called, // it should return the string 'Hello'. $translatorStub->method('getGreeting')->willReturn('Hello'); // 3. Inject the stub into our class under test $generator = new WelcomeGenerator($translatorStub); // 4. Execute and assert $result = $generator->greet('John'); $this->assertEquals('Hello, John!', $result); } }
In this test, it doesn’t matter how the translator works. We only care that it provides us with the string “Hello” so we can verify that the
WelcomeGenerator
concatenates it correctly with the name.3. Mocks: Verifying Interactions (Behavior Verification)
A Mock is a “smarter” double. Like a stub, it can return values, but its main purpose is to verify that certain methods were called on the dependency. Mocks are used to test the interaction between objects.
When to use a Mock? When you need to verify that your class under test is correctly calling the methods of its dependencies.
Practical Example:
Let’s imagine a
UserRegistrar
that, after saving a user, must notify aLogger
service.// src/Logger.php interface Logger { public function log(string $message): void; } // src/UserRegistrar.php class UserRegistrar { private $logger; public function __construct(Logger $logger) { $this->logger = $logger; } public function register(string $name) { // Logic to save the user to the database... echo "Saving user {$name}...\n"; // Notify the logger $this->logger->log("User {$name} registered successfully."); } }
The test needs to ensure that the
log
method of theLogger
is called.// tests/UserRegistrarTest.php use PHPUnit\Framework\TestCase; class UserRegistrarTest extends TestCase { public function testShouldLogMessageOnRegister() { // 1. Create a mock for the Logger interface $loggerMock = $this->createMock(Logger::class); // 2. Set the expectation: We expect the 'log' method // to be called EXACTLY ONCE. $loggerMock->expects($this->once()) ->method('log') ->with($this->equalTo('User Alice registered successfully.')); // 3. Inject the mock into our class under test $registrar = new UserRegistrar($loggerMock); // 4. Execute the method $registrar->register('Alice'); } }
Analyzing the Mock:
-
createMock()
: Creates the mock object. -
expects($this->once())
: This is the expectation. We are telling PHPUnit: “I expect that during the execution of this test, the following method will be called exactly one time.” Other options includeany()
,never()
,atLeastOnce()
. -
method('log')
: Specifies the method we are observing. -
with(...)
: (Optional) Specifies the exact arguments with which we expect the method to be called.
If the
log
method is not called, or is called more than once, or is called with a different message, the test will fail, even if there is no explicitassert
at the end. The expectation (expects
) itself is an assertion.4. Crucial Differences: Mocks vs. Stubs
Characteristic Stub Mock Main Purpose Provide canned answers (state) Verify interactions (behavior) Test Focus On the final state of the object under test On the communication between the test object and its dependency Verification The test makes assertions against the class under test The test verifies that the mock was used as expected ( expects
)Analogy An actor with a fixed script An actor being watched by a director who checks their actions 5. Other Doubles: Fakes and Spies
Although Mocks and Stubs are the most common, it’s worth briefly knowing two other types:
-
Fakes: These are objects that have a working implementation, but it’s much simpler than the production version. A classic example is an in-memory database that replaces a real connection to MySQL or PostgreSQL. It works, but it’s simplified for testing purposes.
-
Spies: A spy is a double that “spies on” method calls without interfering with them (unless instructed to do so). After the code is executed, you can make assertions about how the spy was used. In PHPUnit, you can often achieve spy-like behavior by using a mock with flexible expectations.
Next Steps:
Mastering the use of Stubs and Mocks is a giant leap in your ability to write effective and truly “unit” tests. In the next and final module, we will bring together all the knowledge you’ve acquired and explore advanced topics like code coverage analysis, integrating your tests into a CI/CD workflow, and best practices to ensure your test suite is a valuable asset to your project.
-