Тестирование связанного кода и Dependency Injection

от автора

Всем привет, сегодня я хочу немного порассуждать о тестировании сильно связанного кода, о Dependency Injection и способах его реализации, в том числе мы посмотрим на одно интересное PHP-расширение. Да, кстати, говорить будем о PHP.

Доводилось ли вам когда-нибудь тестировать подобный код?

class Car {     protected $engine;       public function __construct() {         $this->engine = new Engine();     }      public function start() {         if ($this->engine->hasFuel()) {             $this->engine->start();         } else {             throw new CarException(self::EXCEPTION_NO_FUEL);         }     } } 

Как протестировать метод start и, скажем, условие отсутствия топлива и выброшенного исключения? Очевидная проблема заключается в том, что поведение тестируемого метода зависит от стороннего класса Engine и его метода hasFuel, а этот класс “зашит” в код тестируемого класса.

Первая мысль, которая приходит в голову — применение Dependency Injection. Хорошо, давайте рассмотрим способы, как мы можем изменить код класса, чтобы сделать возможным его тестирование:

1. Будем передавать зависимость в конструктор. Это просто и компактно, если у вас только одна зависимость.

public function __construct(Engine $engine) {     $this->engine = $engine; } 

Теперь класс уже можно протестировать. Будем использовать phpUnit для наглядности, и создадим mock класса Engine.

class Test extends BaseTest {     public function testStartException() {         $engineMock = $this->getMock('Engine');         $engineMock->expects($this->once())             ->method('hasFuel')             ->will($this->returnValue(false));         $car = new Car($engineMock);                 $this->setExpectedException('CarException');         $car->start();     } } 

2. Инъекция одного параметра отлично работает, но что если класс зависит от многих других классов, которые должны управляться тестируемым классом? Представим авто с гибридным двигателем, который уже зависит от двух параметров:

public function start() {     // hybrid engine can work using electricity or benzin     if (!$this->battery->isEmpty() || $this->engine->hasFuel()) {         $this->engine->start();     } else {         throw new CarException(self::EXCEPTION_NO_ELECTRICITY_OR_FUEL);     } } 

Мы по-прежнему можем передавать обе зависимости в конструктор, но что если их, например, пять или больше? В таком случае напрашиваются setter-методы:

public function setEngine(Engine $engine) {     $this->engine = $engine; }  public function setBattery(Battery $battery) {     $this->battery = $battery; } 

Тогда в тесте появляется второй mock:

class Test extends BaseTest {     public function testStartException() {         $engineMock = $this->getMock('Engine');         $engineMock->expects($this->once())             ->method('hasFuel')             ->will($this->returnValue(false));         $batteryMock = $this->getMock(‘Battery’);         $batteryMock->expects($this->once())             ->method('isEmpty')             ->will($this->returnValue(true));                  $car = new Car;         $car->setEngine($engineMock);         $car->setBattery($batteryMock);         $this->setExpectedException('CarException');         $car->start();     } } 

3. Метод выше работает, но… если у вас действительно много зависимостей, то вам в конце концов надоест писать однообразный код для создания и передачи mocks. Тесты становятся очень большими, и несмотря на простоту, читать и поддерживать их уже становится неприятно. В таком случае самое время задуматься о целых менеджерах зависимостей, вы можете изобрести что-то простое типа:

Велосипед DependencyContainer

final class DependencyContainer {      const EXCEPTION_NO_CLASS_NAME = 'There is no such class name: %s';     const EXCEPTION_INVALID_CONFIG = 'Config file is invalid: %s';     const CONFIG_PATH = '/../Config/dependencyContainer.php';      /**      * @var DependencyContainer      */     private static $instance;     private $config = array();      private final function __construct($configPath) {         $this->loadConfig($configPath);     }      private final function __clone() {}      /**      * @return DependencyContainer      */     public static function getInstance() {         if (null === self::$instance) {             self::$instance = new self(self::CONFIG_PATH);         }         return self::$instance;     }      private function loadConfig($configPath) {         $config = require __DIR__ . $configPath;         if (!is_array($config)) {             throw new \InvalidArgumentException(sprintf(self::EXCEPTION_INVALID_CONFIG, $configPath));         }         $this->config = $config;     }      public function set($className, $fullPath) {         $this->config[$className] = $fullPath;     }      public function __call($name, $arguments) {         $className = substr($name, strlen('new'));         if (isset($this->config[$className])) {             return new $this->config[$className]($arguments);         }         throw new \InvalidArgumentException(sprintf(self::EXCEPTION_NO_CLASS_NAME, $className));     } } 

С очень простым конфигом типа:

<?php return array(     'Engine' => '\App\Engine',     'Battery' => '\App\Battery', ); 

А использование будет выглядеть так:

class Car {     public function __construct() {         $container = DependencyContainer::getInstance();         $this->engine = $container->newEngine();         $this->battery = $container->newBattery();     } } 

Тогда в тестах вы можете реализовать двойников реальных зависимостей, которые, например будут наследоваться от реальных классов:

namespace Tests\Mocks;  use App;  class Engine extends App\Engine {     public function hasFuel() {         return false;     } } 

А сам тест тогда превращается в простую подмену зависимостей:

class Test extends BaseTest {     public function testStartException() {         $container = DependencyContainer::getInstance();         $container->set('Engine', 'Tests\Mocks\Engine');         $container->set('Battery', 'Tests\Mocks\Battery');         $car = new Car;         $this->setExpectedException('CarException');         $car->start();     } } 

4. … Но лучше использовать нормальный DI Container, который будет инициализировать классы за вас, в котором будет красивый конфиг, и в котором можно будет подменять зависимости. Такой контейнер можно написать или взять готовый, например, возьмем Symphony DependencyInjection. Проще всего подключить компонент через Composer, и потом просто брать и использовать:

namespace Tests;  use Symfony\Component\DependencyInjection\ContainerBuilder;  class Test extends BaseTest {     public function testStartException() {         $container = new ContainerBuilder;         $container             ->register('car', '\App\Car')             ->addMethodCall('setEngine', array(new Mocks\Engine))             ->addMethodCall('setBattery', array(new Mocks\Battery));         $car = $container->get('car');         $this->setExpectedException('CarException');         $car->start();     } } 

Подробно останавливаться на этом способе я не буду, чтобы не растягивать статью, хорошую инструкцию по использованию можно найти по ссылке выше. С помощью этого компонента можно регистрировать классы как сервисы, можно конфигурировать правила их инициализации, можно создавать зависимости сервисов друг от друга, хранить конфиги в виде XML/YAML/PHP, и делать еще много прекрасных вещей. Однако с таким подходом возникает только один вопрос: вопрос применимости таких несколько монструозных конструкций в простых проектах…

5. Пожалуй, развивать мысль с классическим DI больше некуда, поэтому посмотрим на альтернативные способы. Если задуматься о смысле тестировании с помощью DI, то это всего лишь подмена классов на этапе инициализации или загрузки. Стоп-стоп, ведь у нас есть целый механизм в PHP для загрузки классов! Например spl_autoload_register.
Да, мы действительно можем подменять класс на двойник, но он должен существовать в том же namespace что и реальный. То есть унаследовать двойник от реального класса будет невозможно, и для поддержания единообразия интерфейса сущности нужно будет иметь интерфейс или супер-класс.
Ниже очень простой пример подмены с использованием стандартного Composer ClassLoader:

class Test extends BaseTest {     public function testStartException() {         $this->getComposerLoader()->addClassMap(array(             'App\Engine' => "{$this->appPath}/src/Tests/Mocks/Autoload/Engine.php",         ));         $car = new Car;         $this->setExpectedException('CarException');         $car->start();     } } 

Если вы по каким-то причинам используете свой загрузчик, то вам ничего не мешает расширить его подобным методом.
Однако этот подход имеет ряд серьезных недостатков. Например, для каждого нового тестируемого сценария нужно иметь новый файл-двойник, иногда в двойнике придется повторять логику класса, что сделает поддержку тестов адски сложной. Само по себе наличие двух одинаковых классов в одном namespace может вносить путаницу в процесс разработки.

6. Теперь мы хотим пойти еще глубже, и внедриться не просто в загрузку файлов, но хотим контролировать этот процесс на уровне интерпретатора. Давайте посмотрим на PHP-расширение php-test-helpers, к созданию которого тоже приложил руку Sebastian Bergmann. Расширение умеет заставлять интерпретатор игнорировать операторы exit/die, переименовывать функции, и вешать callback на оператор new (полное описание функциональности которого вы можете найти на github). То, что нас интересует в рамках этой статьи, выглядит примерно так:

<?php class Foo {} class Bar {}  var_dump(get_class(new Foo));  set_new_overload(function ($className) {     if ($className == 'Foo') {         $className = 'Bar';     }     return $className; }); var_dump(get_class(new Foo)); 

Выводит:

string(3) "Foo" string(3) "Bar" 

Проще говоря, в языке появляется новая функция set_new_overload, которой можно скормить callback, который может подменять имя класса на другое, даже из другого namespace. Мы видим примерно то же самое, что и в случае подмены в автозагрузке, только в более гибком виде. Тест превращается в такой вид:

class Test extends BaseTest {     private $overrideClasses = array(         'App\Engine' => 'Tests\Mocks\Engine',     );      protected function setUp() {         parent::setUp();         if (!function_exists('set_new_overload')) {             $this->fail('Extension is not available: https://github.com/php-test-helpers/php-test-helpers');         }     }      public function testStartException() {         $overrideClasses = $this->overrideClasses;         set_new_overload(function ($className) use ($overrideClasses) {             if (isset($overrideClasses[$className])) {                 $className = $overrideClasses[$className];             }             return $className;         });         $car = new Car;         $this->setExpectedException('CarException');         $car->start();     } } 

Для того, чтобы установить это расширение, вам придется

1. На Linux попробовать инструкции по установке через pear, но на моем Debian Wheezy это не завелось. Но всегда есть альтернативный способ — скомпилировать из исходников:

apt-get install php5-dev git clone git@github.com:php-test-helpers/php-test-helpers.git php-test-helpers cd php-test-helpers phpize ./configure make install 

После чего вам нужно убедиться в том, что расширение подключено как php-модуль (не zend-модуль) в вашем php.ini.
Для особо ленивых вот мой скомпилированный .so для PHP 5.4:
github.com/theravel/unit-tests-article/blob/master/scripts/extensions/test_helpers.so
(вероятно он будет работать на всех Debian/Ubuntu с PHP 5.4, но врят ли на других версиях PHP или других OS).

2. На Windows… все сложно как всегда (вам правда нужна dll под windows?). На гитхабе есть баг, согласно которому pear установка не работает. Однако вы можете попробовать скачать собранный .dll отсюда:
downloads.php.net/pierre/
Хотя, насколько я понимаю, там есть версии, скомпилированные только для PHP 5.3. Если очень нужно, то я могу попробовать скомпилить расширение под win.

7. В самом конце рассмотрим, возможно, самый простой вариант. В случае, если тестируемые классы открыты для расширения, то можно тестировать не их, а их наследников:

namespace Tests\Mocks;  use App\Cars;  class Car extends Cars\Car {     public function __construct() {         parent::__construct();         $this->engine = new Engine;     } } 

namespace Tests;  class Test extends BaseTest {     public function testStartException() {         $car = new Mocks\Car;         $this->setExpectedException('CarException');         $car->start();     } } 

Весьма распространенных метод, разве что с несколькими ограничениями: для разных сценариев теста вам придется каким-то образом модифицировать mock-класс, и необходимо следить за тем, чтобы тестирование не превращалось в тестирование класса-наследника вместо класса-родителя (да да, иногда можно скатиться и в такую крайность).

Спасибо за ваше время, не ленитесь тестировать код, и, возможно, unit-тестирование сможет сделать ваш код чуть более гибким.

Материалы:

Пощупать рабочие примеры из статьи можно на github:
github.com/theravel/unit-tests-article

ссылка на оригинал статьи http://habrahabr.ru/post/211328/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *