Тестирование абстрактных классов PHP с помощью анонимных классов
Чтобы проиллюстрировать эту технику, рассмотрим абстрактный класс Vehicle
с методом move()
, который также обязывает реализовать метод speed()
в своих дочерних классах.
// /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);
}
}
В идеале мы стремимся к созданию одного тестового кейса для метода move()
, выполняемого на наборах данных с различными сценариями. Хотя тестирование метода в тестах каждого дочернего класса может быть выполнено дополнительно, в данном случае это не главное.
Итак, какие у нас есть варианты для тестирования метода move()
? Один из подходов заключается в создании частичного имитатора (mock) класса, который будет имитировать защищённый метод speed()
. Однако я стараюсь по возможности избегать имитации в тестах. Хотя частичное имитирование может быть приемлемым в этом простом случае, исходя из моего опыта, широкое использование имитаций может привести к проблемам по мере развития кода. Кроме того, метод speed()
, который нам нужно сымитировать, является защищённым. Хотя Mockery
может работать с защищёнными методами, требуется явное разрешение. Примечательно, что документация Mockery
явно не рекомендует использовать эту практику. Поэтому я предпочитаю избегать имитации, когда это возможно.
Другой вариант — использовать анонимный класс, расширяющий абстрактный класс внутри тестового кейса. Кстати, для следующего примера тестового кейса я использую PEST.
// /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],
]);
Если вы не знакомы с анонимными классами, то они позволяют написать все определение класса на месте, в отличие от использования new Something()
для класса, определённого в другом месте. В нашем анонимном классе-заглушке значение, возвращаемое методом speed()
, передаётся в качестве свойства определяемого в конструкторе.
Хорошо, теперь давайте добавим ещё один тестовый кейс для сценариев, в которых скорость равна нулю или ниже, что приводит к тому, что метод move()
выбрасывает исключение.
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);
Поскольку создание экземпляров анонимного класса работает одинаково в обоих тестовых случаях, мы можем извлечь его в функцию хелпер.
// /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);
Вот и все! Надеюсь, вы найдёте этот подход полезным.
В конце статьи стоит признать весомый аргумент в пользу композиции вместо наследования. Если вы не слышали об этом раньше, то в многочисленных статьях, таких как "Композиция вместо Наследования в PHP", написанная Wendell Adriel, эта тема рассматривается подробно. Хотя я стараюсь все больше и больше сокращать наследование в своём коде, важно отметить, что наследование по-прежнему широко используется во многих кодовых базах. Несмотря на обоснованность принципа "композиция вместо наследования", я считаю целесообразным поделиться своим подходом к тестированию методов внутри абстрактных классов.