Using Dependency Injection and Service Container can make testing easier. But how?

Using Service Container and Dependency Injection, makes testing easier. But how? On this article I would like to focus on that, with a simple and straight forward example. We are going to see that it's difficult (if not impossible) to test an dependency correctly without them.

To avoid being verbose, I will refer to Service Container as Container, and Dependency Injection as DI.

You should have some basic knowledge of DI and the Container, to really understand the example. I will not write about how these 2 work here.

Suppose we have the following service:

<?php

namespace App\Services\Dummy;

use App\Models\User;

class UserService
{
    public function deleteUser(User $user): void
    {
        $user->delete();

        $logService = new LogService();
        $logService->logUserDeletion($user);
    }
}

And we want to write a test for our deleteUser function.

We want to assert that our user is being deleted, and our LogService is being called.

We do not want to test the functionality of our LogService, because ideally that service would have it's own dedicated tests.

We can achieve that, by mocking the LogService instance. Laravel makes that pretty easy, because it uses Mockery package under the hood, and wraps it to its own easy to use way.

So you may think that we could do that:

<?php

namespace Tests\Feature;

use App\Services\Dummy\LogService;
use App\Services\Dummy\UserService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\WithFaker;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class UserServiceTest extends TestCase
{
    use DatabaseTransactions;
    use WithFaker;

    #[Test]
    public function deleteUser()
    {
        $user = User::factory()->create();

        $this->mock(LogService::class, fn(MockInterface $mock) =>
            $mock->shouldReceive('logUserDeletion')
                ->with($user)
                ->once()
        );

        $userService = new UserService();
        $userService->deleteUser($user);
        
        /*
         * More assertions maybe, for example that our user got deleted.
         * Out of scope for what we want to focus on.
         */
    }
}

But if you try to run the test, you can see that this is not working.

And it is not working because on deleteUser method, we initialise by ourselves a new object of LogService.

$logService = new LogService();

And that's bad, we do not want to do something like that in our code.

  1. It makes our code tightly coupled. Now our deleteUser function highly depends on our LogService, and with how this class is being initialised.
  2. It makes our tests harder, as we saw above. We can not tell our deleteUser method, to use a LogService mock, and not the real one.

So what's the solution? How we are going to make this better and more testable?

First of all we have to use the Container, to retrieve our dependencies.

<?php

namespace App\Services\Dummy;

use App\Models\User;

class UserService
{
    public function deleteUser(User $user): void
    {
        $user->delete();

        $logService = app(LogService::class);
        $logService->logUserDeletion($user);
    }
}

We now do not initialise by ourselves the LogService, we just retrieve it from the Container.

Now if you execute the test, it will pass. Why? Because by default, Laravel's mock method will bind the new mock object in our container, to our LogService class.

So now when we try to receive LogService from the container, we get the mock object, and we can now make assertions on that.

And this happens because of how mock method works under the hood:

 protected function mock($abstract, ?Closure $mock = null)
  {
      return $this->instance($abstract, Mockery::mock(...array_filter(func_get_args())));
  }

It is a wrapper of Mockery's mock method. Not only it create a mock object, it also "inserts" that to the container, binding it to the class/interface we provided.

That's why!

There is also some more room for improvement here.

Even tho we get our dependency from the Container, we can also use DI on our UserService class, like below:

<?php

namespace App\Services\Dummy;

use App\Models\User;

class UserService
{
    public function __construct(
        private readonly LogService $logService
    )
    {
    }

    public function deleteUser(User $user): void
    {
        $user->delete();

        $this->logService->logUserDeletion($user);
    }
}

Dependencies are now on our constructor and we can clearly see them on one place. For our example we just have only the deleteUser method, but think if there were way more. Each dependency would be visible only on specific methods, making it a little harder to have a good overview of our class and it's dependencies.

But since we used DI on our UserService, we have to do fetch UserService from Container too, because now we initialise our UserService manually, and the class expects one dependency. Without using Container, PHP will expect to manually pass constructor parameters, something that we do not want.

<?php

namespace Tests\Feature;

use App\Services\Dummy\LogService;
use App\Services\Dummy\UserService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\WithFaker;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class UserServiceTest extends TestCase
{
    use DatabaseTransactions;
    use WithFaker;

    #[Test]
    public function deleteUser()
    {
        $user = User::factory()->create();

        $this->mock(LogService::class, fn(MockInterface $mock) =>
            $mock->shouldReceive('logUserDeletion')
                ->with($user)
                ->once()
        );

        $userService = app(UserService::class);
        $userService->deleteUser($user);

        /*
         * More assertions maybe, for example that our user got deleted.
         * Out of scope for what we want to focus on.
         */
    }
}

That's it, our code is now easier to maintain and more testable. Container & DI are 2 incredible tools that can improve our codebase dramatically, if used correctly.

Subscribe to Lioy

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe