📜 ⬆️ ⬇️

Productive unit testing of web applications using the example of yii2 and codeception

The goal of this article is to show the most productive way to write tests in the context of developing web applications.
Here and below, the term tests will be unit tests.

Web application development is accompanied by constant use in the code database. If the code of work with the database and the code of work with the result of interaction with the database is not divided, we will need a database in the vast majority of project tests. Also, if the code uses framework methods, we will need to connect the framework for tests. While there are few tests, everything is fine. When there are more tests, the problem is noticed: the speed of the tests is a bit annoying. When the execution time of all unit tests becomes more than a minute, it becomes impossible to constantly run all tests. The developer starts to run only part of the tests, trying to reduce the negative impact of a long test run time, but the problem of reducing the effectiveness of testing will only increase with time.

The source of the problem lies in the absence of a clear separation of the database work code, code that needs a framework, and code for which neither the database nor the framework is needed.

Our goal will be to figure out how to write tests and code to ensure maximum speed of test execution.

The code, first of all, should aim to conform as simple as possible, and therefore better, to the architecture. In the context of this article, the best architecture will be:
')

Thus, if we set ourselves the goal to write tests that will test the code as quickly as possible, then we are approaching the best architecture of the project.

Consider this sample code:

/** * @return bool */ public function login() { if ($this->validate()) { $user = User::findByUsername($this->username); return Yii::$app->user->login($user); } else { return false; } } 


And test:

 public function setUp() { parent::setUp(); Yii::configure(Yii::$app, [ 'components' => [ 'user' => [ 'class' => 'yii\web\User', 'identityClass' => 'common\models\User', ], ], ]); } protected function tearDown() { Yii::$app->user->logout(); parent::tearDown(); } public function testLoginCorrect() { $model = new LoginForm([ 'username' => 'User', 'password' => 'CorrectPassword', ]); expect('Model should login user', $model->login())->true(); } public function fixtures() { return [ 'user' => [ 'class' => UserFixture::className(), 'dataFile' => '@tests/codeception/common/unit/fixtures/data/models/user.php' ], ]; } 

Result:

 $ codecept run Codeception PHP Testing Framework v2.1.2 Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors. Tests\codeception\common.unit Tests (1) ------------------------------- Test login correct (unit\models\LoginFormTest::testLoginCorrect) Ok ----------------------------------------------------------------------- Time: 1.41 seconds, Memory: 9.75Mb OK (1 test, 1 assertion) 

The login method uses the framework and database. He has two responsibilities: validation and entry into the site. But in general, everything is fine. The code is small, it is understandable and easy to maintain. However, in this case, we do not have a unit test, but an integration test. We depend on the framework and database. In the database, we must put the user User with the password CorrectPassword before starting the test. If we consider this code as acceptable, the majority of our tests will become integration tests, which will affect their speed. Let's try to decouple the testing of the login method from using the database and framework:

 /** * @param \yii\web\User $webUserComponent * @param array $config */ public function __construct(\yii\web\User $webUserComponent, $config = []) { $this->setWebUserComponent($webUserComponent); parent::__construct($config); } /** * @param \yii\web\User $model */ private function setWebUserComponent($model) { $this->webUserComponent = $model; } /** * @return \yii\web\User */ protected function getWebUserComponent() { return $this->webUserComponent; } /** * @return bool */ public function login() { if ($this->validate()) { return $this->getWebUserComponent()->login($this->getUser()); } else { return false; } } /** * @return \common\models\User * @throws \yii\base\Exception */ protected function getUser() { $user = User::findByUsername($this->username); if (!$user) { throw new Exception('     '); } return $user; } 

The test has also changed:

 public function testLoginCorrect() { $webUserComponentMock = \Mockery::mock(\yii\web\User::className()) ->shouldReceive('login')->once()->andReturn(true)->getMock(); $userModelMock = \Mockery::mock(\common\models\User::className()); $loginFormPartialMock = \Mockery::mock(LoginForm::className()) ->shouldAllowMockingProtectedMethods() ->makePartial() ->shouldReceive('getWebUserComponent')->once()->andReturn($webUserComponentMock)->getMock() ->shouldReceive('validate')->once()->andReturn('true')->getMock() ->shouldReceive('getUser')->once()->andReturn($userModelMock)->getMock(); /** @var LoginForm $loginFormPartialMock */ expect('Model should login user', $loginFormPartialMock->login())->true(); } 

Result:

 $ codecept run Codeception PHP Testing Framework v2.1.2 Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors. Tests\codeception\common.unit Tests (1) ------------------------------- Test login correct (unit\models\LoginFormTest::testLoginCorrect) Ok ----------------------------------------------------------------------- Time: 895 ms, Memory: 8.25Mb OK (1 test, 1 assertion) 

We managed to get rid of the database dependency and the testing framework, and we got the advantage of 895 ms instead of 1.41 seconds . However, this is not an entirely correct comparison. We ran only one test, and most of the time was spent on Codeception initialization. What will happen if you run them 10 times in a row?

 $ codecept run Codeception PHP Testing Framework v2.1.2 Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors. Tests\codeception\common.unit Tests (10) ------------------------------------------------------------------------- Test login correct (unit\models\LoginFormTest::testLoginCorrect) Ok Test login correct2 (unit\models\LoginFormTest::testLoginCorrect2) Ok Test login correct3 (unit\models\LoginFormTest::testLoginCorrect3) Ok Test login correct4 (unit\models\LoginFormTest::testLoginCorrect4) Ok Test login correct5 (unit\models\LoginFormTest::testLoginCorrect5) Ok Test login correct6 (unit\models\LoginFormTest::testLoginCorrect6) Ok Test login correct7 (unit\models\LoginFormTest::testLoginCorrect7) Ok Test login correct8 (unit\models\LoginFormTest::testLoginCorrect8) Ok Test login correct9 (unit\models\LoginFormTest::testLoginCorrect9) Ok Test login correct10 (unit\models\LoginFormTest::testLoginCorrect10) Ok ------------------------------------------------------------------------- Time: 6.09 seconds, Memory: 15.00Mb OK (10 tests, 10 assertions) 

 $ codecept run Codeception PHP Testing Framework v2.1.2 Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors. Tests\codeception\common.unit Tests (10) ------------------------------------------------------------------------- Test login correct (unit\models\LoginFormTest::testLoginCorrect) Ok Test login correct2 (unit\models\LoginFormTest::testLoginCorrect2) Ok Test login correct3 (unit\models\LoginFormTest::testLoginCorrect3) Ok Test login correct4 (unit\models\LoginFormTest::testLoginCorrect4) Ok Test login correct5 (unit\models\LoginFormTest::testLoginCorrect5) Ok Test login correct6 (unit\models\LoginFormTest::testLoginCorrect6) Ok Test login correct7 (unit\models\LoginFormTest::testLoginCorrect7) Ok Test login correct8 (unit\models\LoginFormTest::testLoginCorrect8) Ok Test login correct9 (unit\models\LoginFormTest::testLoginCorrect9) Ok Test login correct10 (unit\models\LoginFormTest::testLoginCorrect10) Ok ------------------------------------------------------------------------- Time: 1.05 seconds, Memory: 8.50Mb OK (10 tests, 10 assertions) 

From 895 ms to 1.05 seconds against 1.41 seconds to 6.09 seconds . Using moki we achieved that the tests began to take place almost instantly. This will allow you to write a lot of tests. A lot of tests. And run them whenever we want, after a few seconds getting the result.

In the case of tests that use the framework and database, we can not run them all the time, it is a long time. And if we write a lot of tests we will have to play mortal combat, in the process of their implementation. Of course, this will affect the effectiveness of development. We cannot optimize the process of filling the database only once at the start of tests - the tests must be independent of each other.

However, we have another problem. Tests are performed quickly, but what happened to the code? Simplicity and readability is clearly not increased, especially in the test. The test does not cover all use cases, you need to write a few more tests in the same verbose style. And there is one very dangerous factor to which attention should be paid first of all:

 protected function getUser() 

In order to be able to dunk getUser and getWebUserComponent, we had to use protected scope. We went to a violation of encapsulation.



We wanted to show that fast tests lead to a good architecture, but it turned out that we broke encapsulation, reduced the readability of the code and thus made it harder. Why did we come to this result? We wrote tests after writing the code, and we believed that tests should have nothing to do with what happens inside LoginForm .

If we want to have quick tests along with excellent readability of the code, we must be guided by the principle: tests are important . Let's try to start again, using this principle.

First of all, let's throw out all the code and write a test. My task: to authorize a user, if the login or password entered by him is correct. I think for a start I need a test validating data.

 public function testSuccessValidation() { $loginForm = new LoginForm([ 'username' => 'User', 'password' => 'CorrectPassword' ]); expect('Validation should be success', $loginForm->validate())->true(); } 

There is no password verification code, validation simply returns true . Now I need a test that will determine that the password is incorrect.

 public function testFailedValidation() { $loginForm = new LoginForm([ 'username' => 'User', 'password' => 'INCORRECT-Password' ]); expect('Validation should be failed', $loginForm->validate())->false(); } 

The last test falls, time to implement a password check. To do this, I need to get the user by username from the database and compare the password.

 public function testGetUserByUsername() { $userMock = \Mockery::mock(User::className()) ->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock(); $userName = 'User'; $loginForm = new LoginForm($userMock, [ 'username' => $userName ]); expect('getUser method should return User', $loginForm->getUser() instanceof User)->true(); $userMock->shouldHaveReceived('findByUsername', [$userName]); } 

The logic already appears in the model:

 /** * @param \common\models\User $user * @param array $config */ public function __construct(User $user, $config = []) { $this->user = $user; parent::__construct($config); } /** * @return \common\models\User * @throws \yii\base\Exception */ public function getUser() { $user = $this->user->findByUsername($this->username); if (!$user) { throw new Exception('    '); } return $user; } 

Now we can pay attention to three points:


Why does getUser have public scope? Because tests are important. Maybe it's better to hide the receipt of the user in the LoginForm ? If, as in the first example, we leave only one public login method, we will test the correctness of the getUser method through this method. Only here we get the same complex test code as in the first example with mocks. Why in one case the expansion of the scope is a violation of encapsulation, but not in the other? Now we are testing this particular public method, we immediately made it public, since we need it for the test. A violation of encapsulation occurred when we gave the test knowledge, which the interface did not initially intended for it, in an attempt to speed it up. Such a problem does not arise if you write tests before implementation.

Encapsulation is important, but readability of tests and code is more important. You may get the impression that for the sake of tests, you must open all the methods so that they can be conveniently tested, but you will fail. There will always be places in the code whose enhanced visibility is not needed for testing. Tests should be considered as full client code. Also, as if the controller needs access to the method and you open it, you must also open visibility for the test. Tests are a complete part of your application, they are subject to the same quality standards and the right requirements of the necessary methods for their work.

Why do we create and pass a User object to the constructor, although we only need a static class method? In order to be able to soak it. Is it bold for LoginForm ? Not. Tests are important. Plus, we have the Depency Injection pattern at our disposal.

Why we do not check that we really got the user from the findByUsername method, but only the fact of his calling The correctness of the findByUsername operation has been tested by another integration test, which requires a database. We now need to be sure that the required parameter was passed to findByUsername , and the getUser function will return the result of executing this method.

Add a password verification test, and change other tests of compliance with the new code:

 public function testSuccessValidation() { $userMock = \Mockery::mock(User::className()) ->shouldReceive('validatePassword')->once()->andReturn(true)->getMock() ->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock(); $loginForm = new LoginForm($userMock, [ 'username' => 'User', 'password' => 'CorrectPassword' ]); expect('Validation should be success', $loginForm->validate())->true(); } public function testFailedValidation() { $userMock = \Mockery::mock(User::className()) ->shouldReceive('validatePassword')->once()->andReturn(false)->getMock() ->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock(); $loginForm = new LoginForm($userMock, [ 'username' => 'User', 'password' => 'INCORRECT-Password' ]); expect('Validation should be failed', $loginForm->validate())->false(); } public function testGetUserByUsername() { $userMock = \Mockery::mock(User::className()) ->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock(); $userName = 'User'; $loginForm = new LoginForm($userMock, [ 'username' => $userName ]); expect('getUser method should return User', $loginForm->getUser() instanceof User)->true(); $userMock->shouldHaveReceived('findByUsername', [$userName]); } public function testValidatePassword() { $userMock = \Mockery::mock(User::className()) ->shouldReceive('validatePassword')->once()->andReturn(true)->getMock() ->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock(); $password = 'RightPassword'; $loginForm = new LoginForm($userMock, [ 'password' => $password ]); $loginForm->validatePassword('password'); expect('validate password should be success', $loginForm->getErrors())->isEmpty(); $userMock->shouldHaveReceived('validatePassword', [$password]); } 

The model now looks like this:

 /** * @param \common\models\User $user * @param array $config */ public function __construct(User $user, $config = []) { $this->user = $user; parent::__construct($config); } public function rules() { return [ [['username', 'password'], 'required'], ['password', 'validatePassword'] ]; } /** * @param string $attribute */ public function validatePassword($attribute) { $user = $this->getUser(); if (!$user->validatePassword($this->$attribute)) { $this->addError($attribute, 'Incorrect password.'); } } /** * @return \common\models\User * @throws \yii\base\Exception */ public function getUser() { $user = $this->user->findByUsername($this->username); if (!$user) { throw new Exception('    '); } return $user; } 

Inside the $ user-> validatePassword method there is a call to the framework, and this code should be covered by an integration test. To determine that the method is called with the necessary parameters, we do not need its implementation, and we wet it. Now our class is completely covered with tests, it remains only to implement the authorization.

Test:

 public function testLogin() { $userComponentMock = \Mockery::mock(\yii\web\User::className()) ->shouldReceive('login')->once()->andReturn(true)->getMock(); Yii::$app->set('user', $userComponentMock); $userMock = \Mockery::mock(User::className()) ->shouldReceive('findByUsername')->once()->andReturn(\Mockery::self())->getMock(); $loginForm = new LoginForm($userMock); expect('login should be success', $loginForm->login())->true(); $userComponentMock->shouldHaveReceived('login', [$userMock]); } 


In the model:

 /** * @return bool */ public function login() { return Yii::$app->user->login($this->getUser()); } 

This test is an integration test and it is separate from the unit tests. Here it’s quite a controversial point whether the service locator in the LoginForm class is valid . On the one hand, it does not interfere with the tests, in any case, we would have to test the fact of authorization. On the other hand, our model is now less reusable. I think that this is the most obvious place after the controller, so while there is no additional logic, this code should be in the controller for good.

Result:

 $ codecept run Codeception PHP Testing Framework v2.1.2 Powered by PHPUnit 4.8.10-5-g4ecd63c by Sebastian Bergmann and contributors. Tests\codeception\common.unit Tests (5) --------------------------------------------------------------------------------- Test login (LoginFormTest::testLogin) Ok Test success validation (LoginFormTestWithoutDbTest::testSuccessValidation) Ok Test failed validation (LoginFormTestWithoutDbTest::testFailedValidation) Ok Test get user by username (LoginFormTestWithoutDbTest::testGetUserByUsername) Ok Test validate password (LoginFormTestWithoutDbTest::testValidatePassword) Ok --------------------------------------------------------------------------------- Time: 973 ms, Memory: 10.50Mb OK (5 tests, 5 assertions) 

The result is the x10 number of all 5 tests:

 Time: 1.62 seconds, Memory: 15.75Mb OK (50 tests, 50 assertions) 

Using moki and sharing the logic of working with the database, framework and regular classes, we were able to significantly reduce the test time. Of course, the tests that verify the correctness of working with the database have not gone away, they will need to be implemented. But they should be separated from unit tests and test only methods that interact with the database. Their launch will be necessary only when the logic of working with the database changes.

A question may arise, is it worth it? At the very beginning we had a small method and a small test. The code was simple and readable. Now we got a large number of tests, a bunch of mocks and a dependency in the constructor. All in order to quickly test password validation and authorization. The answer depends on the duration of the development and support of the web application.

If you are working alone on a small project, you will only need a minimum set of the most simple tests that you will write simply because you will write code faster with them. If you know that you will conduct development and support for a long time, carefully assess the situation. You are unlikely to rewrite the tests and the time won at first you will give with interest in the process of maintaining the code with slow tests.

In case more than one programmer works on a project, the answer is obvious. The slow test will take not only your time, but also the time of each team member. You should make every effort to make your tests as fast as possible.

Conclusion:


Resources recommended for review:

Robert C. Martin blog
Robert C. Martin: The Three Rules Of Tdd
Robert C. Martin: The little singleton
Robert C. Martin: The little mocker
Robert C. Martin: The Next Big Thing
Robert C. Martin: Just Ten Minutes Without A test

Agile Book: Test Driven Development

Source: https://habr.com/ru/post/268381/


All Articles