Testing decoupled PHP code?
post

One of the reasons many experienced developers encourage the concept of "decoupling your code" is so that it makes testing your code straightforward. I wanted to share an example of how I went about writing some tests for some code that I had refactored from being a tangled spaghetti-like mess.

Here is the code I am looking at, written targeting PHP 8.1.

<?php
declare(strict_types=1);

namespace Webreg\Query;

use Slim\Psr7\Response;
use Slim\Psr7\Request;
use Twig\Environment;
use Webreg\Repository\GameRepository;
use Webreg\ViewModel\Rotations;

final class RotationManagementQuery
{
    public function __construct(
        private Environment $twig,
        private GameRepository $gameRepository,
        private Rotations $rotations
    ) {}

    public function __invoke(Request $request): Response
    {
        $params = $request->getQueryParams();
        $maxWeek = $this->gameRepository->getMaxWeek();
        $week = (isset($params['week'])) ? (int) $params['week'] : $maxWeek;
        $rotations = $this->rotations->getAllByWeek($week);
        $response = new Response(200, null);
        $response->getBody()->write($this->twig->render('rotations/management.twig', [
            'current_week' => $week,
            'rotations' => $rotations,
        ]));

        return $response;
    }
}

I am using Command Query Responsibility Segregation in this application's architecture, and this bit of code is a Query that will retrieve a collection of pitchers who will be starting for baseball teams in my simulation baseball league for a particular week.

In following some rules for decoupling, you can see some of the following decisions had been made:

  • not extending off of a base class
  • all dependencies are injected at run time
  • single-method for the class

Identifying Dependencies

So what are the dependencies I will need?

In the old architecture, I was creating those dependencies deep inside the "business logic" and therefore it was very hard to write anything other than some kind of "check the HTML output" type of test. Ironically that is what the new test does as well but this sort of decoupled architecture leads to a much more straightforward test.

Identifying Output

In these tests, I wanted to make sure that if I had at least one rotation stored in the database for a particular week, when the page renders I should see that rotation in the output somewhere.

Test Skeleton

As always, I break out the Arrange-Act-Assert pattern to create the skeleton of the test:

    /** @test */
    public function it_returns_expected_rotation(): void
    {
        // Arrange

        // Act

        // Assert
        self::fail();
    }

Remember, you always want to start with a failing test.

Arranging our dependencies

These days I try and use the fewest number of test doubles in my test scenarios. Given the dependencies I needed, I was going to need three "fake" dependencies, configured to provide only the implementation details required to make the scenario work.

I don't want to get into a longer discussion on the use of test doubles except to say that the decoupling strategy I am using will minimize the chances that any doubles drift from how the code is actually implemented.

Trust me, I do this for a living!

    /** @test */
    public function it_returns_expected_rotation(): void
    {
        // Arrange
        $loader = new FilesystemLoader(__DIR__ . '/../../templates/');
        $twig = new Environment($loader);

        $gamesRepo = $this->createMock(GameRepository::class);
        $gamesRepo->expects($this->once())
            ->method('getMaxWeek')
            ->willReturn(1);

        $testRotation = new ArrayCollection();
        $testRotation->add([
            'franchise_id' => 1,
            'ibl' => 'MAD',
            'rotation' => 'One, Two, Three'
        ]);

        $viewModel = $this->createMock(RotationsUsingDoctrine::class);
        $viewModel->expects($this->once())
            ->method('getAllByWeek')
            ->willReturn($testRotation);

        $request = $this->createMock(Request::class);
        $request->expects($this->once())
            ->method('getQueryParams')
            ->willReturn(['week' => 1]);

        // Act

        // Assert
        self::fail();
    }

Acting on the code-under-test

This has always struck me as a weird way to describe "executing the code we are testing" but I guess "Arrange-Execute-Assert" doesn't flow in English quite the same way.

Now that I have all my dependencies created and configured the way I need, time to run the code and grab some results I can test.

    /** @test */
    public function it_returns_expected_rotation(): void
    {
        // Arrange

        // Act
        $query = new RotationManagementQuery($twig, $gamesRepo, $viewModel);
        $results = $query->__invoke($request);

        // Assert
        self::fail();
    }

Asserting results of code execution

Just like I did before, I am checking the HTML output from executing this Query to make sure I am seeing values that I expect

    /** @test */
    public function it_returns_expected_rotation(): void
    {
        // Arrange

        // Act

        // Assert
        self::assertStringContainsString('One, Two, Three', $results->getBody());
    }

When building my assertions, I tend to go with "what are the fewest number of things I need to do in order to prove the code is working as expected."

In this case, I felt checking that I see an expected "pitching rotation" in the output is good enough.

Here is what the whole test looks like:

<?php

namespace Webreg\Test\Query;

use Doctrine\Common\Collections\ArrayCollection;
use Slim\Psr7\Request;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Webreg\Query\RotationManagementQuery;
use PHPUnit\Framework\TestCase;
use Webreg\Repository\GameRepository;
use Webreg\ViewModel\RotationsUsingDoctrine;

class RotationManagementQueryTest extends TestCase
{
    /** @test */
    public function it_returns_expected_rotation(): void
    {
        // Arrange
        $loader = new FilesystemLoader(__DIR__ . '/../../templates/');
        $twig = new Environment($loader);

        $gamesRepo = $this->createMock(GameRepository::class);
        $gamesRepo->expects($this->once())
            ->method('getMaxWeek')
            ->willReturn(1);

        $testRotation = new ArrayCollection();
        $testRotation->add([
            'franchise_id' => 1,
            'ibl' => 'MAD',
            'rotation' => 'One, Two, Three'
        ]);

        $viewModel = $this->createMock(RotationsUsingDoctrine::class);
        $viewModel->expects($this->once())
            ->method('getAllByWeek')
            ->willReturn($testRotation);

        $request = $this->createMock(Request::class);
        $request->expects($this->once())
            ->method('getQueryParams')
            ->willReturn(['week' => 1]);

        // Act
        $query = new RotationManagementQuery($twig, $gamesRepo, $viewModel);
        $results = $query->__invoke($request);

        // Assert
        self::assertStringContainsString('One, Two, Three', $results->getBody());
    }
}

Some thoughts that occur to me from looking at the final test:

  • decoupling makes your dependencies quite visible during test creation
  • always make sure to only implement the behaviour of your test doubles that you need
  • your Arrange step will almost always be the largest part of any test
  • PHPUnit's built-in test double generators also act as assertions
  • sometimes the simplest way of verifying behaviour is what you should use

From my perspective, decoupling the code allows me to focus on smaller pieces of application behaviour, reducing the chances that a change in this code breaks something somewhere else.

For more details on the approach I am using for decoupling my code, check out Matthias Noback's "Recipes for Decoupling".

Categories: PHP, testing