Testing with doubles

So far, we tested classes that are quite isolated; that is, they do not have much interaction with other classes. Nevertheless, we have classes that use several classes, such as controllers. What can we do with these interactions? The idea of unit tests is to test a specific method and not the whole code base, right?

PHPUnit allows you to mock these dependencies; that is, you can provide fake objects that look similar to the dependencies that the tested class needs, but they do not use code from those classes. The goal of this is to provide a dummy instance that the class can use and invoke its methods without the side effect of what these invocations might have. Imagine as an example the case of the models: if the controller uses a real model, then when invoking methods from it, the model would access the database each time, making the tests quite unpredictable.

If we use a mock as the model instead, the controller can invoke its methods as many times as needed without any side effect. Even better, we can make assertions of the arguments that the mock received or force it to return specific values. Let's take a look at how to use them.

Injecting models with DI

The first thing we need to understand is that if we create objects using new inside the controller, we will not be able to mock them. This means that we need to inject all the dependencies—for example, using a dependency injector. We will do this for all of the dependencies but one: the models. In this section, we will test the borrow method of the BookController class, so we will show the changes that this method needs. Of course, if you want to test the rest of the code, you should apply these same changes to the rest of the controllers.

The first thing to do is to add the BookModel instance to the dependency injector in our index.php file. As this class also has a dependency, PDO, use the same dependency injector to get an instance of it, as follows:

$di->set('BookModel', new BookModel($di->get('PDO')));

Now, in the borrow method of the BookController class, we will change the new instantiation of the model to the following:

public function borrow(int $bookId): string {
    $bookModel = $this->di->get('BookModel');

    try {
//...

Customizing TestCase

When writing your unit test's suite, it is quite common to have a customized TestCase class from which all tests extend. This class always extends from PHPUnit_Framework_TestCase, so we still get all the assertions and other methods. As all tests have to import this class, let's change our autoloader so that it can recognize namespaces from the tests directory. After this, run composer update, as follows:

"autoload": {
    "psr-4": {
        "Bookstore\Tests\": "tests",
        "Bookstore\": "src"
    }
}

With this change, we will tell Composer that all the namespaces starting with BookstoreTests will be located under the tests directory, and the rest will follow the previous rules.

Let's add now our customized TestCase class. The only helper method we need right now is one to create mocks. It is not really necessary, but it makes things cleaner. Add the following class in tests/AbstractTestClase.php:

<?php

namespace BookstoreTests;

use PHPUnit_Framework_TestCase;
use InvalidArgumentException;

abstract class AbstractTestCase extends PHPUnit_Framework_TestCase {
    protected function mock(string $className) {
        if (strpos($className, '') !== 0) {
            $className = '' . $className;
        }

        if (!class_exists($className)) {
            $className = 'Bookstore' . trim($className, '');

            if (!class_exists($className)) {
                throw new InvalidArgumentException(
                    "Class $className not found."
                );
            }
        }

        return $this->getMockBuilder($className)
            ->disableOriginalConstructor()
            ->getMock();
    }
}

This method takes the name of a class and tries to figure out whether the class is part of the Bookstore namespace or not. This will be handy when mocking objects of our own codebase as we will not have to write Bookstore each time. After figuring out what the real full class name is, it uses the mock builder from PHPUnit to create one and then returns it.

More helpers! This time, they are for controllers. Every single controller will always need the same dependencies: logger, database connection, template engine, and configuration reader. Knowing this, let's create a ControllerTestCase class from where all the tests covering controllers will extend. This class will contain a setUp method that creates all the common mocks and sets them in the dependency injector. Add it as your tests/ControllerTestCase.php file, as follows:

<?php

namespace BookstoreTests;

use BookstoreUtilsDependencyInjector;
use BookstoreCoreConfig;
use MonologLogger;
use Twig_Environment;
use PDO;

abstract class ControllerTestCase extends AbstractTestCase {
    protected $di;

    public function setUp() {
        $this->di = new DependencyInjector();
        $this->di->set('PDO', $this->mock(PDO::class));
        $this->di->set('UtilsConfig', $this->mock(Config::class));
        $this->di->set(
            'Twig_Environment',
            $this->mock(Twig_Environment::class)
        );
        $this->di->set('Logger', $this->mock(Logger::class));
    }
}

Using mocks

Well, we've had enough of the helpers; let's start with the tests. The difficult part here is how to play with mocks. When you create one, you can add some expectations and return values. The methods are:

  • expects: This specifies the amount of times the mock's method is invoked. You can send $this->never(), $this->once(), or $this->any() as an argument to specify 0, 1, or any invocations.
  • method: This is used to specify the method we are talking about. The argument that it expects is just the name of the method.
  • with: This is a method used to set the expectations of the arguments that the mock will receive when it is invoked. For example, if the mocked method is expected to get basic as the first argument and 123 as the second, the with method will be invoked as with("basic", 123). This method is optional, but if we set it, PHPUnit will throw an error in case the mocked method does not get the expected arguments, so it works as an assertion.
  • will: This is used to define what the mock will return. The two most common usages are $this->returnValue($value) or $this->throwException($exception). This method is also optional, and if not invoked, the mock will always return null.

Let's add the first test to see how it would work. Add the following code to the tests/Controllers/BookControllerTest.php file:

<?php

namespace BookstoreTestsControllers;

use BookstoreControllersBookController;
use BookstoreCoreRequest;
use BookstoreExceptionsNotFoundException;
use BookstoreModelsBookModel;
use BookstoreTestsControllerTestCase;
use Twig_Template;

class BookControllerTest extends ControllerTestCase {
    private function getController(
        Request $request = null
    ): BookController {
        if ($request === null) {
            $request = $this->mock('CoreRequest');
        }
        return new BookController($this->di, $request);
    }

    public function testBookNotFound() {
        $bookModel = $this->mock(BookModel::class);
        $bookModel
            ->expects($this->once())
            ->method('get')
            ->with(123)
            ->will(
                $this->throwException(
                    new NotFoundException()
                )
            );
        $this->di->set('BookModel', $bookModel);

        $response = "Rendered template";
        $template = $this->mock(Twig_Template::class);
        $template
            ->expects($this->once())
            ->method('render')
            ->with(['errorMessage' => 'Book not found.'])
            ->will($this->returnValue($response));
        $this->di->get('Twig_Environment')
            ->expects($this->once())
            ->method('loadTemplate')
            ->with('error.twig')
            ->will($this->returnValue($template));

        $result = $this->getController()->borrow(123);

        $this->assertSame(
            $result,
            $response,
            'Response object is not the expected one.'
        );
    }
}

The first thing the test does is to create a mock of the BookModel class. Then, it adds an expectation that goes like this: the get method will be called once with one argument, 123, and it will throw NotFoundException. This makes sense as the test tries to emulate a scenario in which we cannot find the book in the database.

The second part of the test consists of adding the expectations of the template engine. This is a bit more complex as there are two mocks involved. The loadTemplate method of Twig_Environment is expected to be called once with the error.twig argument as the template name. This mock should return Twig_Template, which is another mock. The render method of this second mock is expected to be called once with the correct error message, returning the response, which is a hardcoded string. After all the dependencies are defined, we just need to invoke the borrow method of the controller and expect a response.

Remember that this test does not have only one assertion, but four: the assertSame method and the three mock expectations. If any of them are not accomplished, the test will fail, so we can say that this method is quite robust.

With our first test, we verified that the scenario in which the book is not found works. There are two more scenarios that fail as well: when there are not enough copies of the book to borrow and when there is a database error when trying to save the borrowed book. However, you can see now that all of them share a piece of code that mocks the template. Let's extract this code to a protected method that generates the mocks when it is given the template name, the parameters are sent to the template, and the expected response is received. Run the following:

protected function mockTemplate(
    string $templateName,
    array $params,
    $response
) {
    $template = $this->mock(Twig_Template::class);
    $template
        ->expects($this->once())
        ->method('render')
        ->with($params)
        ->will($this->returnValue($response));
    $this->di->get('Twig_Environment')
        ->expects($this->once())
        ->method('loadTemplate')
        ->with($templateName)
        ->will($this->returnValue($template));
}

public function testNotEnoughCopies() {
    $bookModel = $this->mock(BookModel::class);
    $bookModel
        ->expects($this->once())
        ->method('get')
        ->with(123)
        ->will($this->returnValue(new Book()));
    $bookModel
        ->expects($this->never())
        ->method('borrow');
    $this->di->set('BookModel', $bookModel);

    $response = "Rendered template";
    $this->mockTemplate(
        'error.twig',
        ['errorMessage' => 'There are no copies left.'],
        $response
    );

    $result = $this->getController()->borrow(123);

    $this->assertSame(
        $result,
        $response,
        'Response object is not the expected one.'
    );
}

public function testErrorSaving() {
    $controller = $this->getController();
    $controller->setCustomerId(9);

    $book = new Book();
    $book->addCopy();
    $bookModel = $this->mock(BookModel::class);
    $bookModel
        ->expects($this->once())
        ->method('get')
        ->with(123)
        ->will($this->returnValue($book));
    $bookModel
        ->expects($this->once())
        ->method('borrow')
        ->with(new Book(), 9)
        ->will($this->throwException(new DbException()));
    $this->di->set('BookModel', $bookModel);

    $response = "Rendered template";
    $this->mockTemplate(
        'error.twig',
        ['errorMessage' => 'Error borrowing book.'],
        $response
    );

    $result = $controller->borrow(123);

    $this->assertSame(
        $result,
        $response,
        'Response object is not the expected one.'
    );
}

The only novelty here is when we expect that the borrow method is never invoked. As we do not expect it to be invoked, there is no reason to use the with nor will method. If the code actually invokes this method, PHPUnit will mark the test as failed.

We already tested and found that all the scenarios that can fail have failed. Let's add a test now where a user can successfully borrow a book, which means that we will return valid books and customers from the database, the save method will be invoked correctly, and the template will get all the correct parameters. The test looks as follows:

public function testBorrowingBook() {
    $controller = $this->getController();
    $controller->setCustomerId(9);

    $book = new Book();
    $book->addCopy();
    $bookModel = $this->mock(BookModel::class);
    $bookModel
        ->expects($this->once())
        ->method('get')
        ->with(123)
        ->will($this->returnValue($book));
    $bookModel
        ->expects($this->once())
        ->method('borrow')
        ->with(new Book(), 9);
    $bookModel
        ->expects($this->once())
        ->method('getByUser')
        ->with(9)
        ->will($this->returnValue(['book1', 'book2']));
    $this->di->set('BookModel', $bookModel);

    $response = "Rendered template";
    $this->mockTemplate(
        'books.twig',
        [
            'books' => ['book1', 'book2'],
            'currentPage' => 1,
            'lastPage' => true
        ],
        $response
    );

    $result = $controller->borrow(123);

    $this->assertSame(
        $result,
        $response,
        'Response object is not the expected one.'
    );
}

So this is it. You have written one of the most complex tests you will need to write during this book. What do you think of it? Well, as you do not have much experience with tests, you might be quite satisfied with the result, but let's try to analyze it a bit further.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.143.247.125