How to write tests properly in PHP (part 1)

3 November 2021

Testing is tough. It may seem simple but has a lot of pitfalls even for developers with many years of experience.
Situation is even worse because there is no way for a novice tester to validate them without a skilled mentor.
This guide is a way to avoid some common pitfalls and give general rule of thumb for writing tests in PHP.
Also, it is the most common way to write object-oriented code in PHP. Assuming that I will use OOP paradigm in examples.

Testing tools

Testing is easier with specialized tools, because they provide a layer of abstraction
to simplify assertions (testing results check) and code generation (mocking and presetting environment before running tests).
There are multiple popular tools in PHP, such as phpunit or codeception.
We use composer package management system to install testing tools in our projects.
For examples in this post I am going to use phpunit.

Remember that testing must be kept separate from production so be careful and don't install testing tools in the require section.

incorrect approach
composer require phpunit/phpunit
composer.json

{
  "require": {
    "php": "7.4",
    "phpunit/phpunit": "^9.0"
  }
}

correct approach
composer require --dev phpunit/phpunit
composer.json

{
  "require": {
    "php": "7.4"
  },
  "require-dev": {
    "phpunit/phpunit": "^9.0"
  }
}

Where should the tests live?

In short, tests should live as close to the testing code as possible.
It helps to keep them reasonable, actual and helps to describe code itself.
Some programming languages support storing tests in the same file where the code lives.

src/Calculator.php

<?php

class Calculator
{
    public static function divide(float $a, float $b): float
    {
        return $a/$b;
    }
}

class CalculatorTest extends \PHPUnit\Framework\TestCase
{
    function test_divide(): void
    {
        self::assertEquals(4.0, Calculator::divide(8, 2));
    }
}

this one is absolutely not recommended for php

Others encourage placing tests near the testing file and name it with test suffix.

src/Calculator.php

<?php

class Calculator
{
    public static function divide(float $a, float $b): float
    {
        return $a/$b;
    }
}

src/CalculatorTest.php

<?php

class CalculatorTest extends \PHPUnit\Framework\TestCase
{
    function test_divide(): void
    {
        self::assertEquals(4.0, Calculator::divide(8, 2));
    }
}

this approach is totally fine, but extremely rare in wild

The last approach is to place tests symmetrically to testing code path.

src/Calculator.php

<?php

namespace Vendor\Package;

class Calculator
{
    public static function divide(float $a, float $b): float
    {
        return $a/$b;
    }
}

tests/CalculatorTest.php

<?php

namespace Tests\Vendor\Package;

use Vendor\Package\Calculator;

class CalculatorTest extends \PHPUnit\Framework\TestCase
{
    function test_divide(): void
    {
        self::assertEquals(4.0, Calculator::divide(8, 2));
    }
}

This approach is the most common for PHP. Generally, your project structure will look like this

src/
├─ code.php
tests/
├─ codeTest.php
composer.json

What should I test?

Before one starts writing tests, they must understand the reason that stands behind testing.
Why are they writing tests?
What do they want to achieve?
General reasons are as follows:

  • make sure that the code does what it has to do
  • make sure that breaking working code won't get away unnoticed

Additionally, there is a non-obvious reason that comes afters years of experience.
Tests naturally help to raise overall code quality.
How is that? Well, tests represent usage of the code. It means, that that developer, who writes them, faces most of the
problems of their usage. If he faces troubles during Arrangement, it means that there is a problem with bringing
system to certain condition, or with particular code initialization.
Every part of the system shall match.
If the one has class named Calculator, then it most likely has to have public methods add, subtract, divide, multiply.
Otherwise, one may end up writing confusing tests for confusing functionality at best.
Rule of thumb: test only interfaces (public methods/properties/everything that is meant to be used explicitly).

Okay, ho do I do it properly?

Testing wouldn't be that difficult if it had one ring to rule them all.
There are rules to guide us through perplexities of the path, yet a lot of developers end up testing side effects, mocks or even compiler.
The most basic that helps to take first steps is a "triple A" (AAA) rule.

  • Arrange
  • Act
  • Assert

Arrange means preparation. It is all about bringing environment to the condition where a test can be started.
Initializing framework, bringing up database source, creating domain models, mocking/stubbing services.
Everything that developer intends to do before starting the test.

Act is an action that is being tested. Usually this is the shortest part of the test because
it means one or several interconnected calls.
After that there is only one step left - to check if there happened what we wanted to happen.

Assert makes sure that arranged environment produced expected results after action.
This one is usually the biggest part of the test because it requires careful and meaningful checks.
So, assert is about "if the action did exactly these things"?

At the beginning I'd recommend one to start their tests with placing comments before each section. Further, one may
stop doing that as it becomes more natural to them.

<?php

namespace Tests\Vendor\Package;

use Vendor\Package\Calculator;

class CalculatorTest extends \PHPUnit\Framework\TestCase
{
    public function testDivide(): void
    {
        // Arrange
        $dividing = 10.0;
        $divider  = 5.0;
        
        // Act
        $result = Calculator::divide($dividing, $divider);
        
        // Assert
        self::assertEquals(2.0, $result);
    }
}

Why are we really doing all this?

I have a few widely known answers for that questions.

  • To make sure that our code works
  • To make sure that our code works after changes made to it

Usually these are enough for the most of the developers to keep writing tests. But there is more.

Let's take a look at an example below.

<?php

class Translator
{
    public function translate(string $text): string
    {
        return file_get_contents('https://some-translation-api/?from=en&to=de&text=' . $text);
    }
}

class TranslatorTest extends \PHPUnit\Framework\TestCase
{
    public function testTranslation(): void
    {
        $translator = new Translator();
     
        $translatedText = $translator->translate("Mother");
        
        self::assertEquals("Mutter", $translatedText);
    }
}

We have a translator class that translates given text from english to german.
And, we have a test that checks if the translator correctly translates "Mother" to "Mutter".

It seems perfectly fine test, doesn't it?
It would, if it was an integration test that check if that API works exactly as expected.
Here is the problem - there is no way to write unit test for Translator. It is locked with file_get_contents and
hardcoded API url. It means that each time we call that function it will create a http-request.
It will make tests slow. It will also require properly working https://some-translation-api service in the testing environment.
It wouldn't be that obvious if we were not writing tests, for it would have almost unquestionable use-cases only.

Here is an alternative code I wrote.

<?php

interface HttpClientInterface
{
    public function sendGet(string $url): string;
}

class HttpClient implements HttpClientInterface
{
    public function sendGet(string $url): string
    {
        return file_get_contents($url);
    } 
}

class Translator
{
    private HttpClientInterface $transport;
    
    public function __construct(HttpClientInterface $transport)
    {
        $this->transport = $transport;
    }
    
    public function translate(string $text): string
    {
        return $this->transport->sendGet('https://some-translation-api/?from=en&to=de&text=' . $text);
    }
}

class TranslatorTest extends \PHPUnit\Framework\TestCase
{
    public function testTranslationVariantIntegrationTest(): void
    {
        $translator = new Translator(new HttpClient());
     
        $translatedText = $translator->translate("Mother");
        
        self::assertEquals("Mutter", $translatedText);
    }
    
    public function testTranslationVariantUnitTest(): void
    {
        $client = $this->createMock(HttpClientInterface::class);
        $client
            ->expects(self::once())
            ->method('sendGet')
            ->with(self::equalTo('https://some-translation-api/?from=en&to=de&text=Mother'))
            ->willReturn('SomeTranslation');
            
        $translator = new Translator($client);
     
        $translatedText = $translator->translate("Mother");
        
        self::assertEquals("SomeTranslation", $translatedText);
    }
}

This demonstrates that tests help to write flexible and reusable code.
In current condition we can replace HttpClient with any other implementation that we prefer, or create decorated clients
such as LoggingClient. Did you notice that we have also solved the single responsibility violation? Because we did. There is one more problem1 in that class, but I hope you will find it by yourself. If you don't or not sure about your guess, feel free to contact me.

Here is a link to the repository that contains as many PHP-test examples as one may face during their career.

Let's take a break from here and continue in a part 2.

Don't take everything plain: we have to challenge and prove the information we face.

Here is what really helps me to do it.