Легкий способ начать писать тестируемый и поддерживаемый код на PHP
Что мне не нравится в php — это то, что язык позволяет легко написать неподдерживаемую ересь, которая работает. Может, кто-то скажет — работает же, зачем это трогать? Затем, что иногда приложение может меняться и приходится это править. Тогда то Вы и почувствуете всю печаль ситуации — многие просто игнорируют стандарты
Так что с этим делать? Пора научиться пользоваться современными инструментами.
Есть несколько вариантов исправления ситуации — использовать фреймворки или же собрать свое приложение из сторонних компонентов по стандартам. В этой статье мы рассмотрим второй вариант, т.к. у фреймворков есть своя документация, следуя которой и нужно создавать свое приложение. Итак, далее я вкратце расскажу о некоторых принципах, которые позволят Вам собрать тестируемое, расширяемое приложение.
Используем composer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
{ "name": "puzzle-cms/puzzle", "description": "Simple CMS based on Symfony and Aura components", "authors": [ { "name": "loki", "email": "your@email.here" } ], "autoload": { "psr-4": { "Puzzle\\": "src/Puzzle/" } }, "require": { "phroute/phroute": "dev-master", "symfony/console": "v3.0.1", "symfony/http-foundation": "v3.0.1", "symfony/filesystem": "v3.0.1", "symfony/translation": "v3.0.1", "aura/di": "2.2.4", "aura/html": "2.x-dev", "aura/sql": "2.4.3", "aura/signal": "1.0.4", "aura/sqlquery": "2.6.0", "phpfastcache/phpfastcache": "3.0.26", "phpmailer/phpmailer": "v5.2.14", "hybridauth/hybridauth": "v2.6.0", "matthiasmullie/minify": "1.3.32", "symfony/yaml": "v3.0.2", "robmorgan/phinx": "v0.5.1" } } |
По пунктам названия и описания думаю все понятно. В секции autoload мы говорим, что будем использовать пространство имен Puzzle для файлов, находящихся в папке src/Puzzle. Это позволит нам автоматически загружать нужные классы, если их расположение и название соответствует стандарту
Используем сторонние компоненты
Если Ваше приложение не очень масштабное и cms или фреймворки для него избыточны, Вы вполне можете использовать или компоненты фреймворков, или просто отдельные библиотеки. Зачем это нужно:
- Как правило, такие компоненты хорошо делают то, зачем они созданы и протестированы
- не нужно изобретать велосипед и заново решать задачи, которые до Вас точно решили уже тысячи людей
- поддержка сообщества и куча примеров использования
Насколько я знаю, есть несколько популярных фреймворков, компоненты которых Вы можете использовать отдельно
В приведенном выше composer.json используются компоненты Symfony и Aura, о чем и будет частично третий пункт
Инверсия управления
Для начала ознакомьтесь с материалом по
Ранее в уроках мы использовали такую конструкцию:
1 2 3 4 5 6 |
public function __construct(){ //initialise the views object $this->view = new \core\View(); //initialise the language object $this->language = new \core\Language(); } |
или такую
1 2 3 4 5 6 7 8 9 |
class Page extends \core\Controller { public $model; public function __construct() { parent::__construct(); $this->model = new \models\page(); } } |
И что в этом плохого, скажете вы? В принципе ничего особенного, только вот если мы будем создавать тесты для класса Page, внезапно окажется, что зависимость от модели определена жестко, т.к. при тестировании нам придется иметь дело с реальными данными, которых может и не быть, т.к. если мы захотим протестировать, как ведет себя контроллер для страниц, нам нужно будет подключиться к базе, сделать запрос, впихнуть все это во View, и иметь отдельно тестовые данные, которые мы не сможем удалить. В общем тестирование данного класса будет крайне проблемным.
Как решить эту проблему? Для этого и существует такое понятие, как
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
<?php namespace Puzzle\Controllers; use Puzzle\Core\ControllerBase; class PagesController extends ControllerBase { protected $model; /** * Render homepage * @return Symfony\Component\HttpFoundation\Response */ public function index() { $content = $this->view->renderTemplate("index", $this->data); $this->response->setContent($content); return $this->response; } /** * Get page data from model * @param string $slug page slug * @return Symfony\Component\HttpFoundation\Response */ public function getPage($slug) { if (!is_string($slug)) { throw new \Exception("Page slug is not string"); } $this->data['page'] = $this->model->getPage($slug); if (!is_array($this->data['page'])) { return $this->send404(); } else { $content = $this->view->renderTemplate("page", $this->data); $this->response->setContent($content); return $this->response; } } public function setModel(\Puzzle\Models\PagesModel $model) { $this->model = $model; } } |
И код базового контроллера, от которого мы наследуем контроллер для страниц
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
<?php namespace Puzzle\Core; abstract class ControllerBase { protected $data; /** * Response handler * @var Symfony\Component\HttpFoundation\Response */ protected $response; protected $model; /** * Templating Engine * @var Puzzle\Core\View */ protected $view; protected $signal; public function setResponse($response) { $this->response = $response; } /** * Set not found status * @return Symfony\Component\HttpFoundation\Response */ public function send404() { $this->response->setStatusCode(404); $content = $this->view->renderTemplate("404", $this->data); $this->response->setContent($content); return $this->response; } public function setModel($model) { $this->model = $model; } public function setView($view) { $this->view = $view; } } |
Что это изменило? Теперь наш контроллер для страниц сам по себе, и он не знает о том, с какой моделью ему нужно будет работать. Но все равно мы не сможет ему подсунуть какую-нибудь дичь, т.к. мы используем
Использование контейнера зависимостей
В предыдущем пункте мы избавили контроллер от необходимости знать, с какой моделью ему работать. Но как нам сказать ему о том, что ему нужно работать именно с той моделью? Для этого мы в примере будем использовать компонент
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
<?php namespace Puzzle; use Aura\Di\Config; use Aura\Di\Container; class Common extends Config { public function define(Container $di) { $di->setters['Puzzle\Core\ControllerBase']['setResponse'] = $di->lazyNew("Symfony\Component\HttpFoundation\Response"); $di->setters['Puzzle\Core\ControllerBase']['setView'] = $di->lazyGet("view"); $di->setters['Puzzle\Controllers\PagesController']['setModel'] = $di->lazyNew("Puzzle\Models\PagesModel"); } public function modify(Container $di) { //определяем роутинг $router = $di->get("router"); $router->get(['/', 'index'], ['Puzzle\Controllers\PagesController', 'index']); $router->get(['/404.html', '404'], ['Puzzle\Controllers\PagesController', 'send404']); $router->get(['/{slug}.html', 'getPage'], ['Puzzle\Controllers\PagesController', 'getPage']); } protected function setView(Container $di, $templating, $basePath) { //здесь мы зададим сервис шаблонизатора } } |
Это просто пример, в котором мы говорим, что задаем зависимость для класса Puzzle\Controllers\PagesController через сеттер setModel, а в родительский контроллер мы внедрили общий сервис Response и View. Зачем все это? Таким образом мы сможем быстро и безболезненно сменить к примеру движок шаблонизации с нашего самописного на Twig, а те зависимости, которые мы внедрили в родительский объект, останутся и в дочернем (Response и View). Таким образом в теории мы реализовали принцип инверсии управления и наш контроллер для страниц стал независимой единицей и ему по бую тонкости реализации механизма работы с http и шаблонизацию, он просто делаем свой маленький кусок работы.
Создаем юнит-тесты для нашего контроллера
Я использую netbeans в качестве ide для разработки, так что через него можно создать файл bootstrap для тестирования с таким содержимым
1 2 3 |
<?php require_once __DIR__ . "/../vendor/autoload.php"; |
Это нужно чтоб наши тесты смогли так же подгружать нужные классы. Для тестирования мы будем использовать
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
<?php namespace Puzzle\Controllers; use Symfony\Component\HttpFoundation\Response; /** * Generated by PHPUnit_SkeletonGenerator on 2016-02-10 at 19:59:13. */ class PagesControllerTest extends \PHPUnit_Framework_TestCase { /** * @var PagesController */ protected $object; /** * Sets up the fixture, for example, opens a network connection. * This method is called before a test is executed. * */ protected function setUp() { $this->object = new PagesController; $this->object->setResponse(new Response()); $model = $this->getModel(); $this->object->setModel($model); $view = $this->getView(); $this->object->setView($view); } /** * Tears down the fixture, for example, closes a network connection. * This method is called after a test is executed. */ protected function tearDown() { } /** * @covers Puzzle\Controllers\PagesController::index * @todo Implement testIndex(). * @group prod */ public function testIndex() { $response = $this->object->index(); $this->assertTrue($response instanceof Response); $this->assertEquals(200, $response->getStatusCode()); } /** * @covers Puzzle\Controllers\PagesController::getPage * @todo Implement testGetPage(). * @dataProvider responseCode * @group prod */ public function testGetPage($slug, $code) { $response = $this->object->getPage($slug); $this->assertTrue($response instanceof Response); $this->assertEquals($code, $response->getStatusCode()); } public function responseCode() { return [ ["test1", 404], ["test", 200], ["test2", 200] ]; } /** * @covers Puzzle\Controllers\PagesController::getPage * @todo Implement testGetPage(). * @group prod */ public function testGetPage2() { try { $response = $this->object->getPage(["key" => "value"]); } catch (\Exception $e) { $this->assertEquals("Page slug is not string", $e->getMessage()); } } /** * */ public function getModel() { $model = $this->getMockBuilder('Puzzle\Models\PagesModel') ->setMethods(['getPage']) ->setConstructorArgs(array()) ->setMockClassName('') // отключив вызов конструктора, можно получить Mock объект "одиночки" ->disableOriginalConstructor() ->disableOriginalClone() ->disableAutoload() ->getMock(); $map = array( array("test1", null), array("test", ["title" => "test"]), array("test2", ["title" => "test2"]), ); $model->expects($this->any()) ->method('getPage') ->will($this->returnValueMap($map)); return $model; } public function getView() { $mock = $this->getMockBuilder('Puzzle\Core\View') ->setMethods(['renderTemplate']) ->setConstructorArgs(array()) ->setMockClassName('') // отключив вызов конструктора, можно получить Mock объект "одиночки" ->disableOriginalConstructor() ->disableOriginalClone() ->disableAutoload() ->getMock(); return $mock; } } |
Вот такой достаточно объемный юнит-тест для такого маленького класса. Многие возмутятся — ну и зачем мне писать юнит-тесты, если они по объему больше основного кода? Это не совсем так, больше по объему здесь получилось, потому что мы используем моки и стабы, подробнее о них ниже, а сейчас давайте разберем что это и зачем мы это написали.
Сначала мы создаем тестируемый объект в методе setUp. Т.к. мы использовали внедрение зависимости, теперь мы можем подменить зависимости, чтоб проверить работу контроллера и не дергать реальную базу данных. В строке
1 |
$this->object->setResponse(new Response()); |
Мы вредряем реальный объект Symfony\Component\HttpFoundation\Response, потому что его использование не требует наличия каких-либо особенных тестовых данных и не замедлит выполнение теста.
Для свойства view нашего контроллера мы используем заглушку, которую мы получаем в методе getView
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public function getView() { $mock = $this->getMockBuilder('Puzzle\Core\View') ->setMethods(['renderTemplate']) ->setConstructorArgs(array()) ->setMockClassName('') // отключив вызов конструктора, можно получить Mock объект "одиночки" ->disableOriginalConstructor() ->disableOriginalClone() ->disableAutoload() ->getMock(); return $mock; } |
Почему здесь можно использовать заглушку? Потому что для тестирования нашего контроллера нам не нужно знать как именно там создался шаблон и т.п.. Все что нам нужно — имитировать его создание, т.к. мы следуем принципу
А для свойства model мы будем использовать mock-объект, потому что хоть контроллер и не знает каким образом модель получает данные, они ему нужны. Поэтому мы имитируем получение данных из базы, чтоб контроллеру было с чем работать
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
public function getModel() { $model = $this->getMockBuilder('Puzzle\Models\PagesModel') ->setMethods(['getPage']) ->setConstructorArgs(array()) ->setMockClassName('') // отключив вызов конструктора, можно получить Mock объект "одиночки" ->disableOriginalConstructor() ->disableOriginalClone() ->disableAutoload() ->getMock(); $map = array( array("test1", null), array("test", ["title" => "test"]), array("test2", ["title" => "test2"]), ); $model->expects($this->any()) ->method('getPage') ->will($this->returnValueMap($map)); return $model; } |
Обратите внимание на
1 2 3 4 5 6 7 8 9 |
$map = array( array("test1", null), array("test", ["title" => "test"]), array("test2", ["title" => "test2"]), ); $model->expects($this->any()) ->method('getPage') ->will($this->returnValueMap($map)); |
собственно здесь мы и имитируем данные, которые по идее должна возвращать модель, но мы не дергаем настоящую базу данных, а предоставляем свои, которые соответствуют тем, что собственно должна выдавать модель.
В методе testGetPage2 мы тестируем, что наш контроллер выбросит исключение, когда мы подсунем ему не то, что нужно
И самое важное
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * @covers Puzzle\Controllers\PagesController::getPage * @todo Implement testGetPage(). * @dataProvider responseCode * @group prod */ public function testGetPage($slug, $code) { $response = $this->object->getPage($slug); $this->assertTrue($response instanceof Response); $this->assertEquals($code, $response->getStatusCode()); } |
аннотация @dataProvider говорит нам, откуда брать данные, чтоб протестировать выполнение контроллера с нужными данными
1 2 3 4 5 6 7 8 |
public function responseCode() { return [ ["test1", 404], ["test", 200], ["test2", 200] ]; } |
первый аргумент — это slug страницы, которую мы получаем. В первом случае наш контроллер должен выдать код ответа 404, т.к. мы не определили его в модели, а в остальных случаях — 200, т.к. мы можем получить нужные данные. И заодно мы проверяем, что нам вернули объект Symfony\Component\HttpFoundation\Response, т.е. запрос отработался как нужно.
Целиком здесь тему юнит-тестирования не раскрыть, т.к. есть моменты с приватными методами и свойствами, об этом поговорим отдельно и позже.
Итоги
Итак, путем небольших манипуляций с кодом мы сделали его более понятным, поддерживаемым и тестируемым. И если вы хотите, чтоб любой человек, который сунется в Ваш код не превратился в эмо, не заплакал и не захотел умереть, следуйте простым правилам:
- Следуйте стандартам psr-1,psr-2,psr-4 — так любой человек сможет прочитать без труда, что Вы написали
- Используйте инверсию контроля — это сделаем Ваш код гибким и тестируемым, и позволит использовать компоненты во многих проектах, не привязываясь жестко к определенному шаблонизатору, СУБД и т.п.
- Пишите юнит-тесты — когда разберетесь, то это окажется очень просто и сэкономит кучу времени на отладке, сопровождении и добавлении нового функционала.
Спасибо за внимание, если есть вопросы — пишите в комментарии
Добавить комментарий
Для отправки комментария вам необходимо авторизоваться.