Unit Testing Yii2 Behavior Using Codeception

In software development, writing automated tests is often relegated to the background by more pressing issues. So in my case, I had to write code, but tests for it - no. At the same time, I had long wanted to try unit testing of my own code, and here the behavior of Yii2 ManyToMany Behavior , which was already written on Habré, turned up by the arm . At first I expanded this behavior a bit, and then decided to put together a set of tests.

The tests themselves, including the ones discussed in this article, can be viewed in the repository at the link above. All commands were executed under Windows with composer installed globally, but I think that developers using Linux can easily adapt them for themselves.

Next, we'll look at setting up Codeception with a module for Yii2 and creating tests for behavior.

Why test?


Are automatic tests worth the time spent developing them? It is difficult to answer unequivocally.

When I decided to participate in the development of Yii2 ManyToMany Behavior, the 1-N type communications functionality was partially implemented and not tested. At a minimum, I had to make sure that the existing code was working. If I hadn’t written automatic tests, I would still have to create some kind of Yii2 application, connect the behavior to its models, and then check on some test data whether it works. From this point of view, tests are beneficial, since the cost of writing the tests themselves is a drop in the bucket compared to preparing test data and a test application. In addition, the developed behavior is a fairly simple thing, for the operation of which only the standard Yii2 code is needed, which is almost guaranteed to work. This greatly facilitates the preparation of tests.
In my case, the tests automatically paid off. As it turned out, resolving the conflict when merging branches, I messed up something, and the 1-N connections stopped being saved. Thanks to the test, I quickly found the error and fixed it.

What are we testing?


The behavior that we are considering allows us to preserve its relationships with other models when saving the model. For example, consider a simple data structure consisting of books ( Book ), authors ( Author ), and book reviews ( Review ). Books and authors are related as NN, that is, a book can have many authors, and an author has many books. Books and reviews are linked as 1-N, that is, a book can have many reviews, but each review can relate to only one book.



During testing, we will save one book with all its connections. When saving the model, you need to consider several possible input options:
  1. A non-empty array of identifiers for the associated model. In this case, the old connections must be deleted and new ones created.
  2. An empty array, as a result of which old connections must be removed.
  3. The complete lack of data, corresponding, for example, to the editing form of a book in which there are no fields related to the authors. In this case, the behavior should not do anything, that is, the existing relationships should not change.

It is necessary to check the performance of behavior in all three cases.

Features Related to Yii2


Since the behavior is designed to work with Yii2, it makes no sense to test it without the rest of the framework. For testing, we will actually create the Yii2 console application, and in it we will operate with models. We read the model from the database, pass the necessary parameters to it, save it, re-read from the database, and check whether it was saved correctly.

Of course, for testing we need a database. Fortunately, it is not necessary for our task to have a separate database server. It will be enough to use the SQLite DBMS, which is supported by Yii2 and stores the database in a file. The test data itself will be stored as a dump, which is loaded before each test.

Setting up Codeception


To get started, use composer to perform a global installation of codeception:

composer global require codeception/codeception

Now we will prepare everything necessary for testing our behavior. The behavior directory already contains the composer.json file , which describes the behavior and its dependencies. Add the yii2-codeception library to it :

composer require --dev yiisoft/yii2-codeception

Then we initialize the codeception environment in the behavior directory:

codecept bootstrap --customize

The name of the actor ( actor ) can be left by default ( Tester ), and we only need one set of tests ( suite ) - unit .

The tests directory and the codeception.yml file will appear , in which we will set the parameters we need. The default settings are fine with us, with the exception of connecting to the database.

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

Now you need to configure the unit test suite in the tests / unit.suite.yml file :

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

UnitHelper module , which was enabled by default, is not needed, but we added Asserts and Db . Now let's build the environment taking into account the selected modules:

codecept build

Finally, you need to configure the Yii2 autoloader in the tests / _bootstrap.php file :
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');

Before writing tests, you need to prepare a database dump and create model classes.

Preparing a database dump


It is convenient to use a visual tool such as DB Browser for SQLite to create the database structure .

Create the book , author , review and book_has_author tables , fill them with test data. Then we dump and save it in tests / _data / dump.sql .

My dump looks like this:

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;


Application configuration


Since our behavior will be tested within the framework of the console application, we need to prepare a configuration for it. Create the file tests / unit / _config.php :

 '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' => '',
        ]
    ]
];

Model creation


We create model class files in the tests / _data directory , and give them namespace data . In order not to do this manually, in another directory I deployed the basic application template , connected it to the database and created the classes using gii .

It is important that the necessary relationships are declared in the Book model :

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']);
}

We add the behavior there too:

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

Be sure to specify the validator for the attributes that are created by the behavior:

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

Now you can write the tests themselves.

Test creation


In codeception, test cases are formatted as classes. To work with Yii2 objects, you need to create a class inherited from yii \ codeception \ TestCase . The class name and file name must end with Test .

The file tests / unit / BehaviorTest.php create a test case BehaviorTest , and in it the method testSaveManyToMany , checking whether the correct set of data is stored for communication NN:

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');
    }
    ...

We perform actions that are usually associated with maintaining the form. Certain data comes from the request ( $ post variable ). The load () method is used to write this data to model attributes. Then the model is saved using the save () method .

After our manipulations, the book should have three authors with keys 7, 8 and 9, which is checked.

Other tests are described in the same way, for example, saving an empty data set for 1-N communication:

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');
}

If you execute codecept run , the system will conduct all available tests and report on their results:

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)

conclusions


Having tried unit testing in practice, I see how useful it was in the development of various add-ons and add-ons. In other words, testing behavior in this way is convenient, but I would not try to cover the entire code of my project with unit tests.

One of the unit testing problems I came across is the need to invent the conditions under which the code is tested. When you look at what you wrote yourself, it’s hard to imagine where it might break. It seems to me that an outside view would help here.

In any case, I can confidently say that when writing code that will then be reused in other projects, unit testing solves a lot of problems and definitely pays off the time spent on its preparation.

Also popular now: