Как стать автором
Обновить

Учебный пример разработки PHP-класса с использованием TDD

Время на прочтение7 мин
Количество просмотров19K
В данном посте приводится учебный пример разработки PHP-класса, который совершает запрос к Twitter API с целью выборки статусов пользователя по его никнейму. Кроме того, Twitter-класс кеширует полученные данные с использованием еще одного PHP-класса, который осуществляет простое кеширование данных в файлах.

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


Постановка задачи и требований


Итак, требуется разработать через тестирование класс на языке PHP, который способен совершать запросы к API Twitter и возвращать данные, полученные в ответ. Также необходимо предусмотреть, что Twitter-объект может использовать (а может и не использовать) кеширующий объект. Кеширующий объект должен сохранять данные в заранее заданной директории с заранее заданным временем жизни.
Так как разработка учебная, то условимся, что данные по статусам будут возвращаться сырые, в формате JSON.
Тестирование должно проводиться с использованием фреймворка PHPUnit.

Приступим к описанию требований к каждому классу, на основе которых будут составлены тесты.

Класс кеширования FileCache:
  1. Класс должен имплементировать интерфейс CacheInterface.
  2. Должна быть возможность выставить каталог, куда будут сохраняться кешированные данные.
  3. Должна быть возможность выставлять время жизни кеша.
  4. При попытке выбрать несуществующие данные должно возвращаться значение false.

Приведу некоторые пояснения. Так как возможностей кеширования может быть много, то нужно заранее объявить интерфейс для каждого такого класса, в данном случае CacheInterface. Остальные требования вполне понятны, на мой взгляд.

Класс Twitter:
  1. Объект должен вызывать метод HTTP клиента с корректным адресом URL.
  2. Объект должен кешировать свои данные, если существует такая возможность (если задан кеширующий объект).

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
В ваших комментариях я хотел бы увидеть замечания по данной статье. Проблемные моменты, с которыми я столкнулся во время обдумывания, и которые я хотел бы прочитать в комментариях:
  1. Насколько широко должен охватываться функционал отдельным тестом? Тест должен тестировать только один метод? Либо же он может тестировать множество методов или отдельный кусок одного метода?
  2. Необходимо ли досконально тестировать каждый аспект (например, корректность параметров, передаваемых методу или конструктору)? Допустим, стоило ли включать в тестирование – принадлежность класса FileCache интерфейсу CacheInterface?

Спасибо за внимание!
Теги:
Хабы:
Всего голосов 48: ↑41 и ↓7+34
Комментарии26

Публикации

Истории

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань