PHPUnit TDD

babahalki

Постоялец
Регистрация
6 Май 2016
Сообщения
247
Реакции
107
Привет. Я пытаюсь делать форк CMS Simpla. Пока разработка идет "на кошках", т.е. все довольно просто, я просто представляю как это должно получится, открываю Geany (практически notepad) и начинаю писать.
Но чем дальше заходит дело, тем сложнее становится. Я начал программировать на php без теоретической подготовки, поэтому как это делать правильно не знаю. Основным источником решений и сразу мануалом является php.net. Перебором разных вариантов я пришел к следующему:
1. **х...чил что-то в своем блокноте
2. Нажал f5 в браузере - посмотрел что получилось.

Чтобы видеть ход выполнения функций и отслеживать результат их выполнения я использую библиотеку, которую увидел когда-то как пример самого простого способа отладки. В итоге весь код у меня выглядит вот так:
Код:
    public function resize_($filename)
    {
        dtimer::log(__METHOD__ . " start $filename");
        return 'done';       
    }

Т.е. по всем функциям происходит вывоз dtimer::log(), который записывает относительное время записи и количество используемой памяти в момент записи.

Где-то в самом конце скрипта делается вызов.
dtimer::show();
Который выводит таблицу из этих записей dtimer::log(), которые выполнялись в хронологическом порядке. Видно кол-во памяти, время выполнения, время выполнения в % от общего времени выполнения и время выполнения нарастающим итогом, т.е. время от первой записи.

upload_2017-12-10_16-48-0.png

Надеюсь, Вы еще не устали читать.

В итоге тестирование у меня получается одновременно с разработкой. Несмотря на то, что я стараюсь не усложнять, все равно какие-то связи между разрабатываемыми функциями возникают, чем их больше тем, сложнее держать все это в голове.
Так я полез читать про юнит тестирование. Установил себе phpunit и сейчас попытался сделать первый тест применительно к своим функциям.

Код:
<?php
use PHPUnit\Framework\TestCase;
require_once('/cygdrive/d/OSPanel/domains/rapida-dev/api/Simpla.php');

class StackTest extends TestCase
{
    public function test_resize()
    {
        $s = new Simpla();
        $res = $s->image->resize_();
       
        $this->assertEquals('done', $res);
    }
}
?>

Запускаю
Код:
phpunit test.php
Ну и сразу получаю.
Код:
There was 1 error:

1) StackTest::test_resize
Undefined index: SERVER_PROTOCOL
Причина ошибки понятна, ведь у меня запуск из консоли.



Заставить это работать в консоли, подсовывая недостающие в консоли переменные?
Заставить это работать в браузере?

Как правильно?
Интересует как теоретическая часть, так и практическая.
 
Запускаю
Код:
phpunit test.php
Ну и сразу получаю.
Код:
There was 1 error:

1) StackTest::test_resize
Undefined index: SERVER_PROTOCOL
Причина ошибки понятна, ведь у меня запуск из консоли.

Как правильно?
Интересует как теоретическая часть, так и практическая.

Теория
Вот это и есть зло от глобальных переменных и доступных из любого места синглтонов :)
Если класс имеет какие-то внешние зависимости, то они должны ему передаваться при создании класса. Через синглтон или глобал, это решается очень красиво - задаём некий глобальный конфиг и класс сам берёт нужные эму данные. Вот только если данных нет или они изменились - часто один и тот же класс нужно превратить в объект с разными конфигами, наступает писец. Первый раз я на такое наткнулся в большом легаси проекте на десятки МБ кода и вот так красиво ошибка не выскакивала - была логическая ошибка, которая пользователю показывала вовсе не то, что ожидалось и на отлов бага ушло много часов.
И вот здесь открывается мощь TDD - написав сначала использование, а потом реализацию функции, гораздо проще сделать хорошую декомпозицию и более чистую архитектуру. Бонусом получаем покрытие кода тестами.
Хорошее видео про TDD -
Код:
 https://www.youtube.com/watch?v=8u6_hctdhqI
У автора еще довольно много похожего.
Хорошее, но очень долгое видео про тестирование в PHP -
Код:
https://www.youtube.com/watch?v=gRmEpUYaS20
У того же Елисеева есть курс про разработку, про ООП, про патерны в PHP - очень рекомендую.
Скрытое содержимое доступно для зарегистрированных пользователей!


Практика
Используем принцип единственной ответственности, который говорит о том что класс должен делать только одно дело и все зависимости передаём в класс явно:

PHP:
class ImgTest extends TestCase
{
    public function test_successful_done_resize()
    {
        $jpg = '../files/uploaded.jpg'; //файл должен лежать в папке тестов
        $s = new Image($jpg);
        $res = $s->resize_();

        $this->assertEquals('done', $res); // Это тестирует возврат done, значит так и обзываем тест
    }
    //С TDD тут еще появятся тест для false, тест того что оно действительно отресайзило, тест с другими расширениями и т.д. всё что сможете придумать для поломки класса

}
А для загрузки картинки мы пишем другой класс, который протестирует именно загрузку!

Можно ли для облегчения кода сделать универсально, как у тебя в примере? Вполне - yii2 во многих местах использует нарушение того же принципа единственной ответственности, вот только Yii2 написан сеньорами и архитекторами, которые уже много лет в разработке и они пошли на это сознательно, зная что выигрывают и что теряют.
А для новичков это будет боль, костыли, куча багов.

Принципы проектирования, о которых я упоминаю: SOLID, GRASP
Для просмотра ссылки Войди или Зарегистрируйся
 
Теория
...
Практика
Используем принцип единственной ответственности, который говорит о том что класс должен делать только одно дело и все зависимости передаём в класс явно:

PHP:
class ImgTest extends TestCase
{
    public function test_successful_done_resize()
    {
        $jpg = '../files/uploaded.jpg'; //файл должен лежать в папке тестов
        $s = new Image($jpg);
        $res = $s->resize_();

        $this->assertEquals('done', $res); // Это тестирует возврат done, значит так и обзываем тест
    }
    //С TDD тут еще появятся тест для false, тест того что оно действительно отресайзило, тест с другими расширениями и т.д. всё что сможете придумать для поломки класса

}
....

Спасибо, концепцию я уловил. Но я это понял еще на примерах самого PHPUnit. Зря я не прислал нормальную функцию.
Вот так выглядит свеженькая функция resize_, которую я запилил вместо старой замороченной.

Код:
    /*
    * Принимает на входе путь к директории оригинала изображения и его размеры, а отдает путь для ресайза
    */
    private function gen_dst_dirname($dirname, $w, $h)
    {
        dtimer::log(__METHOD__ . " start $dirname");
        $dirname_ = explode('/' , trim($dirname, '/') );
        //reverse array
        //expected element 1 = 'img' and element 0 is originals directory
        $dirname_ = array_reverse($dirname_);
        if($dirname_[1] !== 'img'){
            dtimer::log(__METHOD__ . " path error! '$dirname_[1]' only /img/* is allowed", 1);
            return false;
        }
        $dirname_dst_ = $dirname_;
        $dirname_dst_[0] .= '_resized/' . $w .'x' . $h;
        $dirname_dst_ = array_reverse($dirname_dst_);

        $dirname_dst = implode('/', $dirname_dst_);
        dtimer::log(__METHOD__ . " dirname_dst: $dirname_dst");
        return $dirname_dst;
    }
    /*
    * Принимает на входе путь к файлу и размеры, путь задается относительно корневой директории
    * Например, /img/originals/file.png, или img/originals/file.png
    */
    public function resize_($src, $w, $h)
    {
        dtimer::log(__METHOD__ . " start src: $src w: $w h: $h");
        //generate absolute path
        $src_ = $this->config->root_dir . $src;
        if(!file_exists($src_)){
            dtimer::log(__METHOD__ . " $src_ file not exists!", 1);
            return false;
        }   
        //create dst filepath
        $pi = pathinfo($src);
        $basename = $pi['basename'];
        $dirname = $pi['dirname'];
        $dirname_dst = $this->gen_dst_dirname($dirname, $w, $h);
        $filename = $pi['filename'];
        $ext = $pi['extension'];
        $dst = $dirname_dst . '/' . $filename . '.' . strtolower($ext);
        //absolute path
        $dst_ = $this->config->root_dir . $dst;
        dtimer::log(__METHOD__ . " dst_: $dst_");
        //create dst dir if not exists
        if(!file_exists($this->config->root_dir . $dirname_dst)){
            dtimer::log(__METHOD__ . " dirname_dst is not exists! trying to mkdir");
            //octdec() because the function mkdir() takes second parameter in decimal format
            //so chmod 755 octal needs to be converted in decimal first
            if(mkdir($this->config->root_dir . $dirname_dst, octdec((int)$this->config->resize_chmod), true) ){
                dtimer::log(__METHOD__ . " created: $dirname_dst ");
            }else{
                dtimer::log(__METHOD__ . " failed to create: $dirname_dst ", 1);
                return false;
            }
        }
   
        if (class_exists('Imagick') && $this->config->use_imagick){
            $res = $this->image_constrain_imagick($src_, $dst_, $w, $h);
        }else{
            $res = $this->image_constrain_gd($src_, $dst_, $w, $h);
        }
   
        if($res === false){
            dtimer::log(__METHOD__ . " resize error! src: $src_ dst: $dst_", 1);
            return false;
        } else {
            dtimer::log(__METHOD__ . " resize relative dst: $dst");
            return $dst;
        }
   
    }

Вот послушал этого Елисеева, говорит: "Тестируйте только важное, иначе это будет очень много тестов".
Вот никак не могу сообразить в каком порядке надо думать, чтобы сделать сначала тест для нечто, а уже потом это нечто.

Вот у меня resize_($src, $w, $h) возвращает имя созданного ресайза или false.
1. Ресайз создается только в том случае, если $src имеет вид /img/dir/file,
2. если in_array('dir', $this->allowed_dir);
3. Ресайз должен сохранятся в '/img/dir_resize/'.$w .'x'. $h/file
4. В случае успеха возвращает путь, в случае провала возвращает false

PHP:
class ImgTest extends TestCase
{
//1. Ресайз создается только в том случае, если $src имеет вид /img/dir/file,
public function test_correct_cond_1_resize()
{
$s = new Image();
print "testing correct cond 1: ";
$src = '/img/dir/file.png'; //файл действительно существует
$this->assertNotFalse( '$s->resize_($src, 100, 100) );
}
}

И так далее.

Жесткий гемор, просто ппц. Я всю жизнь считал себя перфекционистом, оказалось, что я жестоко ошибался.

P.S. Но что-то мне подсказывает, что надо так делать.
 
Последнее редактирование:
Если кто-то займется модульным тестированием функций, которые используют http заголовки, то наверняка столкнутся с проблемой, с которой столкнулся я.

Дело в том, что php не разрешает отправлять заголовки после того, как что-то будет выведено в стандартный вывод sdtout. Проще говоря, если у вас в ходе выполнения скрипта есть echo или print или вышибло ошибку, то header() работать не будет. Выпадет ошибка 'headers already sent'. На стековерфлоу люди советуют ставить перед функцией теста следующее phpDOC описание.
PHP:
/**
* @runInSeparateProcess
*/
public function nothing()
{
    $this->assertTrue(true);
}

Запуск этой функции будет в отдельном процессе, что позволит избежать ошибки.

Мне данное решение не подошло, потому что я очень активно использую stdout для вывода хода выполнения скрипта, поэтому вывод в отдельный процесс мне не подошел.
Вот мой метод:

Фокус в том, чтобы накапливать сообщения stdout в буфер. Делаем это запуском функции ob_start() в начале скрипта. Так заголовки будут отправлены раньше вывода в stdout и ошибки не будет, но все сообщения, которые выдает скрипты будут видны в выводе. Вот рабочий пример.

PHP:
<?php
use PHPUnit\Framework\TestCase;
ob_start();
require_once('../api/ControllerResize.php');


class TestControllerResize extends TestCase
{
    use \Xpmock\TestCaseTrait;

    private $class;

    protected function setUp()
    {
        dtimer::$enabled = true;
        $this->class = new ControllerResize();

    }

    protected function tearDown()
    {
        dtimer::show_console();
    }

    /**
     * @test
     */
    public function nothing()
    {
        $this->assertTrue(true);
    }

    /**
     * @test
     */
    public function read(){
        $c = $this->class;
        $content = "This is my test content";
        $file = tempnam(sys_get_temp_dir(), 'tmp');
        file_put_contents($file, $content);
        $output = $this->reflect($c)->read($file);
        unlink($file);
        $this->assertEquals($content, $output);
    }

    /**
     * @test
     */
    public function read_partial(){
        $c = $this->class;
        $content = "This is my test content";
        $_SERVER['HTTP_RANGE'] = 'range = 0-10';
        $file = tempnam(sys_get_temp_dir(), 'tmp');
        file_put_contents($file, $content);
        $output = $this->reflect($c)->read($file);
        unlink($file);
        $this->assertEquals(substr($content,0,10), $output);
    }

}
 
не забудьте сбросить потом output buffer через ob_flush(), иначе таких глюков можно навидаться..
 
Назад
Сверху