Модульное тестирование поведения Yii2 с помощью Codeception

В разработке программного обеспечения написание автоматических тестов часто отодвигается на второй план более насущными проблемами. Так и в моем случае, код писать приходилось, а тесты к нему — нет. При этом давно хотелось попробовать модульное тестирование собственного кода, а тут под руку подвернулось поведение Yii2 ManyToMany Behavior, о котором уже писали на Хабре. Это поведение я сначала немного расширил, а затем решил собрать комплект тестов.

Сами тесты, в том числе те, о которых идет речь в этой статье, можно посмотреть в репозитории по ссылке выше. Все команды выполнялись под Windows с глобально установленным composer, но я думаю, что разработчики, пользующиеся Linux, без затруднений смогут адаптировать их под себя.

Далее мы рассмотрим настройку Codeception с модулем для Yii2 и создание тестов для поведения.

Зачем тестировать?


Стоят ли автоматические тесты времени, потраченного на их разработку? Трудно ответить однозначно.

Когда я решил поучаствовать в разработке Yii2 ManyToMany Behavior, функционал по работе со связями типа 1-N был реализован частично и не проверен. Как минимум, мне надо было убедиться, что существующий код работает. Если бы я не написал автоматические тесты, все равно бы пришлось создавать какое-то приложение Yii2, подключать к его моделям поведение, а затем на каких-то тестовых данных проверять, работает ли оно. С этой точки зрения тесты выгодны, так как затраты на написание самих тестов — это капля в море по сравнению с подготовкой тестовых данных и тестового приложения. Кроме того, разрабатываемое поведение — достаточно простая штука, для работы которой нужен только стандартный код Yii2, который практически гарантированно работоспособен. Это сильно облегчает подготовку тестов.
В моем случае автоматически тесты себя оправдали. Как оказалось, разрешая конфликт при объединении веток, я что-то испортил, и связи 1-N перестали сохраняться. Благодаря тесту, я быстро нашел ошибку и исправил ее.

Что тестируем?


Поведение, которое мы рассматриваем, позволяет при сохранении модели также сохранять ее связи с другими моделями. Для примера рассмотрим простую структуру данных, состоящую из книг (Book), авторов (Author) и отзывов на книги (Review). Книги и авторы связаны как N-N, то есть у книги может быть много авторов, а у автора — много книг. Книги и отзывы связаны как 1-N, то есть у книги может быть много отзывов, но каждый отзыв может относиться только к одной книге.



Во время тестирования мы будем сохранять одну книгу со всеми ее связями. При сохранении модели нужно рассмотреть несколько возможных вариантов входных данных:
  1. Непустой массив идентификаторов связанной модели. При этом должны быть удалены старые связи и созданы новые.
  2. Пустой массив, в результате чего должны быть удалены старые связи.
  3. Полное отсутствие данных, соответствующее, например, форме редактирования книги, в которой нет полей, относящихся к авторам. В этом случае поведение не должно делать ничего, то есть существующие связи не должны измениться.

Необходимо проверить работоспособность поведения во всех трех случаях.

Особенности, связанные с Yii2


Так как поведение предназначено для совместной работы с Yii2, тестировать его без остального фреймворка нет смысла. Для тестирования мы фактически создадим консольное приложение Yii2, а в нем будем оперировать моделями. Считаем модель из базы данных, передадим ей нужные параметры, сохраним, снова считаем из базы, и проверим, правильно ли она сохранилась.

Разумеется, для тестирования нам понадобится база данных. К счастью, для нашей задачи необязательно иметь отдельный сервер баз данных. Будет достаточно воспользоваться СУБД SQLite, которая поддерживается Yii2 и хранит базу в файле. Сами же тестовые данные будут храниться в виде дампа, который загружается перед каждым тестом.

Настройка Codeception


Для начала с помощью composer выполним глобальную установку codeception:

composer global require codeception/codeception

Теперь подготовим все необходимое для тестирования нашего поведения. В директории с поведением уже есть файл composer.json, в котором дано описание поведения и его зависимостей. Добавим к нему библиотеку yii2-codeception:

composer require --dev yiisoft/yii2-codeception

Затем выполним инициализацию окружения codeception в директории поведения:

codecept bootstrap --customize

Имя актора (actor) можно оставить по умолчанию (Tester), а набор тестов (suite) нам понадобится только один — unit.

Появится директория tests и файл codeception.yml, в котором мы зададим нужные нам параметры. Параметры по умолчанию нас вполне устраивают, за исключением подключения к базе данных.

actor: Tester
paths:
    tests: tests
    log: tests/_output
    data: tests/_data
    helpers: tests/_support
settings:
    bootstrap: _bootstrap.php
    colors: false
    memory_limit: 1024M
modules:
    config:
        Db:
            dsn: 'sqlite:tests/_output/temp.db'
            user: ''
            password: ''
            dump: tests/_data/dump.sql

Теперь нужно настроить набор тестов unit в файле tests/unit.suite.yml:

class_name: UnitTester
modules:
    enabled: [Asserts, Db]

Модуль UnitHelper, который был включен по умолчанию, нам не понадобится, зато мы добавили Asserts и Db. Теперь построим окружение с учетом выбранных модулей:

codecept build

Наконец, нужно настроить автозагрузчик Yii2 в файле tests/_bootstrap.php:
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');
require_once __DIR__ . implode(DIRECTORY_SEPARATOR, ['', '..', 'vendor', 'autoload.php']);
require_once __DIR__ . implode(DIRECTORY_SEPARATOR, ['', '..', 'vendor', 'yiisoft', 'yii2', 'Yii.php']);
Yii::setAlias('@tests', __DIR__);
Yii::setAlias('@data', __DIR__ . DIRECTORY_SEPARATOR . '_data');

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

Подготовка дампа базы данных


Для создания структуры базы данных удобно использовать визуальный инструмент, такой как DB Browser for SQLite.

Создаем таблицы book, author, review и book_has_author, заполняем их тестовыми данными. Затем делаем дамп и сохраняем его в tests/_data/dump.sql.

Мой дамп выглядит следующим образом:

BEGIN TRANSACTION;
CREATE TABLE "review" (
    `id`    INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    `book_id`   INTEGER,
    `comment`   VARCHAR(150) NOT NULL,
    `rating`    INTEGER NOT NULL
);
INSERT INTO `review` VALUES (1,3,'Старая книга, не потерявшая актуальность.',5);
INSERT INTO `review` VALUES (2,3,'Одобряю!',5);
INSERT INTO `review` VALUES (3,3,'Неплохо.',4);
INSERT INTO `review` VALUES (4,5,'Хлам!',2);
CREATE TABLE "book_has_author" (
    `book_id`   INTEGER NOT NULL,
    `author_id` INTEGER NOT NULL
);
INSERT INTO `book_has_author` VALUES (1,1);
INSERT INTO `book_has_author` VALUES (1,2);
INSERT INTO `book_has_author` VALUES (2,1);
INSERT INTO `book_has_author` VALUES (2,3);
INSERT INTO `book_has_author` VALUES (3,4);
INSERT INTO `book_has_author` VALUES (4,5);
INSERT INTO `book_has_author` VALUES (4,6);
INSERT INTO `book_has_author` VALUES (5,9);
CREATE TABLE "book" (
    `id`    INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    `name`  VARCHAR(150) NOT NULL,
    `year`  INTEGER NOT NULL
);
INSERT INTO `book` VALUES (1,'Основы агрономии и ботаники.',2004);
INSERT INTO `book` VALUES (2,'Ботаника: учеб для с/вузов.',2005);
INSERT INTO `book` VALUES (3,'Краткий словарь ботанических терминов.',1964);
INSERT INTO `book` VALUES (4,'Ботаника с основами геоботаники.',1979);
INSERT INTO `book` VALUES (5,'Ботаника. Систематика высших или наземных растений.',2004);
CREATE TABLE "author" (
    `id`    INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    `name`  VARCHAR(150) NOT NULL
);
INSERT INTO `author` VALUES (1,'Андреев Н.Г.');
INSERT INTO `author` VALUES (2,'Андреев Л.Н.');
INSERT INTO `author` VALUES (3,'Родман Л.С.');
INSERT INTO `author` VALUES (4,'Викторов Д.П.');
INSERT INTO `author` VALUES (5,'Суворов В.В.');
INSERT INTO `author` VALUES (6,'Воронов И.Н.');
INSERT INTO `author` VALUES (7,'Еленевский А.Г.');
INSERT INTO `author` VALUES (8,'Соловьева М.П.');
INSERT INTO `author` VALUES (9,'Тихомиров В.Н.');
COMMIT;


Конфигурирование приложения


Так как наше поведение будет тестироваться в рамках консольного приложения, нужно подготовить для него конфигурацию. Создаем файл tests/unit/_config.php:

<?php
return [
    'id' => 'app-console',
    'class' => 'yii\console\Application',
    'basePath' => \Yii::getAlias('@tests'),
    'runtimePath' => \Yii::getAlias('@tests/_output'),
    'bootstrap' => [],
    'components' => [
        'db' => [
            'class' => '\yii\db\Connection',
            'dsn' => 'sqlite:'.\Yii::getAlias('@tests/_output/temp.db'),
            'username' => '',
            'password' => '',
        ]
    ]
];

Создание моделей


Файлы классов моделей создаем в директории tests/_data, и задаем им namespace data. Чтобы не делать это вручную, я в другой директории развернул шаблон приложения basic, подключил его к базе данных и создал классы с помощью gii.

Важно, чтобы в модели Book были объявлены нужные отношения:

public function getAuthors()
{
    return $this->hasMany(Author::className(), ['id' => 'book_id'])
                ->viaTable('book_has_author', ['author_id' => 'id']);
}

public function getReviews()
{
    return $this->hasMany(Review::className(), ['book_id' => 'id']);
}

Туда же добавляем и поведение:

public function behaviors()
{
return
    [
        [
            'class' => \voskobovich\behaviors\ManyToManyBehavior::className(),
            'relations' => [
                'author_list' => ['authors'],
                'review_list' => ['reviews'],
            ]
        ]
    ];
}

Обязательно указываем валидатор для атрибутов, которые создаются поведением:

public function rules()
{
    return [
        [['author_list', 'review_list'], 'safe'],
         ...

Теперь можно писать сами тесты.

Создание тестов


В codeception тест-кейсы оформляются как классы. Чтобы работать с объектами Yii2 нужно создать класс, унаследованный от yii\codeception\TestCase. Имя класса и имя файла должны заканчиваться на Test.

В файле tests/unit/BehaviorTest.php создадим тест-кейс BehaviorTest, а в нем метод testSaveManyToMany, проверяющий, сохраняется ли корректный набор данных для связи N-N:

class BehaviorTest extends \yii\codeception\TestCase
{
    public $appConfig = '@tests/unit/_config.php';

    public function testSaveManyToMany()
    {
        //load
        $book = Book::findOne(5);

        //simulate form input
        $post = [
            'Book' => [
                'author_list' => [7, 9, 8]
            ]
        ];

        $this->assertTrue($book->load($post), 'Load POST data');
        $this->assertTrue($book->save(), 'Save model');

        //reload
        $book = Book::findOne(5);

        //must have three authors
        $this->assertEquals(3, count($book->authors), 'Author count after save');

        //must have authors 7, 8, and 9
        $author_keys = array_keys($book->getAuthors()->indexBy('id')->all());
        $this->assertContains(7, $author_keys, 'Saved author exists');
        $this->assertContains(8, $author_keys, 'Saved author exists');
        $this->assertContains(9, $author_keys, 'Saved author exists');
    }

    ...

Мы выполняем действия, которые обычно связаны с сохранением формы. Определенные данные приходят из запроса (переменная $post). Метод load() используется для записи этих данных в атрибуты модели. Затем модель сохраняется с помощью метода save().

После наших манипуляций у книги должно появиться три автора с ключами 7, 8 и 9, что и проверяется.

Аналогично описываются и остальные тесты, например, сохранение пустого набора даных для связи 1-N:

public function testResetOneToMany()
{
    //load
    $book = Book::findOne(3);

    //simulate form input
    $post = [
        'Book' => [
            'review_list' => []
        ]
    ];

    $this->assertTrue($book->load($post), 'Load POST data');
    $this->assertTrue($book->save(), 'Save model');

    //reload
    $book = Book::findOne(3);

    //must have zero reviews
    $this->assertEquals(0, count($book->reviews), 'Review count after save');
}

Если выполнить codecept run, система проведет все доступные тесты и отчитается об их результатах:

Codeception PHP Testing Framework v2.0.11
Powered by PHPUnit 4.5.0 by Sebastian Bergmann and contributors.

Unit Tests (2) --------------------------------------------------------------------------------------
Test save many to many (BehaviorTest::testSaveManyToMany)                                       Ok
Test reset one to many (BehaviorTest::testResetOneToMany)                                       Ok
-----------------------------------------------------------------------------------------------------


Time: 390 ms, Memory: 9.00Mb

OK (2 tests, 9 assertions)

Выводы


Попробовав модульное тестирование в деле, я вижу, насколько полезным оно оказалось при разработке разных надстроек и дополнений. Иными словами, тестировать таким образом поведение — удобно, но покрывать модульными тестами весь код своего проекта я бы не стал.

Одна из проблем модульного тестирования, с которой я столкнулся — необходимость выдумывать условия, при которых проверяется код. Когда смотришь на то, что ты написал сам, сложно представить себе, где оно может сломаться. Мне кажется, что здесь бы помог взгляд со стороны.

В любом случае, я могу с уверенностью сказать, что при написании кода, который будет затем многократно использоваться в других проектах, модульное тестирование решает массу проблем и однозначно окупает время, потраченное на его подготовку.

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 12

    0
    Спасибо.
    0
    Когда смотришь на то, что ты написал сам, сложно представить себе, где оно может сломаться. Мне кажется, что здесь бы помог взгляд со стороны.

    Попробуйте сначала тесты писать и только потом код. А вообще есть такие люди как QA, которые могут вас научить тестить систему, искать пограничные случаи и т.д. Они могут вам даже тест план предоставить по которому вам функционал тестами покрыть нужно будет. А в идеальном мире они, вместе с бизнес аналитиками, вам еще и фичаспеки могут написать по которым вы будете тесты реализовывать… эх…

    И да, то что вы называете «модульным тестированием» я называю «интеграционными тестами».
      0
      Я думаю, что до TDD я пока еще не дорос психологически. Что касается терминологии, то в codeception я увидел название unit testing, посмотрел в словаре — вроде это переводится как модульное или блочное тестирование.
        0
        Я не спорю что это так называется, но модульное тестирование подразумевает тестирование одного модуля. А у вас тут имитируется реквест, прогоняется через сервер, есть взаимодействие с данными… Вы тестируете целый кусок системы в сборе. Это еще не функциональные/системные тесты, но уже и не модульные. Да и использовать codeception именно для unit тестирования как-то не удобно. Самое удобное пожалуй для этого — PhpSpec.

        Что до TDD — ну до тестов то вы доросли. TDD лишь поможет вам:
        — более грамотно проектировать систему (если тесты писать сложно — значит что-то не так с архитектурой приложения)
        — писать тесты всегда. Не обязательно добиваться 100% покрытия кода тестами — просто покрыть интерфейс класса тестами и норм, а далее — есть баг или регрессия — пишем тест и потом фиксим.

        А еще есть ATDD которое больше подходит к вашему примеру. Когда вы пишите приемочные тесты на codeception и потом добиваетесь того что бы они проходили. В этом плане, если еще и Behat использовать, можно одновременно и спецификацию по проекту поддерживать в актуальном состоянии.
        +2
        Спор до хрипоты о терминологии в тестирвоании — можно отнести к разряду профессионального заболевания )
        +1
        Одна из проблем модульного тестирования, с которой я столкнулся — необходимость выдумывать условия, при которых проверяется код. Когда смотришь на то, что ты написал сам, сложно представить себе, где оно может сломаться. Мне кажется, что здесь бы помог взгляд со стороны


        Дождитесь пока сломается. Напишите тест. Больше там не сломается )
        А писать кучу гипотетических тестов в вакуме может действительно не стоит…
        Можно, конечно, заморочиться и пройтись по всем классам эквивалентности и покрыть весь код модуля, но всему есть свой разумный предел.
          0
          Дождитесь пока сломается. Напишите тест. Больше там не сломается )

          Наверное как-то так оно и получится в итоге.
          0
          Возможно, кому-нибудь пригодится: Codeception. Debug тестов в PhpStorm
            0
            Вы прописали дамп в настройках, поэтому у Вас перед каждым тестом будет перезаливаться дамп. Это не самый быстрый вариант, особенно, когда написано много тестов. Альтернатива — оборачивать вызовы в транзакции.
              0
              Я где-то видел еще вариант с укладыванием файловой базы sqlite в память, чтобы быстрее работало, но для первого раза решил не заморачиваться. Но за идею с транзакциями — спасибо.
              0
              Ребята, если вам нужны только unit-тесты, то мой вам совет: не используйте Codeception, т.к. он сильно ограничивает phpunit и как пример: вы просто не сможете воспользоваться phpunit.xml настройками, который описаны в документации к phpunit, еще пример: Codeception использует свой хендлер ошибок, полностью перекрывая PHPUnit_Util_ErrorHandler::handleError, и кроме как хака перекрывающего хендлер обратно, это Вы никак не решите. И таких случаев очень много. В настоящий момент, когда у меня в проекте уже естьCodeception, я не понимаю, зачем Codeception мне нужен для unit-тестов, и найти список плюсов, я увы нигде найти не смог.

              Only users with full accounts can post comments. Log in, please.