Add unittests with phpunit on a php application

In this tutorial I will provide the functional code that you can copy-paste it into your files, but I strongly RECOMMEND you to write the code instead of copy-paste it. This way you will learn much faster, and you might even encounter some mistakes you'll make duright writing the code, but this will also help you to learn.

Add phpunit to our previous project

Since we are using composer, it will be pretty easy to add the testing framework phpunit

Run the following command in your terminal, in the root directory of your project:

composer require --dev phpunit/phpunit ^9

Once the command finishes, you will see that you have a new dependency in composer.json file under require-dev directive.

Now that we have phpunit installed, we need to generate the configuration file for it. For this, run the following command in your terminal and hit enter for every question prompted by the command:

vendor/bin/phpunit --generate-configuration
# hit Enter key for every question

For now, you will make a small change in the phpunit.xml file, and that is to disable the results cache by adding cacheResult=false.

The file content should be like this:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         cacheResult="false"
         cacheResultFile=".phpunit.cache/test-results"
         executionOrder="depends,defects"
         forceCoversAnnotation="false"
         beStrictAboutCoversAnnotation="true"
         beStrictAboutOutputDuringTests="true"
         beStrictAboutTodoAnnotatedTests="true"
         convertDeprecationsToExceptions="true"
         failOnRisky="true"
         failOnWarning="true"
         verbose="true">
    <testsuites>
        <testsuite name="default">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <coverage cacheDirectory=".phpunit.cache/code-coverage"
              processUncoveredFiles="true">
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </coverage>
</phpunit>

Another required change we need to make, is to instruct composer that we have a new directory where we'll keep our tests.

For this, we'll need to add the following configuration for autoload-dev in our composer.json file:

"autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
}

In the end, our composer.json file should have the following content:

{
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  },
  "require-dev": {
    "symfony/var-dumper": "^5.4",
    "phpunit/phpunit": "^9"
  },
  "require": {
    "symfony/yaml": "^5.4"
  }
}

Then run the follwing command so that composer will load the new settings:

composer update

What we've done so far, was to install phpunit, add the configuration phpunit.xml file, and configure autoload-dev as PSR-4 standard with the namespace Tests\ for the tests directory. Practically, all classes that we define in tests directory will have the namespace starting with Tests\.

Now, let's create the tests directory:

mkdir tests

The purpose of unittests in the code is to make sure that the application behaves correctly for every change we make in the code. But besides creating the tests that are following the purpose of the application, we also must create some tests for edge cases or cases where the sources will not match our requirements. We must provide coverage in the code for those cases as well.

Let's create a directory where we'll keep our tests files sources:

mkdir tests/_files

Since we have two decoders in our application, yaml and json we'll provide tests coverage for bot of them. Later, when will be added support for other extensions, we'll have to add tests for those as well.

Let's create our first json file for testing purposes:

touch tests/_files/payload.json

with the following content:

{
  "title": "json title 1",
  "description": "json description 1"
}

Now that we have this file, is time to create our first phpunit test class. Usually in the tests directory I like to have the same structure as we have in the src directory. This will help with the structuring the code tests and will be easier to find the tests files when the project size increases very much.

Testing decoders

Create the necessary directory for our test:

mkdir -p tests/Books/Decoders/

Then, create the phpunit file inside this directory:

touch tests/Books/Decoders/JsonDecoderTest.php

with the following content:

<?php

namespace Tests\Books\Decoders;

use App\Books\Decoders\JsonDecoder;
use PHPUnit\Framework\TestCase;

class JsonDecoderTest extends TestCase
{
    const TEST_PAYLOADS = __DIR__ . '/../../_files/';

    public function testParsingFile()
    {
        $decoder = new JsonDecoder();

        $decodedContent = $decoder->parseFile(self::TEST_PAYLOADS . 'payload.json');

        $this->assertSame('json title 1', $decodedContent['title']);
        $this->assertSame('json description 1', $decodedContent['description']);
    }
}

Let's see what we're doing here. First, we define the namespace for the test file. Since we keep the same directory structure for our tests classes, the namespace of the class will be very similar to the namespace of the actual class we want to test, except for the first element of the namespace definition. Instead of App\ we have Tests\. The rest is the same.

Then, we have two use statements where we make a reference to the actual class we want to test, and the TestCase class that our test class extends TestCase.

After the use statements, we define our test class. The test class will have the same name as the class that we'll test, but we'll add Test at the end of the name and then extend the phpunit TestCase class, so we can benefit of the phpunit integration and helpers.

Inside the class we define a TESTPAYLOADS constant where we keep a reference to our tests/files directory where we'll keep all our test source files, like json, yaml and so on. The purpose of those files it to help us to test our application.

The actual tests are defined in this class as methods, and they will start with test prefix. You can also skip the prefix, but you'll need to add @test annotation in the docblock of the method, but this is up to you.

In the first test, we'll make sure that our JsonDecoder can open the json file by the file path provided as a parameter to the parseFile method of the decoder class.

Once we get read the file and get the content as an array, we can make some test assertions to make sure that our files gets decoded correctly.

In the assertions, is better to have hardcoded values. When you make changes in the source files or tests payload files, the tests might fail and for this you'll have to make some adjustments. But this will help you to make sure you don't change functionality with unwanted behaviour. This will be very helpful specially when you'll work on big projects.

If you run the following command:

vendor/bin/phpunit

you'll have an output similar to this one:

PHPUnit 9.5.11 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.27
Configuration: /path/to/project/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.020, Memory: 6.00 MB

OK (1 test, 2 assertions)

For now, we just made a test that works. The only thing to notice here is the last line of the output OK (1 test, 2 assertions). This is because in the test class, we define one test which is the testParsingFile method in the JsonDecoderTest class and inside the method we have two $this->assertSame. This assertion will make sure that the string from the decoded file is the same as the string defined in the test.

Let's create a new test json file which will not have a description.

touch tests/_files/payload-no-description.json

with the following content:

{
  "title": "json title no description"
}

Then add the following method inside the JsonDecoderTest class:

public function testParsingFileWithoutDescription()
{
    $decoder = new JsonDecoder();

    $decodedContent = $decoder->parseFile(self::TEST_PAYLOADS . 'payload-no-description.json');

    $this->assertSame('json title 1', $decodedContent['title']);
    $this->assertFalse(array_key_exists('description', $decodedContent));
}

Now, when you run vendor/bin/phpunit command in your terminal, the tests will fail and you'll have an output similar to this one:

PHPUnit 9.5.11 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.27
Configuration: /path/to/project/phpunit.xml

.F                                                                  2 / 2 (100%)

Time: 00:00.025, Memory: 6.00 MB

There was 1 failure:

1) Tests\Books\Decoders\JsonDecoderTest::testParsingFileWithoutDescription
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'json title 1'
+'json title no description'

/path/to/project/tests/Books/Decoders/JsonDecoderTest.php:28

FAILURES!
Tests: 2, Assertions: 3, Failures: 1.

The test is failing because we have a different title in our new file. Let's fix it by changing the assertion for the title element in the testParsingFileWithoutDescription to match the value we have defined in the json file.

Replace the line where we check the title with this line:

// this must replace the line in `testParsingFileWithoutDescription` method in class `JsonDecoderTest`
$this->assertSame('json title no description', $decodedContent['title']);

Then, when you run vendor/bin/phpunit again, you should have all test passed, but this time you'll see that we have 2 tests and 4 assertions. These values will increase as you add more tests and assertions. When you add tests, make sure you test the actuall implementation of the classes from the src directory.

Since we have finished the tests for the json decoder, for now, let's create the same thing for the Yaml decoder.

Create the yaml test payload

touch tests/_files/payload.yaml

with the following content:

title: yaml title 1
description: yaml description 1

And create the yaml without the description

touch tests/_files/payload-no-description.yaml

with the following content:

title: yaml title no description

Let's create the Yaml test file:

touch tests/Books/Decoders/YamlDecoderTest.php

with the following content:

<?php

namespace Tests\Books\Decoders;

use App\Books\Decoders\YamlDecoder;
use PHPUnit\Framework\TestCase;

class YamlDecoderTest extends TestCase
{
    const TEST_PAYLOADS = __DIR__ . '/../../_files/';

    public function testParsingFile()
    {
        $decoder = new YamlDecoder();

        $decodedContent = $decoder->parseFile(self::TEST_PAYLOADS . 'payload.yaml');

        $this->assertSame('yaml title 1', $decodedContent['title']);
        $this->assertSame('yaml description 1', $decodedContent['description']);
    }

    public function testParsingFileWithoutDescription()
    {
        $decoder = new YamlDecoder();

        $decodedContent = $decoder->parseFile(self::TEST_PAYLOADS . 'payload-no-description.yaml');

        $this->assertSame('yaml title no description', $decodedContent['title']);
        $this->assertFalse(array_key_exists('description', $decodedContent));
    }
}
Note that we have changed the assertions and the test payloads contents, to have the word yaml instead of json.

Now, if you run the following command in your terminal:

vendor/bin/phpunit

You should have 4 passing tests with 8 assertions

Testing Abstract class

In the previous tests, we were able to test the actual decoder classes simply by creating and object from them. But for the abstract classes we cannot do this because we cannot instantiate classes which are abstract.

Instead, we can use phpunit mocks to virtually create an object that will extend the abstract class, and we'll define the output of the method that we need to implement in the class.

Let's create the file for our new test:

touch tests/Books/FileDecoderTest.php

with the following content:

<?php

namespace Tests\Books;

use App\Books\FileDecoder;
use PHPUnit\Framework\TestCase;

class FileDecoderTest extends TestCase
{
    public function testProcessFile()
    {
        $decoder = $this->getMockForAbstractClass(FileDecoder::class);
        $decoder->expects($this->any())
            ->method('parseFile')
            ->willReturn([
                'title' => 'mock title 1',
                'description' => 'mock description 1',
            ]);

        $payload = $decoder->processFile('/path/to/my-mock-file.json');

        $this->assertSame('mock title 1', $payload['title']);
        $this->assertSame('mock description 1', $payload['description']);
        $this->assertSame('/path/to/my-mock-file.json', $payload['source']);
    }
}

So far, everything is the same as what we did in the previous two test classes. The only difference is the $decoder instantiation. For this, we use the mocking functionality of the phpunit to create our virtual class.

This is a subject that you should know more about because you'll need it quite often in the real applications and for this please read more in the phpunit documentation.

At this stage, if you run again vendor/bin/phpunit command you should have 5 passing tests and 11 assertions.

Test interfaces

Actually there is nothing to test here since the interfaces don't have functional code. So let's move on.

Test FilesManager class

The test for this class is quite interesting because practically you'll test the functionality of the cmd/run.php file. That is because in this test class, you'll have exactly the same code that you have in the cmd/run.php file, but you'll provide a different path for the files sources.

Let's get going.

First create a directory where we'll keep the fully functional files:

mkdir tests/_files/full-tests

and then, copy the files that have the full structure inside:

cp tests/_files/payload.* tests/_files/full-tests/

To make this final test pass, we need to add some getter methods inside the src/FilesManger.php class.

Add the following methods inside the FilesManager class in src/FilesManager.php file:

/**
 * @return array
 */
public function getDecoders()
{
    return $this->decoders;
}

/**
 * @return array
 */
public function getRegistry()
{
    return $this->registry;
}

/**
 * @return array
 */
public function getErrors()
{
    return $this->errors;
}
Please note that the getters we've added to the FilesManager class might not be necessary in the real application. We've added them here only for the purpose of tests execution.

Create the test class file:

touch tests/FilesManagerTest.php

with the following content:

<?php

namespace Tests;

use App\Books\Decoders\JsonDecoder;
use App\Books\Decoders\YamlDecoder;
use App\FilesManager;
use PHPUnit\Framework\TestCase;

class FilesManagerTest extends TestCase
{
    public function testFilesManager()
    {
        $manager = new FilesManager();
        $manager->addDecoder('json', new JsonDecoder());
        $manager->addDecoder('yaml', new YamlDecoder());

        $manager->readDirectory(__DIR__ . '/_files/full-tests');

        $this->assertEquals(2, count($manager->getDecoders()));
        $this->assertEquals(2, count($manager->getRegistry()));
        $this->assertEquals(0, count($manager->getErrors()));
    }
}

Now, if you run again the following command:

vendor/bin/phpunit

You'll see that all 6 tests are successful, and you have 14 assertions.

Congratulations ! so far.

At this point you might think that you have finish everything since you've created tests for your application, and they are all successful. But the big question is: Are those tests enough? I mean, are we sure we have covered all use cases ?

Let's see.

Run the following command to make sure we have XDEBUG extention installed for PHP.

php -v

You should have an output similar to this one:

PHP 7.4.27 (cli) (built: Dec 16 2021 18:14:21) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
    with Xdebug v3.0.4, Copyright (c) 2002-2021, by Derick Rethans
    with Zend OPcache v7.4.27, Copyright (c), by Zend Technologies

If you don't have the line with Xdebug ... then you need to install the extension on your computer.

Now, that we have the extension enabled on our local php, let's run the phpunit with test coverage report output:

XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html tests/_coverage

This command will run the phpunit tests, but it will write a coverage report in the tests/_coverage directory as HTML files.

Then, open a new terminal window or tab and in the project root directory run the following command:

php -S localhost:8000 -t tests/_coverage

Then open the url http://localhost:8000 in your browser.

This is the tests coverage report for our small application. You'll notice that the Books directory there is maked with green color and 100% coverage, while our FilesManager.php file has only 72%. That means we are missing some use cases. Click on the file and let's see what is missing.

Right on the top, you'll see that the methods readDirectory and logError are marked with a red/pink color. That is because we haven't tested the whole code inside those methods. If you scroll down the page, you'll see that in the readDirectory method we don't test the subdirectories functionality and we don't call the logError method if we have a file that we don't have a decoder for it.

To cover those steps too, is pretty simple in our case.

Just create a new directory:

mkdir tests/_files/full-tests/uncover

and then create an empty text file inside this directory

touch tests/_files/full-tests/uncover/my-file.txt

Now, if you run the tests again

XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html tests/_coverage

you will see that we'll have a failing test, but the coverage of our class has become 100%.

This is exactly what we want, except for the failing test. Let's fix it.

Open the file tests/FilesManagerTest.php and replace the 0 value with 1 for the count($manager->getErrors()) line.

Run the tests again, and they will pass.

Congratulations!!!