Alexander Steshenko | On Software Engineering

Testing the model

Testing environment

I chose PHPUnit and created a directory for our tests already. To get started two things left:

phpunit.xml so far is this:

<phpunit bootstrap="./bootstrap.php">
<testsuite name="BasicCRM Test Suite">
<directory>./</directory>
</testsuite>
</phpunit>

Tests suite’s entry point as any entry point of our app must define APPLICATION\PATH constant, include path and an autoloader so models can be found and loaded.

/**
 * Tests suite entry point
 */
// Define path to application directory
define('APPLICATION_PATH', realpath(dirname(__FILE__) . '/../application'));

// Ensure is on include_path
set_include_path(implode(PATH_SEPARATOR, array(
    APPLICATION_PATH . '/models',
    get_include_path(),
)));

// Registering the autoloader
require_once 'Zend/Loader/Autoloader.php';
Zend_Loader_Autoloader::getInstance()->setFallbackAutoloader(true);

for the autoloader I used the one that comes with Zend Framework.

Domain Model

First thing to do is to test whether we’ve brought all our domain model requirements to the code correctly. You do not need a database for that. The tests must cover all constraints you have in your domain model and nothing else should be taken into account.

Let’s arrange “Tests” to be the root namespace for our tests. Tests for the domain model will be located in the tests/Domain directory and have the namespace Tests/Domain accordingly.

For instance, here are the requirements for the Subscription, the simplest domain entity:

As the implementation reflects the domain model itself, the tests reflect these constraints. Let’s take a look at tests/Domain/SubscriptionTest.php

namespace Tests\Domain;

use Domain\Subscription;

class SubscriptionTest extends \PHPUnit_Framework_TestCase
{
    /*
     * has a name
     */
    public function testHasName()
    {
        $subscription = new Subscription();
        // just checking that the attribute exists
        $this->assertAttributeEmpty('name', $subscription);
    }

    /*
     * has an id
     */
    public function testHasId()
    {
        $subscription = new Subscription();
        // just checking that the attribute exists
        $this->assertAttributeEmpty('id', $subscription);
    }
}

It’s not needed to choose what should be covered by unit tests. You test everything that your domain model dictates. To not spoil it, never change tests without changing the domain model actual requirements. And vice versa.

Now, let’s test something more interesting, the User entity.

Here is the test case tests/Domain/UserTest.php, check out the comments:

namespace Tests\Domain;

use Domain\User;

class UserTest extends \PHPUnit_Framework_TestCase
{
    /*
     * has a unique identifier for reference
     */
    public function testHasId()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456');
        // just checking that the attribute exists
        $this->assertAttributeEmpty('id', $user);
    }

    /*
     * has a name
     */
    public function testHasName()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456');
        $this->assertAttributeEquals('John Smith', 'name', $user);
    }

    /*
     * has valid email
     * aspect: has email
     */
    public function testHasEmail()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456');
        $this->assertAttributeEquals('valid-email@example.com', 'email', $user);
    }

    /*
     * has valid email
     * aspect: email must be valid
     */
    public function testHasValidEmail()
    {
        $this->setExpectedException('DomainException', 'Email is not valid');
        new User('invalid-email-example.com', 'John Smith', '123456');
    }

    /*
     * has a password not shorter than 6 characters, hashed
     * aspect: password must be longer than 5 characters
     */
    public function testHasPasswordNotShorterThan6Characters()
    {
        $this->setExpectedException('DomainException', 'Wrong password length');
        new User('valid-email@example.com', 'John Smith', '12345');
    }

    /*
     * has a password not shorter than 6 characters, hashed
     * aspect: has password hashed
     */
    public function testHasPasswordHashed()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456');
        $this->assertAttributeEquals(sha1('123456'), 'passwordHash', $user);
    }

    /*
     * may be either admin or not admin
     * aspect: may be admin
     */
    public function testMayBeAdmin()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456', true);
        $this->assertAttributeEquals(true, 'isAdmin', $user);
    }

    /*
     * may be either admin or not admin
     * aspect: may be admin
     */
    public function testMayBeNotAdmin()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456', false);
        $this->assertAttributeEquals(false, 'isAdmin', $user);
    }

    /*
     * is not admin by default
     */
    public function testIsNotAdminByDefault()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456');
        $this->assertAttributeEquals(false, 'isAdmin', $user);
    }

    /*
     * belongs to a single company
     * aspect: belongs to a company
     */
    public function testBelongsToCompany()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456');
        /*
         * We use PHPUnit's mock builder to create Companies
         * because it's not the Company we're testing now and
         * we don't need real ones.
         *
         * PHPUnit's mock builder is explained in the
         * official manual.
         */
        $company = $this->getMock('Domain\Company', array(), array(), '', false);
        $user->setCompany($company);
        $this->assertAttributeEquals($company, 'company', $user);
    }

    /*
     * belongs to a single company
     * (there will be an error on attempt to associate a user with
     *  more than just one company)
     */
    public function testBelongsToSingleCompany()
    {
        $this->setExpectedException('DomainException', 'The user already belongs to a company');
        $user = new User('valid-email@example.com', 'John Smith', '123456');
        $companyMockBuilder = $this->getMockBuilder('Domain\Company')->disableOriginalConstructor();
        $company1 = $companyMockBuilder->getMock();
        $company2 = $companyMockBuilder->getMock();

        $user->setCompany($company1);
        $user->setCompany($company2);
    }

    /*
     * there is a way to define whether a user is an admin or not
     */
    public function testIsAdmin()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456', true);
        $this->assertTrue($user->isAdmin());
    }

    /*
     * there is a way to define whether a user is an admin or not
     */
    public function testIsNotAdmin()
    {
        $user = new User('valid-email@example.com', 'John Smith', '123456', false);
        $this->assertFalse($user->isAdmin());
    }
}

I did the same thing for the Company, you can find the test case here https://github.com/lcf/BasicCRM/blob/master/tests/Domain/CompanyTest.php

Running the console tool:

Service Layer serves not only for application logic purposes but conveniently groups the upper layer of domain logic. One service layer function = one atomic business operation which we must test.

While repositories are also domain model objects we only use standard Doctrine 2 features in our scenario. Something already covered with tests by Doctrine 2 developers. The same goes for the persister i.e. EntityManager in our case: it’s been already very well tested, so we only need to make sure everything we expect to be called is called and all requirements are reflected in the code.

Same as for Company in the User test above we need neither real repositories nor real entity manager. We’ll use PHPUnit built-in mock builder to fake those and utilize the ServiceLocator capabilities to replace the implementation of repositories and the manager so Service Layer gets mocks instead.

For that we’ll need to add two methods to models/ServiceLocator.php:

public static function setEm(Doctrine\Orm\EntityManager $em)
    {
        self::$em = $em;
    }

    public static function setSubscriptionRepository(Doctrine\Orm\EntityRepository $repository)
    {
        self::$subscriptionRepository = $repository;
    }

    public static function getSubscriptionsRepository()
    {
        if (self::$subscriptionRepository === null) {
            self::$subscriptionRepository = self::getEm()->getRepository('\Domain\Subscription');
        }

        return self::$subscriptionRepository;
    }

The requirements for the Service Layer’s registerCompany scenario look like this:

  1. finds the subscription plan by its identifier in the data storage
  2. error if the plan is for some reason not found
  3. error if two passwords provided are not equal
  4. creates new user admin account based on the email, name and password provided
  5. creates company based on company name provided, new admin user and the subscription plan found
  6. saves the new company in the data storage

tests/Domain/RegisterCompanyTest.php:

namespace Tests\Domain;

/**
 * We'll need the following annotation in tests where we change static variables of our ServiceLocator class
 * so they'll be restored afterwards.
 * It's not a problem, because 
 * ServiceLocator is the only class with anything "static" we're going to have.
 *
 * @backupStaticAttributes enabled
 */
class RegisterCompanyTest extends \PHPUnit_Framework_TestCase
{
    /**
     * We'll create some simple mocks for every test to configure
     *
     * @return void
     */
    protected function setUp()
    {
        $subscriptionRepository = $this->getMockBuilder('Doctrine\Orm\EntityRepository')
            ->disableOriginalConstructor()
            ->getMock();
        $em = $this->getMockBuilder('Doctrine\Orm\EntityManager')
            ->disableOriginalConstructor()
            ->getMock();
        \ServiceLocator::setSubscriptionRepository($subscriptionRepository);
        \ServiceLocator::setEm($em);
    }

    /**
     * @todo add getCompanyService to the ServiceLocator
     * @return \Services\CompanyService
     */
    protected function _getService()
    {
        return new \Services\CompanyService();
    }

    // ----------------------------------------------------------------------------------

    /*
     * finds the subscription plan by its identifier in the data storage
     */
    public function testFindSubscriptionPlanByItsId()
    {
        $subscriptionId = 12;
        \ServiceLocator::getSubscriptionsRepository()
            ->expects($this->once())
            ->method('find')
            ->with($this->equalTo($subscriptionId))
            ->will($this->returnValue($this->getMock('Domain\Subscription')));

        $this->_getService()->registerCompany($subscriptionId, 'Test Company', 'John Smith', 
                                              'valid-email@example.com', '123456', '123456');
    }

    /*
     * error if the plan is for some reason not found
     */
    public function testSubscriptionPlanNotFound()
    {
        $this->setExpectedException('DomainException', 'Subscription is not found');
        \ServiceLocator::getSubscriptionsRepository()
            ->expects($this->once())
            ->method('find')
            ->with($this->anything())
            ->will($this->returnValue(null)); // null means not found

        $this->_getService()->registerCompany(12, 'Test Company', 'John Smith', 
                                              'valid-email@example.com', '123456', '123456');
    }

    /*
     * error if two passwords provided are not equal
     */
    public function testPasswordsMustBeEqual()
    {
        \ServiceLocator::getSubscriptionsRepository()
            ->expects($this->once())
            ->method('find')
            ->with($this->anything())
            ->will($this->returnValue($this->getMock('Domain\Subscription')));
        $this->setExpectedException('DomainException', 'Passwords are not equal');
        $this->_getService()->registerCompany(12, 'Test Company', 'John Smith', 
                                              'valid-email@example.com', '1234568', '1234567');
    }

    /*
     * creates new user admin account based on the email, name and password provided
     * creates company based on company name provided, new admin user and the subscription plan found
     * saves the new company in the data storage
     */
    public function testPersistNewCompany()
    {
        \ServiceLocator::getSubscriptionsRepository()
            ->expects($this->once())
            ->method('find')
            ->with($this->anything())
            ->will($this->returnValue($this->getMock('Domain\Subscription')));

        \ServiceLocator::getEm()
            ->expects($this->once())
            ->method('persist')
            ->with($this->isInstanceOf('Domain\Company'));

        \ServiceLocator::getEm()
            ->expects($this->once())
            ->method('flush');

        $this->_getService()->registerCompany(12, 'Test Company', 'John Smith', 
                                              'valid-email@example.com', '1234567', '1234567');
    }
}

This concludes testing the Domain Model. Whether you have something like user stories or not you’re free to change the order of things: you can write tests first, you can implement the logic keeping the domain requirements in your head and then test the logic.

You just need to maintain integrity. If you change one part - the changes should be done in all others. For instance you wrote the tests and are starting the implementation. You occasionally see that you can’t code what you really needed in terms of the Domain Model requirements you have. Stop at that moment and extend the user stories with everything you need, then update the tests so they follow the new requirements and then complete the implementation.

I don’t personally like UMLs and have no problems with mixing orders of things getting into place according to the original task.

Application logic

Software isn’t just about the domain model. We have things such as sending emails, database transactions. Things that need to be tested as well. What sense make our classes if our Service can’t actually save anything to the database?

We need more tests and these are not “Domain”. I call them “Functional” which is also the namespace they belong to and the directory, i.e. tests/Functional. They’re called so because of the new point of view. We don’t care anymore what’s inside of our ServiceLayer functions, we don’t need to know their structure, implementation details and patterns applied. We just need to make sure that those “functions” do what they are supposed to do in the end.

In this specific case the only thing we have to do is to test whether the CompanyService::registerCompany() actually adds our new company and user data to the database tables. The test case is “tests/Functional/CompanyServiceTest.php” which groups tests for all functions related to companies operations.

PHPUnit has everything we need for testing databases.

Before any test executed it truncates the tables (*), fills them with data from XML-formatted fixtures. Every database test should check that after a certain operation that affects the DB the data in it matches our expectations about it.

We’ll have a file tests/Functional/files/company-service.xml, a fixture for CompanyServiceTest. We need to put there a couple of subscription plans so they can be found by CompanyService::registerCompany method:

<dataset>
<subscriptions id="1" name="Standard">
<subscriptions id="2" name="Pro">
<users>
<companies>
</companies></users></subscriptions></subscriptions></dataset>

The testRegisterCompany will call the method and make sure that the new data appeared in the tables. New data in this case is an admin user and a company. To do that we use another data set, tests/Functional/files/register-company.xml:

<dataset>
<companies id="1" is_activated="0" name="New Company" subscription_id="1">
<users company_id="1" email="john-smith@example.com" id="1" is_admin="1" name="John Smith" password_hash="20eabe5d64b0e216796e834f52d61fd0b70332fc">
</users></companies></dataset>

This is enough, except that the main database can’t be used for tests. We need a copy. I called mine basiccrm\tests. To make tests environment use this new database instead,I introduced sections to the config.ini configuration file, like this:

[main]
; Doctrine 2 configuration 
doctrine.cacheClass = "\Doctrine\Common\Cache\ArrayCache"
doctrine.db.dbname = "basiccrm"
doctrine.db.user = "root"
doctrine.db.password = "local"
doctrine.db.host = "127.0.0.1"
doctrine.db.driver = "pdo_mysql"

[tests : main]
doctrine.db.dbname = "basiccrm_tests"

The “tests” section inherits from “main” (which is for the main app) and in order to use it I added the following line to the test suite entry point:

ServiceLocator::setConfig(new Zend_Config_Ini(APPLICATION_PATH . '/configs/config.ini', 'tests'));

That’s all. Now, the test itself:

namespace Functional;

class CompanyServiceTest extends \PHPUnit_Extensions_Database_TestCase
{
    protected function getConnection()
    {
        $pdo = \ServiceLocator::getDb()->getWrappedConnection();
        return $this->createDefaultDBConnection($pdo);
    }

    protected function getDataSet()
    {
        return $this->createFlatXMLDataSet(dirname(__FILE__).'/_files/company-service.xml');
    }

    protected function getSetUpOperation()
    {
        return new \PHPUnit_Extensions_Database_Operation_Composite(array(
            new \TestsExtensions\TruncateDatabaseOperation(),
            \PHPUnit_Extensions_Database_Operation_Factory::INSERT()
        ));
    }

    public function testRegisterCompany()
    {
        $companyService = new \Services\CompanyService();
        $companyService->registerCompany(1, 'New Company', 'John Smith', 
                                         'john-smith@example.com', '1234567', '1234567');
        $expected = $this->createFlatXMLDataSet(dirname(__FILE__).'/_files/register-company.xml');
        $actual = new \PHPUnit_Extensions_Database_DataSet_QueryDataSet($this->getConnection());
        // we're listing tables that matter 
        $actual->addTable('users');
        $actual->addTable('companies');
        $this->assertDataSetsEqual($expected, $actual);
    }
}

(*) Truncating the tables.

While PHPUnit has a built-in truncate operation it won’t fit for tables with foreign keys. I added my own Truncate operation: https://github.com/lcf/BasicCRM/blob/master/tests/TestsExtensions/TruncateDatabaseOperation .php. It does two things: deletes all data from a table and resets the auto-increment number.


Comments and feedback are much appreciated. See contact details here