How Test Doubles Improve Your Code: PHPUnit and Laravel
Ever avoided writing tests because setting up a real database felt like too much work?
In PHPUnit, test doubles are a way of testing your code in isolation from its dependencies. Common types include stubs, mocks, and fakes.
Imagine you’re testing a function which has a dependency injected into it, e.g. a Database class. You want to test your function but you do not want to instantiate and test the dependency, at least not in this test.
Your test can use a test double. That is, it creates an object which looks and acts like your dependency and returns values you specify.
Let’s take a look at an example:
You have a User class. In this User class you have a function called getEmail which receives a user’s id and returns the user’s email. The class is instantiated with a Database object in the constructor and getEmail($id) uses a fetchRecord function from this Database object to get the email from the database.
Here’s what that might look like:
class User
{
public function __construct(public Database $db) {}
public function getEmail(int $id): string
{
$record = $this->db->fetchRecord("user", $id);
return $record->email;
}
}
Now, we would like to test this function. But we don’t want to use a real database in our tests and we don’t want to test multiple things. We don’t want to test the getEmail function AND the database’s fetchRecord function.
This is where test doubles come to our rescue.
createStub and createMock
Two of the methods PHPUnit provides us with are createStub and createMock. They are very similar. They both allow us to create mocks of our dependencies which will have the same interface our code under test expects, as well as return the same types that our code under test expects.
The main difference between test stubs and test mocks is that stubs simply provide an interface but do not check that the code in the mock was actually called.
- Stubs provide canned responses. The test passes whether or not the stubbed method is called.
- Mocks set expectations. The test fails if the expected method isn’t called with the correct arguments.
Test Stub
public function test_can_get_user_email_from_id(): void
{
$dbStub = $this->createStub(Database::class);
$mockRecord = (object)[
"id" => 1,
"email" => "test@example.com",
"name" => "Joe Black",
];
$dbStub->method("fetchRecord")
->willReturn($mockRecord);
$userModel = new User($dbStub);
$this->assertEquals("test@example.com", $userModel->getEmail(1));
}
Test Mock
public function test_can_get_user_email_from_id_again(): void
{
$dbMock = $this->createMock(Database::class);
$mockRecord = (object)[
"id" => 1,
"email" => "test@example.com",
"name" => "Joe Black",
];
$dbMock->expects($this->once())
->method("fetchRecord")
->with('user', 1)
->willReturn($mockRecord);
$userModel = new User($dbMock);
$this->assertEquals("test@example.com", $userModel->getEmail(1));
}
Use stubs when you only care that your code handles the response correctly, use mocks when you also need to verify the interaction happened.
So, in these examples we’re testing that our code under test can fetch and return a value which it gets from a database. But we’re mocking that database and injecting our mock into the User class.
Testing leads to better software design
On a slightly tangential note, testing code, and mocks in particular leads to better software practices.
For instance, consider a Person class which is extremely similar to the User class. The main difference is that it does not get the database class by dependency injection. It hard codes the instantiation of the database class in the getEmail function itself:
public function getEmail(int $id): string
{
$db = new Database;
$record = $db->fetchRecord("user", $id);
return $record->email;
}
As you can imagine, this works but it’s not great practice. A better approach would be dependency injection (better still if the constructor had an interface as its database property).
There are valid software principles why this isn’t ideal which we won’t go into here (see my blog on Dependency Injection).
When attempting to test this code the problem is immediately surfaced.
For instance, if we tried to use a mock or stub, like this:
public function test_can_get_person_email_from_id(): void
{
$dbStub = $this->createStub(Database::class);
$mockRecord = (object)[
"id" => 1,
"email" => "test@example.com",
"name" => "Joe Black",
];
$dbStub->method("fetchRecord")
->willReturn($mockRecord);
$personModel = new Person();
$this->assertEquals("test@example.com", $personModel->getEmail(1));
}
This test will fail. We’re creating a stub but we have no way of passing this to the Person class. Consequently the Person class’s getEmail function instantiates a concrete database class.
The test fails because our stub is never used—the Person class creates its own Database instance internally, bypassing our stub entirely.
To test this version our test suite would need to set up a database and populate it.
Fakes in Laravel
In Laravel we can mock Queues, Mail, and Event just as easily.
Imagine we have a Car Model. It has a function in the model to set its status. That way we can set the status of a car as sold.
When a car is sold we want to send an email to the dealership to let them know that the car has been sold.
We achieve this by triggering an event in the setStatus function. When the status is set to “sold” we fire a CarSold event.
This event has a listener called EmailDealerWhenCarSold. This listener then dispatches a job called SendEmail and finally in the SendEmail job we send a mail using Laravel’s Mail class.
That’s a fair amount of abstraction.
We want to test our setStatus function without necessarily having real queues, events, listeners and mailer triggering.
This is where fakes come in handy.
We could test that the status is set in this function, but we could also mock the queue and the mailable to ensure that those are triggered when the status is set.
For example, mocking the queue:
public function test_event_queues_job(): void
{
Queue::fake();
$car = Car::factory()->create(["status" => "in_stock"]);
$this->assertEquals("in_stock", $car->status);
$car->setStatus("sold");
$this->assertEquals("sold", $car->status);
Queue::assertPushed(SendEmail::class, fn ($job) => $job->car->id === $car->id);
}
In this example we’re faking the queue, meaning that the event is triggered and the listener runs. The listener calls dispatch(), but instead of the job actually being queued and executed, it’s intercepted and recorded. The job’s
handle() method never runs.
We can assert that the job has been pushed and that it received the correct parameters, but remember, it’s not the real job which was triggered, its just a mock.
Note that in the test we assert that the job was pushed but we also assert in the same assert statement that it received the correct car object.
Similarly we can fake the Mail class:
public function test_job_sends_mail(): void
{
Mail::fake();
$car = Car::factory()->create(["status" => "in_stock"]);
$this->assertEquals("in_stock", $car->status);
$car->setStatus("sold");
$this->assertEquals("sold", $car->status);
Mail::assertSent(CarSoldEmailToDealer::class, fn($mail) => $mail->hasTo("dealer@example.com"));
}
This means in this case we’ve triggered a real event, had a real listener dispatching a real job, but when that job called Mail::send(), the mail was intercepted and recorded rather than actually sent. As before we can assert that the mail was sent and that it was sent to the correct email address.
In the same way we can also test the function with a fake Event:
public function test_setting_status_to_sold_raises_event(): void
{
Event::fake();
$car = Car::factory()->create(["status" => "in_stock"]);
$this->assertEquals("in_stock", $car->status);
$car->setStatus("sold");
$this->assertEquals("sold", $car->status);
Event::assertDispatched(CarSold::class, fn($event) => $event->car->id === $car->id);
}
Note in this case the event itself is faked. That means we can’t assert anything downstream in this test. I.e, we cannot assert that the Mail is sent because this test faked the event which triggers that whole chain of actions.
| Fake | What Still Runs | What’s Intercepted |
| Event::fake() | Nothing downstream | Events are recorded, listeners don’t run |
| Queue::fake() | Events, Listeners | Jobs are recorded, job handlers don’t run |
| Mail::fake() | Events, Listeners, Jobs | Mail is recorded, not sent |
So, there you have it. Stubs, Mocks and Fakes help us test our code without needing to have all the dependencies set up.
It also may lead to better code when we have testing top of mind because we think about dependency injection. Similarly, instead of directly calling a third party API in a function which we couldn’t easily mock in a test, we might wrap that call in a class and inject the dependency. This will allow us to test our code but mock the API calls.
Code Samples
You can get the code samples used in this post from Github:
Laravel Code: https://github.com/jsmcm/laravel-test-doubles
PHP Code: https://github.com/jsmcm/php-test-doubles
John, a seasoned Freelance Full Stack Developer based in South Africa, specialises in delivering bespoke solutions tailored to your needs. With expertise in back end languages and frameworks, PHP, Laravel and Golang and Front end frame words Vue3, Nuxt3 as well as Angular, I am equipped to tackle any project, ensuring robust, scalable, and cutting-edge outcomes.
My comprehensive skill set enables me to provide exceptional freelance services both remotely and in person. Whether you’re seeking to develop an innovative application or require meticulous refinement of existing systems, I am dedicated to elevating your digital presence through unparalleled technical prowess and strategic development methodologies. Let’s connect to transform your vision into reality.