Тестирование абстрактных классов PHP с помощью анонимных классов

Источник: «Testing Abstract Classes in PHP using Anonymous Classes»
Абстрактные классы не могут быть инстанцированы напрямую, что создаёт проблему при тестировании функциональности, реализованной в самом абстрактном классе. В этой статье я поделюсь своим подходом к решению этой проблемы.

Чтобы проиллюстрировать эту технику, рассмотрим абстрактный класс 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, эта тема рассматривается подробно. Хотя я стараюсь все больше и больше сокращать наследование в своём коде, важно отметить, что наследование по-прежнему широко используется во многих кодовых базах. Несмотря на обоснованность принципа "композиция вместо наследования", я считаю целесообразным поделиться своим подходом к тестированию методов внутри абстрактных классов.

Дополнительные материалы

Предыдущая Статья

Будьте последовательны в использовании скриптов Composer в CI

Следующая Статья

Основы TypeScript: компилятор TypeScript (tsc) и tsconfig.json