В данном посте приводится учебный пример разработки 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?
Спасибо за внимание!