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.