Unit- and Feature testing PHP

Photo by DANIEL BECERRA on Unsplash

Photo by DANIEL BECERRA on Unsplash

Automated testing enables software developers to quickly add new features and handle breaking changes in dependencies without adding technical debt by delaying updates. 

Unit and feature testing is necessary for complex and mission-critical applications to ensure that the dev team and product owners can sleep well at night, knowing that new features and bug fixes do not create new problems in existing parts of the application.

In this article, I will guide you through installing the necessary packages and writing a simple unit tests and a couple of basic feature tests for any modern PHP project running on a Debian-based Linux distro.

Overview

A short introduction to automated testing

Automated testing, sometimes incorrectly called unit testing, is simply the practice of writing scripts or programs that verify that software works as intended. Many philosophies and methodologies exist for writing and organizing automated tests, but two main types of tests are generally written for software projects: Unit tests, and feature tests.

Unit tests verify that the smallest possible unit of code is tested. This is typically a function, or a method in a class. The idea is that if the unit works as intended, all uses of that unit will also work as intended. A unit test of a getter method will assert that the returned output is as expected. A unit test of a method that processes data asserts that the returned output was as expected based on the provided input.

Feature tests are the opposite of a unit test. Unlike unit tests, feature tests assert functionality from a holistic perspective. For example, a feature test can assert that an API endpoint with CRUD functionality works as intended by asserting that all available services provided by that API endpoint correctly create updates, read, and delete data. Therefore, an API endpoint feature test will be performed by sending HTTP requests and asserting that the API endpoint reacts correctly. Because of this, a feature test will assert the functionality of all units (methods and functions) used by that feature. This ensures that not only a single unit of code works as intended, but the whole feature works as intended.

Writing feature tests on web-based projects is probably the most effective way to secure the confidence and sleep of product owners and developers. Unit tests ensure that all the project's building blocks are healthy.

From a stakeholder perspective, I think feature tests and unit tests follow the 80/20 rule. Feature tests will take 20% of the time to write and assert 80% of functionality. Unit tests will take the remaining 80% of the time and assert the last 20% of the functionality. 

Most projects need both unit tests and feature tests. However, I would always prioritize feature tests in a real-world setting with tight budgets and timelines.

Setup

Installing needed packages

If you use a Vanilla PHP, or a framework without PHPUnit pre-installed, you must install it yourself. 

Most web frameworks come with a testing framework preinstalled and preconfigured. An example is the popular web framework Laravel. It includes the PHPUnit package as part of its base installation and a bootstrap script that makes all framework features available for the tests. With Laravel, you do not have to install anything to get started.

In this article, I will demonstrate the installation and configuration of PHPUnit on a basic application built without any framework.

Start by installing all the required PHP modules.

sudo apt install php-cli php-json php-mbstring php-xml php-pcov php-xdebug

Once the PHP modules are installed, you can install the composer packages that will be used for testing. To install them, run the following command in your project root.

composer require phpunit/phpunit

To verify that the installation succeeded, run these commands:

php ./vendor/bin/phpunit --version
PHPUnit 9.6.20 by Sebastian Bergmann and contributors.

The final piece is the phpunit.xml file defining your test suites and other settings. Add the file containing these lines of code to your project root.

vim phpunit.xml
phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    bootstrap="vendor/autoload.php"
    cacheDirectory=".phpunit.cache"
    requireCoverageMetadata="false"
    testdox="true"
    colors="true">

    <testsuites>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
</phpunit>

To reflect the configuration in the phpunit.xml file, you must create a couple of directories. These directories will hold all your unit tests and feature tests, respectively. 

mkdir -p tests/Unit
mkdir -p tests/Feature

Writing unit test

Writing a simple PoC unit test

Start by creating a simple service class with a simple static method.

vim src/Services/TestService.php
TestService.php
<?php
namespace App\Services;

class TestService{
    public static function addNumbers(int $a, int $b): int
    {
        return $a + $b;
    }
}

To test this unit of code, we write a simple test that asserts that the method returns an integer and another test that asserts that the method's result is as expected.

vim tests/Unit/ServiceHealthTest.php
ServiceHealthTest.php
<?php
use PHPUnit\Framework\TestCase;
use App\Services\TestService;

final class ServiceHealthTest extends TestCase
{
    /**
     * @covers App\Services\TestService
     */
    public function testAddNumbers(): void
    {
        $result = TestService::addNumbers(1,2);

        $this->assertIsInt($result);
        $this->assertEquals(3, $result);
    }
}

To run this specific unit test, run this command:

php ./vendor/bin/phpunit --filter=ServiceHealthTest
PHPUnit 10.5.35 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.1.27
Configuration: /var/www/html/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.200, Memory: 4.00 MB

Service Health
 ✔ Add numbers

OK (1 test, 2 assertions)

From the output above, one test containing two assertions was executed successfully.

Writing feature tests

Writing a simple PoC feature test

Unlike unit tests, feature tests are designed to test the functionality of your application from a more practical angle. In this example, I will write a simple feature test that checks if all valid routes are working correctly by checking the HTTP status code of the responses. 

To do this, we must first install the Guzzle package, which will be used to run web requests in our tests.

composer require guzzlehttp/guzzle

Once installed, we can write out tests.

vim tests/Feature/RoutesHealthTest.php
RoutesHealthTest.php
<?php
use PHPUnit\Framework\TestCase;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;

final class RoutesHealthTest extends TestCase
{   
    private string $baseUri = 'https://testing.no.ddev.site';
    
    public function testValidRoutes(): void
    {
        $routes = [
            '/hello',
            '/goodbye',
        ];
        
        $client = new Client([
            'base_uri' =>  $this->baseUri,
            'timeout'  => 2.0,
        ]);

        foreach ($routes as $route) {
            $response = $client->request('GET', $route);
            $this->assertEquals(200, $response->getStatusCode());
        }
    }

    public function testInvalidRoute(): void
    {
        $client = new Client([
            'base_uri' => $this->baseUri,
            'timeout'  => 2.0,
        ]);

        $this->expectException(ClientException::class);
        $response = $client->request('GET', '/invalid/route/goes/here');
    }
}

The test class above runs two feature tests. The first checks all defined valid routes return HTTP 200 responses, and the second verifies that an invalid route results in an HTTP 404 response. 

In many frameworks and content management systems, getting a list of all active routes is possible. You can run similar tests on all those routes to verify that they all work correctly, and return a HTTP 200 status.

To run this specific test run this command:

php ./vendor/bin/phpunit --filter=RoutesHealthTest
PHPUnit 10.5.35 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.1.27
Configuration: /var/www/html/phpunit.xml

..                                                                  2 / 2 (100%)

Time: 00:01.049, Memory: 4.00 MB

Routes Health
 ✔ Valid routes
 ✔ Invalid route

OK (2 tests, 3 assertions)

From the output above, two tests containing three assertions were executed successfully.

Running tests

Handy options

After you have written a few tests, the tests can be pretty time-consuming. Therefore, it can be handy to limit the number of tests that you want to run by limiting what test suits, or what specific test class you want to run.

php ./vendor/bin/phpunit --testsuite=Unit
PHPUnit 10.5.35 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.1.27
Configuration: /var/www/html/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.175, Memory: 4.00 MB

Service Health
 ✔ Add numbers

OK (1 test, 2 assertions)

To run all the tests you have created, simply run this command:

php ./vendor/bin/phpunit
PHPUnit 10.5.35 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.1.27
Configuration: /var/www/html/phpunit.xml

...                                                                 3 / 3 (100%)

Time: 00:00.738, Memory: 4.00 MB

Routes Health
 ✔ Valid routes
 ✔ Invalid route

Service Health
 ✔ Add numbers

OK (3 tests, 5 assertions)

Congratulations! 🎉 From here on, you can start building unit tests and feature tests, which can help you be more confident that bug fixes and features do not introduce regression defects in your projects.