Monday, 5 August 2013

Test Driven Design(TDD) in CakePHP 2 Part 2

I have explained the way how TDD should be done in the CakePHP for controllers. In this post, I will go through how it is done in the models.

Let's again start with an example. If you read my last post, we need a method on the 'Cake' model named 'getNumberOfCakes' to take a integer and return the number of cakes in an array fetched from database.

If you baked the model, you will have a test for it baked under 'app/Test/Model/' named 'CakeTest.php'. You can use cake console to generate it as well. Just simply type 'Console/cake bake test' and follow the prompt. The test class looks like this:

App::uses('Cake', 'Model');

/**
 * Cake Test Case
 *
 */
class CakeTest extends CakeTestCase {

/**
 * Fixtures
 *
 * @var array
 */
    public $fixtures = array(
        'app.cake'
    );

/**
 * setUp method
 *
 * @return void
 */
    public function setUp() {
        parent::setUp();
        $this->Cake = ClassRegistry::init('Cake');
    }

/**
 * tearDown method
 *
 * @return void
 */
    public function tearDown() {
        unset($this->Cake);

        parent::tearDown();
    }

}

'setUp' and 'tearDown' methods are called before and after every test method runs respectively. 'setUp' generally instantiates a model object, in this case 'Cake' model, and assigns it to the test class itself with the name of model. Whereas, 'tearDown' unsets it.

You may notice that a public instance variable called 'fixtures'. It is an array contains the models being used in the tests which are using fixtures as data instead of real data already in the database. Again, if you bake the model, you will find a fixture under 'app/Test/Fixture' called 'CakeFixture'. Otherwise, you can bake the fixture using 'Console/cake bake fixture'. Open it, you will find two properties, which are 'fields' - the schema of 'Cake' table and 'records' - the data for that table. If you have set up test database in the database.php under 'app/Config', your 'Cake' model under the test class above will always be backed by a table defined by 'fields' property with data inside defined by 'records' property in the test database. This table will be truncated after running every test method in the test class and refilled before running them.

With the knowledge of fixtures, it couldn't be easier for us to write a test for model. In this case, we just need to call 'getNumberOfCakes' method on the '$this->Cake' and using '$this->assertEquals' method to make sure the result returned is the result we can find from the fixture. In order to test 'getNumberOfCakes' method, we need to have at least 2 records in the fixture. So we change the 'records' property on 'CakeFixture' to:

public $records = array(
    array(
        'id' => 1,
        'name' => 'Cheese Cake',
        'created' => '2013-06-23 15:59:45',
        'modified' => '2013-06-23 15:59:45'
    ),
    array(
        'id' => 2,
        'name' => 'Choc Mud Cake',
        'created' => '2013-06-25 10:58:00',
        'modified' => '2013-06-25 13:53:00'
    )
);

Then, we write a test for 'getNumberOfCakes' method:
// in CakeTest class under app/Test/Case/Model
/**
 * test getNumberOfCakes method
 *
 * @return void
 */
    public function testGetNumberOfCakes() {
        $expected = array(
            array(
                'Cake' => array(
                    'id' => 1,
                    'name' => 'Cheese Cake',
                    'modified' => '2013-06-23 15:59:45'
                )
            )
        );
        $this->assertEquals(
            $expected, 
            $this->Cake->getNumberOfCakes(1)
        );
    }

You can run the test in the console using 'Console/cake test app Model/Cake' and you will see a 'PDOException' thrown out. This is because 'getNumberOfCakes' method does not exist yet on the 'Cake' model. To rectify this, we need go on to the step 2 of TDD process "write the code to let the test pass". But this time we are going to go through step 2 and step 3 in our mind first. Imagine you put a public method 'getNumberOfCakes' under 'Cake' model taking a parameter '$number' and then you just copy the value of '$expected' from test as the return value of the method. This way the test is going to pass. However, there are duplications. To reduce them, we use a 'find' method with 'all' as first parameter and an array contains 'limit' 1 and 'fields' as second parameter to replace the return value. To further reduce the duplication, we replace the number 1 with the variable passed to it. Finally, we end up with:
/**
 * get number of cakes, given $number
 * 
 * @return array
 */
    public function getNumberOfCakes($number)
    {
        return $this->find('all', array(
            'fields' => array(
                'Cake.id',
                'Cake.name',
                'Cake.modified'
            ),
            'limit' => $number
        ));
    }

You may have some doubts or questions about the test in this example. That can be 'Is the only assertion sufficient? Should we test the situation that passing 2 to getNumberOfCakes? Why passing 1 is good/sufficient here?'. Let's take a close look at the test. The '$expected' variable is an array with a structure of what's coming out of "find('all')". Thus, it can be expected that a "find('all')" being in 'getNumberOfCakes' when reducing the duplication of returning the same value of '$expected' from 'getNumberOfCakes' is the simplest and easiest way. Also, since we only have two records of cakes in the fixture, we can't put 2 to 'getNumberOfCakes' in the test. If we did, the simplest and easiest way to reduce duplication is to NOT put 'limit' in the array of the second parameter of 'find' method in the 'getNumberOfCake' and that is certainly not we want. By putting number one here, we can be sure that it is enough to produce the correct code to cover the test. This is a typical example to show how important and useful that 'Reduce duplication in the simplest and easiest way to make test stay passed' is.

Let us now look at a more complicated example. We need to have a method sitting on the 'Cake' model called 'addAndNotify' which takes an array '$data' and a boolean '$notify'. What it dose is saving '$data' into database, then sends an email using email config 'notify' if '$notify' is true.

First, we start with a test to test the data passed to 'addAndNotify' method gets saved into database.
// in CakeTest.php under /app/Test/Case/Model
/**
 * test addAndNotify method saves cake to database
 *
 * @return void
 */
    public function testAddAndNotifySavesCakeToDatabase()
    {
        $latestId = $this->Cake->field('id', null, array('id' => 'DESC'));

        $this->Cake->id = 1;
        $result = $this->Cake->addAndNotify(array(
            'Cake' => array(
                'name' => 'Fruit Cake'
            )
        ));
        $this->assertInternalType('array', $results);
        $this->assertEquals('Fruit Cake', $this->Cake->field('name', array(
            'id' => $latestId + 1
        )));
    }

The Code with no duplication to let it pass is:
// in Cake Model
    public function addAndNotify($data)
    {
        $this->create();
        return $this->save($data);
    }
By looking at the test above, you may have several questions:
Q. The first line of the test getting the last 'id' from 'cakes' table in the test database. And we know that the data in the table is from 'CakeFixture' class. So why don't we just look into the fixture to get an id?
A. As your program grows, you may need to add test data to fixtures, which means the last id will change and this test will fail. Imagine you have dozens of tests rely on the last id in the fixture hard coded in the test and it is certainly not a easy solution for good maintainability of your tests.

Q. Why do we assign number 1 to the 'id' on the '$this->Cake'?
A. As we know, if there is an 'id' on the 'Model', CakePHP will do an 'update' instead of 'create' when you call 'save'. And the later is what we want. If we don't assign a value to id, a call to 'save' method is enough to let the test pass but when you calling 'addAndNotify' multiple times, the later one is going to overwrite the one saved before. Thus, assigning number 1 to 'id' here is to make sure 'create' method is called before 'save'.

Q. Do we need to test the situation that '$result' is false?
A. Yes and No. Yes because we need to know 'false' is returned if the method can't save the data. No, because we have already covered the situation of returning false. Since the easiest and simplest way to have the correct return value in the 'addAndNotify' method to let the test pass is to return the value coming out of 'save' method, which insures if the data can't be saved, 'false' is returned as desired. This is another good example of telling us that how important 'simplest and easiest' is when you write your code to cover your test.

Q. Why do we only test the field 'name' is equal to 'Fruit Cake' and we don't test the created and modified time?
A. If you write the code below to test the 'created' and 'modified' time

// under testAddAndNotifySavesCakeToDatabase method in CakeTest.php 
// in /app/Test/Case/Model
    $this->assertEquals(array(
        'Cake' => array(
            'id' => $latestId + 1,
            'name' => 'Fruit Cake',
            'created' => date('Y-m-d H:i:s'),
            'modified' => date('Y-m-d H:i:s')
        )
    ), $this->Cake->read(null, $latestId + 1));
and it passes when you run it, I can assure you it is not the right test. Because the time we generate using 'date' function is technically not the same as the time the data is saved into database, although they can be in the same second to let the test pass. This means under some circumstances such as you have a very slow computer or you turn on the sql log for every query to write to a very slow hard disk or you call 'sleep' function right before this assertion, you will see the test fail. Therefore, testing created and modified fields auto-generated by cake can not be easily done. In the meantime, it is not necessary since the test for this functionality belongs to the tests of 'save' method in the CakePHP core. 

We need to write a another test to make sure when passing true to the second parameter of 'addAndNotify' method, it will send an email. First, we are going to do some modification of the test above:
 
// in CakeTest.php under /app/Test/Case/Model
/**
 * test addAndNotify method saves cake to database
 *
 * returns the first parameter to addAndNotify and the result coming out of addAndNotify
 * @return array
 */
    public function testAddAndNotifySavesCakeToDatabase() {
        $latestId = $this->Cake->field('id', null, array('id' => 'DESC'));

        $this->Cake->id = 1;
        $cake = array(
            'Cake' => array(
                'name' => 'Fruit Cake'
            )
        );
        $results = $this->Cake->addAndNotify($cake);
        $this->assertInternalType('array', $results);
        $this->assertEquals('Fruit Cake', $this->Cake->field('name', array(
            'id' => $latestId + 1
        )));
        return compact('results', 'cake');
    }
We change the test so it returns the array we passed to and the value returned from 'addAndNotify'. Then we write the test below:
// in CakeTest.php under /app/Test/Case/Model
/**
 * test addAndNotify method sends email when notify is true
 *
 * @depends testAddAndNotifySavesCakeToDatabase
 * @return void
 */
    public function testAddAndNotifySendsEmailWhenNotifyIsTrue($data) {
        $this->Cake->CakeEmail = $this->getMock('CakeEmail', array(
            'send',
            'config'
        ));

        $this->Cake->CakeEmail->expects($this->once())
            ->method('config')
            ->with('default');

        $this->Cake->CakeEmail->expects($this->once())
            ->method('send')
            ->with(array(
                'data' => array(
                    'name' => $data['results']['Cake']['name']
                )
            ));

        $this->Cake->addAndNotify($data['cake'], true);
    }
In the comment of this test method, there is a '@depends' annotation, which means this test depends on 'testAddAndNotifySavesCakeToDatabase' method and it will not run unless 'testAddAndNotifySavesCakeToDatabase' passes. Also, this test takes a parameter. It will be filled of the value returned from the test it depends on.
In this test method, a mocked object of 'CakeEmail' gets created and 'send' and 'config' methods are expected to be called on it to set up 'CakeEmail' object and send email with saved 'Cake' data.This way, we don't need to worry about duplicating the test we did in 'testAddAndNotifySavesCakeToDatabase' or mocking up 'save' method which is some sort of duplication as well. This is all because '@depends' gives us the confidence that when 'testAddAndNotifySavesCakeToDatabase' passes we will have a working pair of input and output of 'addAndNotify' method.
To satisfy this test method, we changed the code of 'addAndNotify' method to:
// in Cake.php under /app/Model
    public function addAndNotify($data)
    {
        $this->create();
        $result = $this->save($data);

        $this->CakeEmail->config('default');
        $this->CakeEmail->send(array(
                'data' => array(
                    'name' => $result['Cake']['name']
                )
            ));

        return $result;
    }
This seems to be a straight forward solution to let the second test pass. However, when you run it with 'Console/cake test app Model/Cake --filter testAddAndNotify', you will see 'PHP Fatal error:  Call to a member function config() on a non-object'. This is because the first test we wrote does not set a 'CakeEmail' object onto 'Cake' model. It intended that way since it is only testing the saving part of the method. Thus, a further change to 'addAndNotify' is required.
// in Cake.php under /app/Model
    public function addAndNotify($data, $notify = false)
    {
        $this->create();
        $result = $this->save($data);

        if ($notify) {
            $this->CakeEmail->config('default');
            $this->CakeEmail->send(array(
                'data' => array(
                    'name' => $result['Cake']['name']
                )
            ));
        }
        return $result;
    }
Run the test again, it will pass and I can't see any duplication needs to be reduced and the code is simple and easy enough. So that's it? No! The problem comes up with the first line of the second test method which is assigning a mocked object to 'Cake' model. This means 'addAndNotify' method does not create this object by itself and this can be problematic and confusing as people who want to use this method expect it to take care of that.
To rectify this, we write another test to force 'Cake' model create 'CakeEmail' by itself.
// in CakeTest.php under /app/Test/Case/Model
/**
 * test __get method creates CakeEmail object
 * @return void
 */
    public function test__getCreatesCakeEmailObject()
    {
        $this->assertInstanceOf('CakeEmail', $this->Cake->CakeEmail);
        $this->assertIdentical(
            $this->Cake->CakeEmail,
            $this->Cake->__get('CakeEmail')
        );
        $this->assertEquals('name', $this->Cake->displayField);
    }
The first assertion here is to make sure that object of class 'CakeEmail' is instantiated when we need it on the 'Cake' model and the second one is to test this instantiation only happens once. As to the third assertion, it is to make sure '__get' method on the parent object is called(for this to work as intended, remove the display field property if it is assigned to 'name' or 'title').
The simplest and easiest way to let the pass test is:
// in Cake.php under /app/Model
/**
 * @param string $name
 * @return mixed
 */
    public function __get($name) {
        if ($name == 'CakeEmail' && !isset($this->CakeEmail)) {
            $this->CakeEmail = new CakeEmail();
        }
        return parent::__get($name);
    }
Now, we can say this is it. To recap, we did the following in the three tests above:
  1. We used fixture to help us create test environment
  2. We used mocked object to prevent sending email out
  3. We used '@depends' annotation to avoid duplication of test
  4. We learned writing test can lead to lazy instantiation
Stay tuned for my next post about more tips and tricks of doing TDD under CakePHP.