Table of contents
Test doubles is a technique in which you replace the actual object with pretend object or a real method by pretend method or pre-define the return data and function arguments for a method to ease the testing purpose. In the real world, our application may have various components that require connecting different databases, APIs, and other services that cannot be used in the test environment. Instead of depending on networks or other services or components, it is easier to use test doubles for testing purposes.
Gerard Meszaros first used the term "Test Double" term in his book xUnit Test Patterns, to give a common name for stubs, mocks, fakes, dummies, and spies. The name comes from the notion of a Stunt Double in the movie. Meszaros has defined five types of doubles which we will study from the perspective of PHPUnit & Mockery
To illustrate the concept in detail,
Let's define an interface called GithubInterface
as below,
interface GithubRepository
{
public function find(string $uuid);
public function get();
public function create(RepositoryDTO $userDto);
}
Here, GithubRepository
the interface has three methods, find(string $uuid)
to find repositories by uuid, get()
to get users' repositories, and create(RepositoryDTO $userDto)
to create new repositories in Github.
Stub
The idea of returning configured values in a method call is called stub. In the above example, the gets method is expected to return a list of repositories in array format from a remote GitHub server. Instead of depending on network-dependent service, we could stub the above get
method just to define the return data for the get
method as
/** @test */
public function github_repository_get_method_with_stubs()
{
$stub = $this->createStub(GithubRepository::class);
// using PHPUnit's createStub method to create stub
$stub->method('get') ->willReturn([
['uuid' => 12345678, 'name' => 'laravel/laravel'],
['uuid' => 12345679, 'name' => 'laravel/framework']
]);
$this->swap(GithubRepository::class, $stub);
$this->assertTrue(
is_array(
app()->make(GithubRepository::class)->get())
);
}
In the above example, a stub GithubRepository::class
is created and returns an array of repositories. The swap
method here is the Laravel swap method, which binds the current stubs into Service Container.
Spy & Mock
In definition, Spy & Mock are very similar to each other as Spy stores the interactions made on execution, and allows us to make assertions against those interactions, whereas Mock is an object with pre-defined expectations.
Difference between Mock & Spy:
Mock | Spy |
Mock defines expectation at first and asserts that after the interaction has happened. | Spy stores interactions first and then it asserts against those interactions. |
Expectations should be defined before any interaction happens. | Doesn't care about expectations even if interactions are stored. |
PHPUnit support Mock. | The exact spy term doesn't exist in PHPUnit. |
PHPUnit Mock & Spy: PHPUnit doesn't support spy but supports the Mock.
/** @test */
public function users_repository_find_method_with_spy()
{
$mock = $this->createMock(GithubRepository::class);
$mock->expects($this->exactly(1))
->method('find')->with(1)
->willReturn(['uuid' => 12345678, 'name' => 'laravel/laravel']);
$this->swap(GithubRepository::class, $mock);
$this->assertTrue(is_array(app()->make(UserRepository::class)->find(1)));
}
Here, PHPUnit's mock works quite differently than the definition. It works as a combination of both spy and mock, in which the expectation is not required even if it's mock, but can define expectation as shown in the example above.
$mock = $this->createMock(GithubRepository::class);
$this->swap(GithubRepository::class, $mock);
print_r(app()->make(GithubRepository::class)->find(1));
In the above example, we haven't defined the expectation for the find method, and it simply returns null. If the same mock was created with mockery, it would have thrown an exception.
Mockery Mock & Spy: Mockery's Spy and Mock work as per our definition that mock expects expectations and spy stores interactions and allows us to assert against it. Here is an example of a Mock
/** @test */
public function github_repository_get_method_with_mock()
{
$stub = Mockery::mock(GithubRepository::class);
$stub->shouldReceive('get')
->andReturn([
['uuid' => 12345678, 'name' => 'laravel/laravel'],
['uuid' => 12345679, 'name' => 'laravel/framework']
]);
$this->swap(GithubRepository::class, $stub);
$this->assertTrue(is_array(app()->make(GithubRepository::class)->get()));
}
As mentioned above, Spy doesn't expect expectations, whereas mock does, so if we remove $stub->shouldHaveReceived('get')
the spy example, it simply works, whereas removing the Mock's expectation causes an error in the mock example.
One flexibility about mockery is that expectations can be set even for spy.
/** @test */
public function github_repository_get_method_with_spy_wrong()
{
$stub = Mockery::spy(GithubRepository::class);
$stub->shouldReceive('get')
->andReturn([
['uuid' => 12345678, 'name' => 'Bedram Tamang'],
['uuid' => 12345679, 'name' => 'Sajan Lamsal']
]);
$this->swap(GithubRepository::class, $stub);
$this->assertTrue(is_array(app()->make(GithubRepository::class)->get()));
}
Dummies
Dummies are fill-only parameters in testing, which are never used. Consider the above create method, which requires an instance of RepositoryDTO
, but we are doing test doubles of all the methods.
So, we possibly pass a new instance of RepositoryDTO
just to fill parameters.
/** @test */
public function github_repository_with_dummies()
{
$mock = $this->createStub(GithubRepository::class);
$mock->method('create')
->willReturn([]);
$this->swap(GithubRepository::class, $mock);
$dummy = new RepositoryDTO();
dd(app()->make(GithubRepository::class)->create($dummy));
}
Fakes
Fakes are short-cut implementation of real objects, it tries to mimic the actual implementation but in a short-cut way. Consider another example, in which we define UserRepository
an interface and a method get()
to get the list of users from a database.
interface UserRepository
{
public function get(): Collection|array;
}
and the actual implementation would be,
class UserEloquentRepository implements UserRepository
{
public function get(): Collection|array
{
return User::query()->get();
}
}
To illustrate the concept of fake, let's try not to touch the database when getting user lists from the database. To do so, let's define a UserFakerRepository
as
class UserFakeRepository implements UserRepository
{
public SupportCollection $data;
public function get()
{
return $this->data;
}
}
and the fake UserFakeRepository
is swapped during the test.
/** @test */
public function it_fakes_the_user_repositor()
{
$userRepository = new UserFakeRepository();
$userRepository->data = collect();
$this->swap(UserRepository::class, $userRepository);
print_r(app()->make(UserRepository::class)->get());
}
For syntactic sugar, let's define fake
method in UserFakeRepository
,
public static function fake(\Closure $closure)
{
$class= new static();
$class->data = $closure();
app()->instance(UserRepository::class, $class);
}
and our test would be,
/** @test */
public function it_fakes_the_user_repository()
{
UserFakeRepository::fake(function () {
return collect([new User()]);
});
print_r(app()->make(UserRepository::class)->get());
}
Conclusion
In conclusion, Stub
can be great to use when the requirement is just to return some pre-defined data from some method. Mock and Spy can be used for a similar purpose but are also useful to assert expectations. Either PHPUnit or Mocker can be used for this purpose. And fake can be used to fake the whole class, and useful especially when it's difficult to mock or stub.