Database testing

This will be the most controversial of the sections of this chapter by far. When it comes to database testing, there are different schools of thought. Should we use the database or not? Should we use our development database or one in memory? It is quite out of the scope of the book to explain how to mock the database or prepare a fresh one for each test, but we will try to summarize some of the techniques here:

  • We will mock the database connection and write expectations to all the interactions between the model and the database. In our case, this would mean that we would inject a mock of the PDO object. As we will write the queries manually, chances are that we might introduce a wrong query. Mocking the connection would not help us detect this error. This solution would be good if we used ORM instead of writing the queries manually, but we will leave this topic out of the book.
  • For each test, we will create a brand new database in which we add the data we would like to have for the specific test. This approach might take a lot of time, but it assures you that you will be testing against a real database and that there is no unexpected data that might make our tests fail; that is, the tests are fully isolated. In most of the cases, this would be the preferable approach, even though it might not be the one that performs faster. To solve this inconvenience, we will create in-memory databases.
  • Tests run against an already existing database. Usually, at the beginning of the test we start a transaction that we roll back at the end of the test, leaving the database without any change. This approach emulates a real scenario, in which we can find all sorts of data and our code should always behave as expected. However, using a shared database always has some side effects; for example, if we want to introduce changes to the database schema, we will have to apply them to the database before running the tests, but the rest of the applications or developers that use the database are not yet ready for these changes.

In order to keep things small, we will try to implement a mixture of the second and third options. We will use our existing database, but after starting the transaction of each test, we will clean all the tables involved with the test. This looks as though we need a ModelTestCase to handle this. Add the following into tests/ModelTestCase.php:

<?php

namespace BookstoreTests;

use BookstoreCoreConfig;
use PDO;

abstract class ModelTestCase extends AbstractTestCase {
    protected $db;
    protected $tables = [];

    public function setUp() {
        $config = new Config();

        $dbConfig = $config->get('db');
        $this->db = new PDO(
            'mysql:host=127.0.0.1;dbname=bookstore',
            $dbConfig['user'],
            $dbConfig['password']
        );
        $this->db->beginTransaction();
        $this->cleanAllTables();
    }

    public function tearDown() {
        $this->db->rollBack();
    }

    protected function cleanAllTables() {
        foreach ($this->tables as $table) {
            $this->db->exec("delete from $table");
        }
    }
}

The setUp method creates a database connection with the same credentials found in the config/app.yml file. Then, we will start a transaction and invoke the cleanAllTables method, which iterates the tables in the $tables property and deletes all the content from them. The tearDown method rolls back the transaction.

Note

Extending from ModelTestCase

If you write a test extending from this class that needs to implement either the setUp or tearDown method, always remember to invoke the ones from the parent.

Let's write tests for the borrow method of the BookModel class. This method uses books and customers, so we would like to clean the tables that contain them. Create the test class and save it in tests/Models/BookModelTest.php:

<?php

namespace BookstoreTestsModels;

use BookstoreModelsBookModel;
use BookstoreTestsModelTestCase;

class BookModelTest extends ModelTestCase {
    protected $tables = [
        'borrowed_books',
        'customer',
        'book'
    ];
    protected $model;

    public function setUp() {
        parent::setUp();

        $this->model = new BookModel($this->db);
    }
}

Note how we also overrode the setUp method, invoking the one in the parent and creating the model instance that all tests will use, which is safe to do as we will not keep any context on this object. Before adding the tests though, let's add some more helpers to ModelTestCase: one to create book objects given an array of parameters and two to save books and customers in the database. Run the following code:

protected function buildBook(array $properties): Book {
    $book = new Book();
    $reflectionClass = new ReflectionClass(Book::class);

    foreach ($properties as $key => $value) {
        $property = $reflectionClass->getProperty($key);
        $property->setAccessible(true);
        $property->setValue($book, $value);
    }

    return $book;
}

protected function addBook(array $params) {
    $default = [
        'id' => null,
        'isbn' => 'isbn',
        'title' => 'title',
        'author' => 'author',
        'stock' => 1,
        'price' => 10.0,
    ];
    $params = array_merge($default, $params);

    $query = <<<SQL
insert into book (id, isbn, title, author, stock, price)
values(:id, :isbn, :title, :author, :stock, :price)
SQL;
    $this->db->prepare($query)->execute($params);
}

protected function addCustomer(array $params) {
    $default = [
        'id' => null,
        'firstname' => 'firstname',
        'surname' => 'surname',
        'email' => 'email',
        'type' => 'basic'
    ];
    $params = array_merge($default, $params);

    $query = <<<SQL
insert into customer (id, firstname, surname, email, type)
values(:id, :firstname, :surname, :email, :type)
SQL;
    $this->db->prepare($query)->execute($params);
}

As you can note, we added default values for all the fields, so we are not forced to define the whole book/customer each time we want to save one. Instead, we just sent the relevant fields and merged them to the default ones.

Also, note that the buildBook method used a new concept, reflection, to access the private properties of an instance. This is way beyond the scope of the book, but if you are interested, you can read more at http://php.net/manual/en/book.reflection.php.

We are now ready to start writing tests. With all these helpers, adding tests will be very easy and clean. The borrow method has different use cases: trying to borrow a book that is not in the database, trying to use a customer not registered, and borrowing a book successfully. Let's add them as follows:

/**
 * @expectedException BookstoreExceptionsDbException
 */
public function testBorrowBookNotFound() {
    $book = $this->buildBook(['id' => 123]);
    $this->model->borrow($book, 123);
}

/**
 * @expectedException BookstoreExceptionsDbException
 */
public function testBorrowCustomerNotFound() {
    $book = $this->buildBook(['id' => 123]);
    $this->addBook(['id' => 123]);

    $this->model->borrow($book, 123);
}

public function testBorrow() {
    $book = $this->buildBook(['id' => 123, 'stock' => 12]);
    $this->addBook(['id' => 123, 'stock' => 12]);
    $this->addCustomer(['id' => 123]);

    $this->model->borrow($book, 123);
}

Impressed? Compared to the controller tests, these tests are way simpler, mainly because their code performs only one action, but also thanks to all the methods added to ModelTestCase. Once you need to work with other objects, such as sales, you can add addSale or buildSale to this same class to make things cleaner.

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

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