Testing Abstract Classes in PHP using Anonymous Classes

2023-11-24

Abstract classes cannot be instantiated directly, posing a challenge when testing functionality implemented within the abstract class itself. In this article, I will share my approach to addressing this issue.

To illustrate the technique, let's consider an abstract class Vehicle with a move() method, also mandating a speed() method implementation in its child classes.

/app/Utils/Vehicle.php

namespace App\Utils;

use Exception;

abstract class Vehicle
{
    abstract protected function speed(): float;

    /**
     * @throws Exception
     */
    public function move(float $distance): float
    {
        $speed = $this->speed();

        if ($speed <= 0) {
            throw new Exception('Vehicle does not move. Speed 0.');
        }

        return round($distance / $speed, 2);
    }
}

Ideally, we aim for a single test case for the move() method, run with datasets with different scenarios. While testing the method in each child class's tests can additionally be done, it's not the focus here.

Now, what are our options for testing the move() method? One approach is to create a partial mock of the class, mocking the protected speed() method. However, I personally try to avoid mocking in tests wherever I can. While partial mocking might be acceptable in this straightforward case, based on my experience, the extensive use of mocks can lead to issues as code evolves. Furthermore, the speed() method we need to mock is protected. Although Mockery can handle mocking protected methods, explicit permission is required. Notably, the Mockery documentation explicitly advises against this practice. Hence, I prefer to avoid mocking whenever I can.

Another option is to use an anonymous class, extending the abstract class within the test case. By the way, I'm using PEST for the following example test case.

/tests/Unit/Utils/VehicleTest.php

namespace Tests\Unit\Utils;

use App\Utils\Vehicle;

it('calculates the duration it will take to move the distance', function ($speed, $distance, $duration) {
    $vehicle = new class ($speed) extends Vehicle {
        public function __construct(private float $speed) {}

        protected function speed(): float
        {
            return $this->speed;
        }
    };

    expect($vehicle->move($distance))->toBe($duration);
})->with([
    [60, 60, 1.0],
    [45.5, 87.3, 1.92],
    [310, 100, 0.32],
]);

If you aren't familiar with anonymous classes, they enable writing the entire class definition on the spot, as opposed to using new Something() for a class defined elsewhere. In our anonymous stub class, the value returned by the speed() method is passed as a promoted constructor argument.

Okay, now let's add another test case for scenarios where the speed is zero or below, causing the move() method to throw an exception.

it('throws an exception when the return value of the speed method is zero or below', function ($speed) {
    $vehicle = new class ($speed) extends Vehicle {
        public function __construct(private float $speed) {}

        protected function speed(): float
        {
            return $this->speed;
        }
    };

    $vehicle->move(123);
})->with([0, -0.1, -1])->throws(Exception::class);

Since creating instances of the anonymous class works the same way in both test cases, we can extract it into a helper function.

/tests/Unit/Utils/VehicleTest.php

namespace Tests\Unit\Utils;

use App\Utils\Vehicle;

function getVehicleWithSpeed(float $speed): Vehicle
{
    return new class ($speed) extends Vehicle {
        public function __construct(private float $speed) {}

        protected function speed(): float
        {
            return $this->speed;
        }
    };
}

it('calculates the duration it will take to move the distance', function ($speed, $distance, $duration) {
    expect(getVehicleWithSpeed($speed)->move($distance))->toBe($duration);
})->with([
    [60, 60, 1.0],
    [45.5, 87.3, 1.92],
    [310, 100, 0.32],
]);

it('throws an exception when the return value of the speed method is zero or below', function ($speed) {
    getVehicleWithSpeed($speed)->move(123);
})->with([0, -0.1, -1])->throws(Exception::class);

And there you have it! I hope you find this approach helpful.

At the end of the article, it's worth acknowledging the valid argument favoring composition over inheritance. If you haven't heard about this before, numerous articles, such as this one by Wendell Adriel, delve into the topic. While I try to reduce inheritance in my code more and more, it's important to note that inheritance remains widely used in many codebases. Despite the validity of the composition-over-inheritance principle, I find it worthwhile to share my approach to testing methods within abstract classes.