Как писать легко тестируемый и поддерживаемый код на PHP

Разнообразные фреймворки предоставляют инструменты для быстрой разработки приложений, но зачастую они способствуют накоплению технического долга также быстро, как позволяют создавать функциональность.

Технический долг появляется, когда сопровождение не является главной целью разработчика. Будущие изменения и отладка кода становятся затруднительными в связи с недостаточным модульным тестированием и непроработанной структурой.

В этой статье вы узнаете, как структурировать ваш код так, чтобы достичь простоты тестируемости и сопровождения – и сэкономить ваше время.

Мы рассмотрим:

  • Принцип «Не повторяйся»;
  • Внедрение зависимости;
  • Интерфейсы;
  • Контейнеры;
  • Модульные тесты с помощью фреймворка PHPUnit.

Давайте начнем с несколько выдуманного, но типичного кода. Это может быть класс в любом заданном фреймворке:

class User {
 public function getCurrentUser()
{
    $user_id = $_SESSION['user_id'];
 
    $user = App::db->select('id, username')
                    ->where('id', $user_id)
                    ->limit(1)
                    ->get();
     if ( $user->num_results() > 0 )
    {
            return $user->row();
    }
 
    return false;
} 
}

Этот код будет работать, но требует некоторых улучшений:

1. Этот код не тестируемый.

Мы полагаемся на глобальную переменную $_SESSION. Фреймворки модульного тестирования, такие как PHPUnit, работают с командной строкой, где $_SESSION и другие глобальные переменные не доступны.
Мы полагаемся на соединения с базой данных. В идеале, в модульном тестировании нужно избегать настоящих соединений с базой данных. Тестирование связано с кодом, а не с данными.

2. Данный код не такой поддерживаемый, каким бы мог быть.

Например, если мы изменим источник данных, нам нужно будет изменять код базы данных при каждом использовании App::db в нашем приложении. Кроме того непонятно, как быть в случае, когда мы хотим использовать не просто информацию о текущем пользователе?

Предпринятый модульный тест

Здесь приведена попытка создания модульного теста для функционала, описанного выше:

class UserModelTest extends PHPUnit_Framework_TestCase {
     public function testGetUser()
    {
        $user = new User();
         $currentUser = $user->getCurrentUser();
         $this->assertEquals(1, $currentUser->id);
    }
 }

Давайте рассмотрим этот тест. Во-первых, он провалится. Переменная $_SESSION, используемая в классе User, не существует в модульном тесте, так как он запускает код в командной строке.

Во-вторых, нет установки соединения с базой данных. Это значит, что для того, чтобы все заработало, нам нужно будет запустить наше приложение, чтобы получить объекты App и db. Также нам нужно активное соединение с базой данных для тестирования.

Для того чтобы этот модульный тест работал, нам нужно сделать следующее:

  1. Установить настройки конфигурации нашего приложения для запуска интерфейса командной строки (PHPUnit);
  2. Доверять подключению к базе данных. Это означает рассматривать источник данных отдельно от нашего модульного теста. Что если наша тестовая база данных не содержит ожидаемых нами данных? Что делать, если у нас медленное соединение с базой данных?
  3. Полагаясь на то, что приложение загружается, мы увеличиваем накладные расходы тестирования, резко замедляя модульные тесты. В идеале большая часть нашего кода должна быть протестирована независимо от использованного фреймворка.

Что ж, давайте перейдем к тому, как это можно улучшить.

Придерживаемся принципа «Не повторяйся»

Функция, возвращающая текущего пользователя, не является необходимой в контексте нашего простого примера. Это выдуманный пример, но придерживаясь принципа «Не повторяйся», я выбираю в качестве первой оптимизации обобщение данного метода:

class User {
     public function getUser($user_id)
    {
        $user = App::db->select('user')
                        ->where('id', $user_id)
                        ->limit(1)
                        ->get();
         if ( $user->num_results() > 0 )
        {
            return $user->row();
        }
         return false;
    }
 }

Этим методом мы можем пользоваться во всем нашем приложении. Мы можем передать текущего пользователя в вызове функции, вместо того, чтобы внедрять данный функционал в нашу модель. Код становится более модульным и поддерживаемым, когда перестает зависеть от других функциональных единиц (например, глобальной переменной сессии).

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

Внедрение зависимости

Давайте поправим ситуацию путем добавления некоторых зависимостей. Здесь показано, как может выглядеть наша модель, если мы поместим соединение с базой данных в класс:

class User {
     protected $_db;
     public function __construct($db_connection)
    {
        $this->_db = $db_connection;
    }
     public function getUser($user_id)
    {
        $user = $this->_db->select('user')
                        ->where('id', $user_id)
                        ->limit(1)
                        ->get();
         if ( $user->num_results() > 0 )
        {
            return $user->row();
        }
        return false;
    }
 }

Теперь зависимости для нашей модели User обеспечены. Наш класс больше не предполагает наличие определенного подключения к базе данных, и не полагается на какие-либо глобальные объекты.

На данный момент наш класс в целом тестируемый. Мы можем передать источник данных согласно нашему выбору (по большей части) и идентификатор пользователя и протестировать результаты вызова. Также мы можем переключать соединение к нашим отдельным базам данных (предполагая, что обе базы реализуют одинаковые методы извлечения данных). Круто!

Давайте посмотрим, как выглядит модульный тест сейчас:

<?php
 use Mockery as m;
use FideloperUser;
 class SecondUserTest extends PHPUnit_Framework_TestCase {
     public function testGetCurrentUserMock()
    {
        $db_connection = $this->_mockDb();
        $user = new User( $db_connection );
        $result = $user->getUser( 1 );
        $expected = new StdClass();
        $expected->id = 1;
        $expected->username = 'fideloper';
        $this->assertEquals( $result->id, $expected->id, 'User ID set correctly' );
        $this->assertEquals( $result->username, $expected->username, 'Username set correctly' );
    }
     protected function _mockDb()
    {
        // Фиктивный объект («заглушка») результата запроса строки из базы данных
        $returnResult = new StdClass();
        $returnResult->id = 1;
        $returnResult->username = 'fideloper';
         // Фиктивный объект результата запроса базы данных
        $result = m::mock('DbResult');
        $result->shouldReceive('num_results')->once()->andReturn( 1 );
        $result->shouldReceive('row')->once()->andReturn( $returnResult );
         // Фиктивный подключение к базе данных
        $db = m::mock('DbConnection');
         $db->shouldReceive('select')->once()->andReturn( $db );
        $db->shouldReceive('where')->once()->andReturn( $db );
        $db->shouldReceive('limit')->once()->andReturn( $db );
        $db->shouldReceive('get')->once()->andReturn( $result );
        return $db;
    }
 }

Я добавил кое-что новенькое в этот модульный тест: фиктивную реализацию. Фиктивная реализация позволяет нам имитировать (фальсифицировать) PHP объекты. В данном случае мы осуществляем фиктивную реализацию подключения к базе данных. С нашей «заглушкой» мы можем пропустить тестирование подключения к базе данных и просто проверить нашу модель.

Хотите узнать больше о фиктивной реализации?

В нашем случае мы имитируем SQL подключение. Мы указываем, что фиктивный объект имеет методы select, where, limit и get. Я возвращаю сам фиктивный объект, чтобы отразить, как объект подключения SQL возвращает сам себя ($this). Таким образом, вызовы этого метода становятся цепными. Отметим, что для метода get я возвращаю результат вызова базы данных – объект класса stdClass с данными пользователя.

Таким образом, решаются несколько проблем:

  1. Мы тестируем только нашу модель класса. Мы не проверяем подключение к базе данных;
  2. Мы можем контролировать входные и выходные данные фиктивного подключения к базе данных, и, следовательно, провести надежное тестирование, не обращая внимания на результат запроса в базе данных. Я знаю, что я получу идентификатор пользователя «1» как результат фиктивного обращения к базе данных;
  3. Нам не нужно запускать наше приложение, или иметь какую-либо конфигурацию или существующую базу данных для того, чтобы проводить тестирование.

Но мы все еще можем сделать наш код гораздо лучше. С этого места начинается самое интересное.

Интерфейсы

Для дальнейшего улучшения мы можем определить и реализовать интерфейсы. Рассмотрим следующий код:

interface UserRepositoryInterface {
    public function getUser($user_id);
}
 class MysqlUserRepository implements UserRepositoryInterface {
     protected $_db;
     public function __construct($db_conn)
    {
        $this->_db = $db_conn;
    }
     public function getUser($user_id)
    {
        $user = $this->_db->select('user')
                    ->where('id', $user_id)
                    ->limit(1)
                    ->get();
 
        if ( $user->num_results() > 0 )
        {
            return $user->row();
        }
        return false;
    }
 }
class User {
     protected $userStore;
     public function __construct(UserRepositoryInterface $user)
    {
        $this->userStore = $user;
    }
     public function getUser($user_id)
    {
        return $this->userStore->getUser($user_id);
    }
}

Здесь происходит несколько вещей.

  1. Во-первых, мы определяем интерфейс для нашего источника данных. Таким образом, определяется метод getUser();
  2. Затем мы реализуем данный интерфейс. В данном случае мы осуществляем MySQL реализацию. Мы принимаем объект подключения к базе данных, и, используя его, вытягиваем информацию о пользователе из базы данных;
  3. И наконец, мы настраиваем использование реализации класса UserRepositoryInterface в нашей модели User. Этим гарантируется, что источник данных всегда будет иметь доступным метод getUser(), независимо от того, какой именно источник данных используется для реализации UserRepositoryInterface.

Заметьте, что наш объект класса User содержит типизацию для объектов UserRepositoryInterface в своем конструкторе. Это значит, что класс, реализующий UserRepositoryInterface, должен быть передан в объект класса User. Это гарантирует то, что метод getUser, на который мы полагаемся, будет всегда доступен.

Что мы имеем в результате?

  • Наш код теперь полностью тестируемый. Для класса User мы легко имитируем источник данных (тестирование реализации источника данных является задачей отдельного модульного теста);
  • Наш код стал намного более поддерживаемым. Мы можем подключать различные источники данных, не внося изменения в код во всем нашем приложении;
  • Мы можем создать ЛЮБОЙ источник данных: ArrayUser, MongoDbUser, CouchDbUser, MemoryUser и т.д.;
  • Мы можем легко передать любой источник данных в объект нашего класса User, если это нужно. Если вы решите уйти от базы данных SQL, вы можете просто создать другую реализацию (например, MongoDbUser) и передать ее в вашу модель User.

А еще мы упростили наш модульный тест!

<?php
 use Mockery as m;
use FideloperUser;
 class ThirdUserTest extends PHPUnit_Framework_TestCase {
     public function testGetCurrentUserMock()
    {
        $userRepo = $this->_mockUserRepo();
         $user = new User( $userRepo );
         $result = $user->getUser( 1 );
         $expected = new StdClass();
        $expected->id = 1;
        $expected->username = 'fideloper';
         $this->assertEquals( $result->id, $expected->id, 'User ID set correctly' );
        $this->assertEquals( $result->username, $expected->username, 'Username set correctly' );
    }
    protected function _mockUserRepo()
    {
        //Имитируем ожидаемый результат
        $result = new StdClass();
        $result->id = 1;
        $result->username = 'fideloper';
         // Имитируем любой пользовательский репозиторий 
        $userRepo = m::mock('FideloperThirdRepositoryUserRepositoryInterface');
        $userRepo->shouldReceive('getUser')->once()->andReturn( $result );
 
        return $userRepo;
    }
}

Мы полностью избавились от фиктивного подключения к базе данных. Вместо этого, мы просто имитируем источник данных и говорим ему, что делать, когда вызывается метод getUser.

Но мы все еще можем сделать код лучше!

Контейнеры

Рассмотрим использование нашего текущего кода:

//В некотором контроллере
$user = new User( new MysqlUser( App:db->getConnection("mysql") ) );
$user->id = App::session("user->id");
 $currentUser = $user->getUser($user_id);

Нашим завершающим шагом будет объявление контейнеров. В коде выше нам нужно создавать и использовать группу объектов только для того, чтобы получить информацию о текущем пользователе. Этот код может быть разбросан по всему вашему приложению. Если вам нужно переключиться с базы данных SQL к MongoDB, вам придется редактировать каждый участок программы, где встречается приведенный выше код.

И это вряд ли соответствует принципу «Не повторяйся». Контейнеры могут исправить это.

Контейнер просто содержит в себе объект или функцию. Это похоже на реестр в вашем приложении. Мы можем использовать контейнер для автоматического создания нового объекта User со всеми необходимыми зависимостями. Ниже я использую распространенный класс контейнеров Pimple.

// Где-то в файле конфигурации 
$container = new Pimple();
$container["user"] = function() {
    return new User( new MysqlUser( App:db->getConnection('mysql') ) );
}
 // Теперь во всех наших контроллерах мы можем просто написать:
$currentUser = $container['user']->getUser( App::session('user_id') );

Я переместил создание модели User в одно место в конфигурации приложения. В результате:

  1. Наш код соответствует принципу «Не повторяйся». Объект класса User и выбранное место хранения данных определено в одном месте нашего приложения;
  2. Мы можем переключать нашу модель User от использования MySQL к любому другому источнику данных в ОДНОМ месте. Это значительно упрощает поддержку кода.

Заключительное слово

В нашем уроке мы выполнили следующее:

  1. Подчинили наш код принципу «Не повторяйся» и сделали возможным его повторное использование;
  2. Создали поддерживаемый код – мы можем, если нужно, переключать источники данных для наших объектов для всего приложения в одном месте;
  3. Сделали наш код легко тестируемым – мы можем просто имитировать объекты, не полагаясь на загрузку нашего приложения или создание тестовой базы данных;
  4. Получили знания о внедрении зависимостей и интерфейсах для того, чтобы создавать легко тестируемый и поддерживаемый код;
  5. Увидели на примере, как контейнеры могут помочь сделать наше приложение более легким в обслуживании.

Я уверен, вы заметили, что мы добавили намного больше кода для достижения простоты обслуживания и тестируемости. Сильный аргумент против такой реализации – увеличение сложности. В самом деле, для этого требуется более глубокое знание кода, как для основного разработчика, так и для остальных участников проекта.

Однако, затраты на объяснение и понимание кода с лихвой окупаются снижением технического долга.
Код стал значительно более легким в обслуживании, представилась возможность производить изменения всего в одном месте, а не в нескольких.

Возможность быстро проводить модульное тестирование снизит количество ошибок в коде со значительным отрывом – особенно в долгосрочных или разрабатываемых в сообществе (с открытым исходным кодом) проектах.

Проделывая дополнительную работу сейчас, мы сэкономим время и освободимся от головной боли в будущем.

Источники

Вы можете легко включить фиктивные объекты и PHPUnit в ваше приложение с помощью Composer. Добавьте вот это в вашу секцию “require-dev” в файле composer.json:

"require-dev": {
    "mockery/mockery": "0.8.*",
    "phpunit/phpunit": "3.7.*"
}

Затем вы можете установить ваши зависимости, основанные на Composer, со следующими требованиями:

$ php composer.phar install –dev

При использовании PHP фреймворка Laravel 4 применение контейнеров и других идей, описанных здесь, носит исключительно важный характер.

Спасибо за прочтение!

Перевод статьи «How to Write Testable and Maintainable Code in PHP» был подготовлен дружной командой проекта Сайтостроение от А до Я.