В данном посте приводится учебный пример разработки PHP-класса, который совершает запрос к Twitter API с целью выборки статусов пользователя по его никнейму. Кроме того, Twitter-класс кеширует полученные данные с использованием еще одного PHP-класса, который осуществляет простое кеширование данных в файлах.
Целью поста является закрепление собственных знаний, полученных в результате прочтения некоторых книг, статей, а также возможность получить комментарии от опытных TDD-практиков, с указанием на грубые ошибки в процессе разработки или в тестах.
Итак, требуется разработать через тестирование класс на языке PHP, который способен совершать запросы к API Twitter и возвращать данные, полученные в ответ. Также необходимо предусмотреть, что Twitter-объект может использовать (а может и не использовать) кеширующий объект. Кеширующий объект должен сохранять данные в заранее заданной директории с заранее заданным временем жизни.
Так как разработка учебная, то условимся, что данные по статусам будут возвращаться сырые, в формате JSON.
Тестирование должно проводиться с использованием фреймворка PHPUnit.
Приступим к описанию требований к каждому классу, на основе которых будут составлены тесты.
Класс кеширования FileCache:
Приведу некоторые пояснения. Так как возможностей кеширования может быть много, то нужно заранее объявить интерфейс для каждого такого класса, в данном случае CacheInterface. Остальные требования вполне понятны, на мой взгляд.
Класс Twitter:
HTTP клиент должен быть сторонним объектом, так как он не должен быть привязан к классу Twitter, таким образом он должен просто возвратить данные, полученные по переданному URL. Так как HTTP клиент зависим от внешних факторов, и я еще не знаю, как тестировать подобные объекты, то данный класс будет разработан примитивным без использования тестирования.
Начнем разработку с класса FileCache, напишем первый тест:
Тест довольно простой, и он начинает успешно выполнятся после описания интерфейса:
… и после начального описания класса FileCache:
Для реализации дальнейших двух тестов необходимо запрограммировать методы подготовки фикстуры, а именно – создание и очищение каталога с кеш-файлами:
И тесты:
Данный тест осуществляет проверку, действительно ли кеш-файл появился в каталоге, который был задан при создании объекта FileCache.
Чтобы тест сработал успешно, я осуществил минимальные изменения класса FileCache:
Следующий тест, который реализует проверку времени жизни кеша:
Тест проверяет наличие кеш-данных до наступления их «протухания» и после. Реализация данного теста на мой взгляд неприемлема при разработке более крупного проекта, так как оператор sleep(3) задерживает выполнение теста на 3 секунды. Наиболее подходящий вариант – ручное изменение времени доступа к файлу.
Добавим в конструктор класса присвоение времени жизни кеша:
И добавим метод load:
На данном этапе уже можно провести рефакторинг кода с целью у��аления дублирования кода в классе FileCache, а именно создание имени файла в методе load и save. Для этого добавим закрытый метод _createFilename. Данный отрезок кода уже был протестирован, поэтому можно не бояться, что закрытый метод будет неоттестирован (источник: blog.byndyu.ru, точный пост не помню, но все статьи одинаково полезны и интересны).
Последний тест:
Для того, чтобы тест сработал, необходимо всего лишь добавить кусочек кода в метод load:
Итак, все тесты для класса FileCache работают, можно переходить к реализации класса Twitter.
Начинаем с написания теста для первого же требования:
Данный тест проверит, что URL, по которому будет произведена выборка данных из Twitter API передается верно в объект Http-клиента. Так как тесты должны быть независимы, то я использую мок-объект для имитации Http-клиента. Я описываю, какой метод должен быть вызван у мок-объекта, сколько раз и с какими параметрами. Подробнее об этом можно прочитать в документации к PHPUnit.
Я сразу приведу еще один тест, который тестирует второе требование к классу Twitter:
Данный тест достаточно объемный. Здесь также используются мок-объекты. Кроме мок-объекта Http-клиента используется также и мок-объект кеш-класса, несмотря на то, что данный класс уже разработан (помним про независимость тестов). Тест проверяет, будет ли произведено обращение к HTTP, если данные уже есть в кэш. Кроме того сверяется корректность возвращенных данных.
Исходный код класса Twitter, который выполняет оба теста приведен далее:
Оба класса разработаны через тестирование, они работают должным образом. Для проверки реальной работы я написал простой HTTP-клиент, который возвращает результат функции file_get_contents, а также написал простой php-скрипт, выводящий результаты работы, но это выходит за рамки статьи.
Проект также выложен в GitHub: github.com/xstupidkidzx/tddttl
В ваших комментариях я хотел бы увидеть замечания по данной статье. Проблемные моменты, с которыми я столкнулся во время обдумывания, и которые я хотел бы прочитать в комментариях:
Спасибо за внимание!
Целью поста является закрепление собственных знаний, полученных в результате прочтения некоторых книг, статей, а также возможность получить комментарии от опытных TDD-практиков, с указанием на грубые ошибки в процессе разработки или в тестах.
Постановка задачи и требований
Итак, требуется разработать через тестирование класс на языке PHP, который способен совершать запросы к API Twitter и возвращать данные, полученные в ответ. Также необходимо предусмотреть, что Twitter-объект может использовать (а может и не использовать) кеширующий объект. Кеширующий объект должен сохранять данные в заранее заданной директории с заранее заданным временем жизни.
Так как разработка учебная, то условимся, что данные по статусам будут возвращаться сырые, в формате JSON.
Тестирование должно проводиться с использованием фреймворка PHPUnit.
Приступим к описанию требований к каждому классу, на основе которых будут составлены тесты.
Класс кеширования FileCache:
- Класс должен имплементировать интерфейс CacheInterface.
- Должна быть возможность выставить каталог, куда будут сохраняться кешированные данные.
- Должна быть возможность выставлять время жизни кеша.
- При попытке выбрать несуществующие данные должно возвращаться значение false.
Приведу некоторые пояснения. Так как возможностей кеширования может быть много, то нужно заранее объявить интерфейс для каждого такого класса, в данном случае CacheInterface. Остальные требования вполне понятны, на мой взгляд.
Класс Twitter:
- Объект должен вызывать метод HTTP клиента с корректным адресом URL.
- Объект должен кешировать свои данные, если существует такая возможность (если задан кеширующий объект).
HTTP клиент должен быть сторонним объектом, так как он не должен быть привязан к классу Twitter, таким образом он должен просто возвратить данные, полученные по переданному URL. Так как HTTP клиент зависим от внешних факторов, и я еще не знаю, как тестировать подобные объекты, то данный класс будет разработан примитивным без использования тестирования.
Начинаем разрабатывать
Начнем разработку с класса FileCache, напишем первый тест:
public function testFileCacheClassShouldImplementCacheInterface() { $fileCache = new FileCache(); $this->assertInstanceOf('CacheInterface', $fileCache); }
Тест довольно простой, и он начинает успешно выполнятся после описания интерфейса:
interface CacheInterface { /** * @abstract * @param string $id * @param mixed $data * @return bool */ public function save($id, $data); /** * @abstract * @param string $id * @return mixed */ public function load($id); }
… и после начального описания класса FileCache:
class FileCache implements CacheInterface { public function save($id, $data){} public function load($id) {} }
Для реализации дальнейших двух тестов необходимо запрограммировать методы подготовки фикстуры, а именно – создание и очищение каталога с кеш-файлами:
class FileCacheTest extends PHPUnit_Framework_TestCase { protected $cacheDir = './cache_data'; protected function setUp() { //Create cache dir if (file_exists($this->cacheDir)) { $this->_removeCacheDir(); } mkdir($this->cacheDir); } public function tearDown() { //remove cache dir $this->_removeCacheDir(); } protected function _removeCacheDir() { $dir = opendir($this->cacheDir); if ($dir) { while ($file = readdir($dir)) { if ($file != '.' && $file != '..') { unlink($this->cacheDir . '/' . $file); } } } closedir($dir); rmdir($this->cacheDir); } }
И тесты:
public function testSettingCacheDir() { $beforeFilesCount = count(scandir($this->cacheDir)); $fileCache = new FileCache($this->cacheDir); $fileCache->save('data_name', 'some data'); $afterFilesCount = count(scandir($this->cacheDir)); $this->assertTrue($afterFilesCount > $beforeFilesCount); }
Данный тест осуществляет проверку, действительно ли кеш-файл появился в каталоге, который был задан при создании объекта FileCache.
Чтобы тест сработал успешно, я осуществил минимальные изменения класса FileCache:
class FileCache implements CacheInterface { /** * @var string */ protected $cacheDir; /** * @param string $cacheDir */ public function __construct($cacheDir = '.') { $this->cacheDir = $cacheDir; } /** * @param string $id * @param mixed $data * @return bool */ public function save($id, $data) { $filename = $this->cacheDir . '/' . $id . '.dat'; $f = fopen($filename, 'w'); fwrite($f, serialize($data)); fclose($f); return true; } }
Следующий тест, который реализует проверку времени жизни кеша:
public function testSettingCacheLifetime() { $lifetime = 2; $cacheData = 'data'; $cacheId = 'expires'; $fileCache = new FileCache($this->cacheDir, $lifetime); $fileCache->save($cacheId, $cacheData); $this->assertEquals($cacheData, $fileCache->load($cacheId)); sleep(3); $this->assertFalse($fileCache->load($cacheId)); }
Тест проверяет наличие кеш-данных до наступления их «протухания» и после. Реализация данного теста на мой взгляд неприемлема при разработке более крупного проекта, так как оператор sleep(3) задерживает выполнение теста на 3 секунды. Наиболее подходящий вариант – ручное изменение времени доступа к файлу.
Добавим в конструктор класса присвоение времени жизни кеша:
/** * @param string $cacheDir * @param int $lifetime */ public function __construct($cacheDir = '.', $lifetime = 3600) { $this->cacheDir = $cacheDir; $this->lifetime = $lifetime; }
И добавим метод load:
/** * @param string $id * @return mixed */ public function load($id) { $filename = $this->cacheDir . '/' . $id . '.dat'; if (time() - fileatime($filename) > $this->lifetime) { return false; } return unserialize(file_get_contents($filename)); }
На данном этапе уже можно провести рефакторинг кода с целью у��аления дублирования кода в классе FileCache, а именно создание имени файла в методе load и save. Для этого добавим закрытый метод _createFilename. Данный отрезок кода уже был протестирован, поэтому можно не бояться, что закрытый метод будет неоттестирован (источник: blog.byndyu.ru, точный пост не помню, но все статьи одинаково полезны и интересны).
protected function _createFilename($id) { return $this->cacheDir . '/' . $id . '.dat'; }
Последний тест:
public function testLoadShouldReturnFalseOnNonexistId() { $fileCache = new FileCache($this->cacheDir); $fileCache->save('id', 'some data'); $this->assertFalse($fileCache->load('non_exist')); }
Для того, чтобы тест сработал, необходимо всего лишь добавить кусочек кода в метод load:
public function load($id) { $filename = $this->_createFilename($id); if (!file_exists($filename)) { return false; } if (time() - fileatime($filename) > $this->lifetime) { return false; } return unserialize(file_get_contents($filename)); }
Итак, все тесты для класса FileCache работают, можно переходить к реализации класса Twitter.
Разрабатываем дальше
Начинаем с написания теста для первого же требования:
public function testTwitterShouldCallHttpClientWithCorrectUrl() { $httpClient = $this->getMock('HttpClientInterface'); $nickname = 'test_nick'; $twitter = new Twitter($httpClient); $httpClient ->expects($this->once()) ->method('get') ->with($this->equalTo('http://api.twitter.com/1/statuses/user_timeline.json?screen_name=' . $nickname)); $twitter->getStatuses($nickname); }
Данный тест проверит, что URL, по которому будет произведена выборка данных из Twitter API передается верно в объект Http-клиента. Так как тесты должны быть независимы, то я использую мок-объект для имитации Http-клиента. Я описываю, какой метод должен быть вызван у мок-объекта, сколько раз и с какими параметрами. Подробнее об этом можно прочитать в документации к PHPUnit.
Я сразу приведу еще один тест, который тестирует второе требование к классу Twitter:
public function testTwitterShouldLoadDataFromCacheIfIsPossible() { $cache = $this->getMock('CacheInterface'); $httpClient = $this->getMock('HttpClientInterface'); $nickname = 'test_nick'; $twitter = new Twitter($httpClient); $url = 'http://api.twitter.com/1/statuses/user_timeline.json?screen_name=' . $nickname; $urlMd5 = md5($url); $resultCached = array('status1', 'status2', 'status3'); $resultNotCached = array('save_to_cache'); $twitter->setCache($cache); $cache->expects($this->at(0))->method('load')->with($this->equalTo($urlMd5))->will($this->returnValue($resultCached)); $cache->expects($this->at(1))->method('load')->with($this->equalTo($urlMd5))->will($this->returnValue(false)); $httpClient->expects($this->once())->method('get')->with($this->equalTo($url))->will($this->returnValue($resultNotCached)); $cache->expects($this->once())->method('save')->with($this->equalTo($urlMd5), $this->equalTo($resultNotCached)); $this->assertEquals($resultCached, $twitter->getStatuses($nickname)); $this->assertEquals($resultNotCached, $twitter->getStatuses($nickname)); }
Данный тест достаточно объемный. Здесь также используются мок-объекты. Кроме мок-объекта Http-клиента используется также и мок-объект кеш-класса, несмотря на то, что данный класс уже разработан (помним про независимость тестов). Тест проверяет, будет ли произведено обращение к HTTP, если данные уже есть в кэш. Кроме того сверяется корректность возвращенных данных.
Исходный код класса Twitter, который выполняет оба теста приведен далее:
class Twitter { /** * @var HttpClientInterface */ protected $httpClient; /** * @var string */ protected $methodUrl = 'http://api.twitter.com/1/statuses/user_timeline.json'; /** * @var CacheInterface */ protected $cache = null; /** * @param HttpClientInterface $httpClient */ public function __construct(HttpClientInterface $httpClient) { $this->httpClient = $httpClient; } /** * @param CacheInterface $cache * @return Twitter */ public function setCache(CacheInterface $cache) { $this->cache = $cache; return $this; } /** * @param string $nickname * @return mixed */ public function getStatuses($nickname) { $url = $this->methodUrl . '?screen_name=' . $nickname; $cache = $this->cache; $cacheId = md5($url); $data = false; if ($cache !== null) { $data = $cache->load($cacheId); } if ($data === false) { $data = $this->httpClient->get($url); if ($cache !== null) { $cache->save($cacheId, $data); } } return $data; } }
Готово!
Оба класса разработаны через тестирование, они работают должным образом. Для проверки реальной работы я написал простой HTTP-клиент, который возвращает результат функции file_get_contents, а также написал простой php-скрипт, выводящий результаты работы, но это выходит за рамки статьи.
Проект также выложен в GitHub: github.com/xstupidkidzx/tddttl
В ваших комментариях я хотел бы увидеть замечания по данной статье. Проблемные моменты, с которыми я столкнулся во время обдумывания, и которые я хотел бы прочитать в комментариях:
- Насколько широко должен охватываться функционал отдельным тестом? Тест должен тестировать только один метод? Либо же он может тестировать множество методов или отдельный кусок одного метода?
- Необходимо ли досконально тестировать каждый аспект (например, корректность параметров, передаваемых методу или конструктору)? Допустим, стоило ли включать в тестирование – принадлежность класса FileCache интерфейсу CacheInterface?
Спасибо за внимание!
