Testing the validation of a model and any further data manipulation, until it reaches the database and comes back, is the basic step in Yii to ensure that the model has clear and well-defined validation rules implemented. This is effectively useful when it comes down to preventing clients from being able to pass additional or wrong data, when interacting with the system.
If you care about security, this is something you might need to investigate a bit further, if you haven't done it already.
So, let's get back to /tests/codeception/unit/ models/UserTest.php
: The file should already be there, and it's more or less what you would get by running the following command:
$ ../vendor/bin/codecept generate:phpunit unit models/UserTest Test was created in /var/www/vhosts//htdocs/tests/unit/UserTest.php
Right now, if you were to run this command, you would end up with a test that you would need to slightly change, so that we could use the Yii infrastructure. In particular, you would need to change the class your test was extending from with the following:
// tests/codeception/unit/UserTest.php namespace testscodeceptionunitmodels; use yiicodeceptionTestCase; class UserTest extends TestCase { }
In other words, we need to use the provided yiicodeceptionTestCase
class, instead of the PHPUnit default PHPUnit_Framework_TestCase
class.
So, let's first sketch down a few tests within our testscodeceptionunitmodelsUserTest
class:
// tests/codeception/unit/models/UserTest.php public function testValidateReturnsFalseIfParametersAreNotSet() { $user = new User; $this->assertFalse($user->validate(), "New User should not validate"); } public function testValidateReturnsTrueIfParametersAreSet() { $configurationParams = [ 'username' => 'a valid username', 'password' => 'a valid password', 'authkey' => 'a valid authkey' ]; $user = new User($configurationParams); $this->assertTrue($user->validate(), "User with set parameters should validate"); }
As you can see, knowing what to test requires insight on how Yii works. So, it might be completely fine to actually get the first test sketches completely wrong, if you don't know how things are intended to work.
In the preceding code snippet, we've defined two tests; the first is what we've called the negative and the second is the positive one.
Before we continue with the rest of our tests, let's review what we've got up until here: The test file is a class with a name in the <Component>Test
format, which collects all tests relating to the component we want to test; each method of the class is a test of a specific feature, either positive or negative.
Each method/test in the class should have at least one assertion, and PHPUnit provides a long list of assertion statements that you can trigger to assert that an actual value matches an expected value, together with methods to expect for a specific exception.
These methods are provided by the parent class TestCase
. You can get the full list at https://phpunit.de/manual/current/en/appendixes.assertions.html.
Some basic assertions are as follows:
assertTrue(actualValue)
and its opposite assertFalse(...)
assertEquals(expectedValue, actualValue)
and its opposite assertNotEquals(...)
assertNull(actualValue)
The result of your test is based on the output of these methods. You should also try to avoid wrapping some assertions within one or more conditions. Think carefully about what you're trying to achieve and what you're actually testing.
As for exceptions, you need to use some documentation annotation:
PHPUnit uses documentation annotations extensively to cover what's not normally doable with in-test assertions.
On top of what we will see, there's plenty of other functionalities, such as testing dependencies with @depends
, @before
, and @after
or grouping with @group
.
For a full list of annotations you can use, head over to https://phpunit.de/manual/current/en/appendixes.annotations.html.
Consider the following example:
/**
* @expectedException yiiaseInvalidParamException
*/
public function
testValidatePasswordThrowsInvalidParamExceptionIfPasswordIsIncorrect() {
$user = new User;
$user->password = 'some password';
$user->validatePassword('some other password'),
}
On top of @expectedException
, you can also use @expectedExceptionCode
and @expectedExceptionMessage
, in case you need to ensure that the content of the exception is what you are expecting it to be.
Another way to do this is to use the setExpectedException()
method, which might provide a higher level of flexibility when you have more complex exception cases to deal with.
Assertion testing in PHPUnit is quite straightforward once you've got a grip on how your class, model, and method are going to be used.
On top of this, PHPUnit provides some clever functionality to help us speed up testing and solve some intricacies. Data providers, fixtures, stubs, and mocks will be covered later on and in Chapter 5, Summoning the Test Doubles.
Now that we know everything we need to in order to start, we would normally decide to implement the rules to make the testValidateReturnsTrueIfParametersAreSet()
and testValidateReturnsTrueIfParametersAreNotSet()
tests pass, although at this occasion, it seems much easier to just continue sketching the remaining methods that we would need to implement later on, such as getId()
, getAuthKey()
, validateAuthKey()
, findIdentity()
, and findIdentityByAccessToken()
, plus two more methods that have been implemented and used already, namely validatePassword()
and findByUsername()
, both used by the LoginForm
model.
We can immediately decide to get rid of the simplest methods to cover. We're not going to make any use of the access token, and normally, if we weren't forced to implement the method by the interface, we could have just avoided this bit. In this case, instead, we need to get it sorted and the best way to document this missing functionality is to raise NotSupportedException
from the method and expect such an exception:
/** * @expectedException yiiaseNotSupportedException */ public function testFindIdentityByAccessTokenReturnsTheExpectedObject() { User::findIdentityByAccessToken('anyAccessToken'), }
Following this method, we test getId()
:
public function testGetIdReturnsTheExpectedId() { $user = new User(); $user->id = 2; $this->assertEquals($expectedId, $user->getId()); }
We can use the exact same logic to test $user->getAuthkey()
.
While for findIdentity()
, we can do the following:
public function testFindIdentityReturnsTheExpectedObject() { $expectedAttrs = [ 'username' => 'someone', 'password' => 'else', 'authkey' => 'random string' ]; $user = new User($expectedAttrs); $this->assertTrue($user->save()); $expectedAttrs['id'] = $user->id; $user = User::findIdentity($expectedAttrs['id']); $this->assertNotNull($user); $this->assertInstanceOf('yiiwebIdentityInterface', $user); $this->assertEquals($expectedAttrs['username'], $user->username); $this->assertEquals($expectedAttrs['password'], $user->password); $this->assertEquals($expectedAttrs['authkey'], $user->authkey); }
With findIdentity()
, we want to make sure the object returned is the one we were expecting, so our assertions ensure that:
IdentityInterface
is what most of the methods interacting with the user at authentication time will expect it to be)The negative test for findIdentity()
is quite straightforward:
public function testFindIdentityReturnsNullIfUserIsNotFound() { $this->assertNull(User::findIdentity(-1)); }
Implementing a test like this might raise some eyebrows, as we've hardcoded a value, -1
, which might not be representative of any actual real-world case.
The best way would be to use a data provider, which can feed our test with a list of values that should make the test pass. This is quite convenient as we can tailor edge cases when it comes down to doing some regression testing on the existing features:
/**
* @dataProvider nonExistingIdsDataProvider
*/
public function testFindIdentityReturnsNullIfUserIsNotFound(
$invalidId
) {
$this->assertNull(User::findIdentity($invalidId));
}
public function nonExistingIdsDataProvider() {
return [[-1], [null], [30]];
}
In a data provider, each second-level array is a call to the function requesting it and the content of these arrays is the ordered list of the actual parameters of the method.
So, in our preceding case, the test will receive -1
, null
, and 30
in consecutive invocations.
If we were to use a data provider for our initial test testFindIdentityReturnsTheExpectedObject()
, we could test whether the username contains UTF-8 or invalid characters, for instance.
So, using data providers is a good thing! It gives us the ability to use a single test to check more complex situations that require a certain level of flexibility.
But here comes the problem: The database that is used during all tests (with $user->save()
) will continue to grow, as there is no instruction to tell it to do otherwise.
As a result, we can add the following to the setUp()
function:
// tests/codeception/unit/models/UserTest.php
protected function setUp()
{
parent::setUp();
// cleanup the User db
User::deleteAll();
}
Remember to clean up after yourself: You might be impacting someone else's test. For now, with this call to deleteAll()
in place, we are fine.
The setUp()
function is called at the beginning before every single test contained in the class. PHPUnit provides several layers of methods for setting things up before one or more tests, and unsetting them after. The sequence of calls can be summed up with the following:
testscodeceptionunitmodelsUserTest::setUpBeforeClass(); testscodeceptionunitmodelsUserTest::_before(); testscodeceptionunitmodelsUserTest::setUp(); testscodeceptionunitmodelsUserTest::testSomething(); testscodeceptionunitmodelsUserTest::tearDown(); testscodeceptionunitmodelsUserTest::_after(); testscodeceptionunitmodelsUserTest::tearDownAfterClass();
Here, setUpBeforeClass()
is the most external call possible that is run before the class is instantiated. Please also note that _before
and _after
are Codeception TestCase
methods, while the rest are standard PHPUnit methods.
Since we are here, we could also add a test-wide User
class that will be instantiated before each test; it can be used by any of our tests. For this to happen, we need to add a private variable and add the related statement, where needed:
// tests/codeception/unit/models/UserTest /** @var User */ private $_user = null; protected function setUp() { parent::setUp(); // setup global User $this->_user = new User; // cleanup the User db User::deleteAll(); }
Now, we just need to amend the relevant tests to use $this->_user
when needed.
As we've seen, the data provider solution helps you run the same test with a different dataset each time, which ends up being extremely useful. Another and possibly complimentary solution is to use fixtures that let you preload some well-defined data and keep tests even more simple. This would mean being able to test methods such as User::findIdentity()
without having to rely on $user->save()
, which is not a part of the test itself.
Fixtures are used to set the database at a fixed/known state so that your tests can run in a controlled environment. In doing this, we will also eliminate the need to delete all users in the setUp
function or rely on static values that might be influenced by other previously run tests.
The fixture is just a class that is dynamically loaded in the setUp()
method, and you're left with only the task of creating the fixture class and the actual content for the database.
Let's start by creating the fixture class:
// tests/codeception/unit/fixtures/UserFixture.php namespace appcodeception estsunitfixtures; use yii estActiveFixture; class UserFixture extends ActiveFixture { public $modelClass = 'appmodelsUser'; }
In this case, we're extending from ActiveFixture
as it will provide some additional functionality that might be useful, so the only thing we need to do is to define the model it will mimic. The alternative, as for login forms or other custom-made models, is to extend from yii estFixture
, where you have to define the table name by using the public property $tableName
. With ActiveFixture
, by just defining $className
, the fixture will figure out the table name by itself.
The next step is to define the actual fixture that will define the content we want to fill into our database. By default, Yii will try to look for a file named <table_name>.php
within the fixtures/data/
folder. The fixture is just a return statement of an array, such as the following:
// tests/codeception/unit/fixtures/data/user.php return [ 'admin' => [ 'id' => 1, 'username' => 'admin', 'password' => Yii::$app->getSecurity()->generatePasswordHash('admin'), 'authkey' => 'valid authkey' ] ];
Each entry of the fixture can be key-indexed to quickly reference it in our tests. You don't also normally need to specify the primary key as they will be automatically created, as in the case of ActiveRecord
.
As a last step, we need to implement the fixtures()
method to define which fixtures we want to use in our tests. To do so, we can use the following code:
// tests/codeception/unit/models/UserTest.php public function fixtures() { return [ 'user' => UserFixture::className(), ]; }
By doing this, our setUp()
method will initialize the database with the content of the fixture we've just defined. If we were in need to use more than one fixture for the same fixture class, then we could have specified which fixture to load in the current test by also returning a dataFile
key that specified the path of the fixture, as in the following example:
public function fixtures() { return [ 'user' => [ 'class' => UserFixture::className(), 'dataFile' => '@app/tests/codeception/unit/fixtures/data/userModels.php' ] ]; }
Now that we have the fixture defined and ready to be used, we can access its content via the $this->user
variable (and now you can see why it's better to keep private and public variables well defined and separate). You can normally use it as an array and access the index or key you need, or let it return an ActiveRecord
object as with $this->user('admin')
.
Now, we can see it in action by refactoring our previously implemented test:
public function testFindIdentityReturnsTheExpectedObject() {
$expectedAttrs = $this->user['admin'];
/** @var User $user */
$user = User::findIdentity($expectedAttrs['id']);
$this->assertNotNull($user);
$this->assertInstanceOf('yiiwebIdentityInterface', $user);
$this->assertEquals($expectedAttrs['id'], $user->id);
$this->assertEquals($expectedAttrs['username'], $user->username);
$this->assertEquals($expectedAttrs['password'], $user->password);
$this->assertEquals($expectedAttrs['authkey'], $user->authkey);
}
This way, we can carry on with our tests without worrying about calling save()
every time we need to ensure that a record is in the database.
This also means that we won't need to clean up the database, as the fixture will do so for us:
protected function setUp() { parent::setUp(); $this->_user = new User; }
Following what we just said, it should be quite straightforward to implement tests for findByUsername()
in the same way as we did for findIdentity()
. So, I'll leave this for you as an exercise.
By now, we should have almost all the tests created, apart from the ones covering validateAuthKey()
, which you should be able to implement without any particular problem, and validatePassword()
, which we will take a closer look at in Chapter 5, Summoning the Test Doubles.