Customizing Codeception Database Cleanup
Recently, I was looking into ways to speed up the runtime of the test suite at Blopboard. We use the Codeception framework to write functional tests for our REST API, part of which entails putting the database into a known state using Codeception's Db module. The behavior of this module is similar to that of the PHPUnit Database extension with one exception: where PHPUnit only truncates tables and leaves their schemas intact, Codeception removes the database structure and expects the SQL dump it uses to recreate it between tests.
I must admit to not understanding this design decision of Codeception, nor attempts to clarify it. Be that as it may, I had a hunch that subverting it might lead to a faster runtime for our test suite, so I set about trying to find a solution to facilitate that. I found one, and while it's a bit hacky, it works.
<?php
namespace Codeception\Module;
/**
* Extends the standard Db helper to override cleanup behavior so that tables
* are truncated rather than dropped and recreated between tests.
*/
class DbHelper extends \Codeception\Module\Db
{
protected function cleanup()
{
$dbh = $this->driver->getDbh();
if (! $dbh) {
throw new ModuleConfigException(
__CLASS__,
'No connection to database. Remove this module from config'
. 'if you don\'t need database repopulation'
);
}
try {
if (!count($this->sql)) {
return;
}
/** Start **/
$dbh->exec('SET FOREIGN_KEY_CHECKS=0;');
$res = $dbh->query("SHOW FULL TABLES WHERE TABLE_TYPE LIKE '%TABLE';")->fetchAll();
foreach ($res as $row) {
$dbh->exec('TRUNCATE TABLE `' . $row[0] . '`');
}
$dbh->exec('SET FOREIGN_KEY_CHECKS=1;');
/** End **/
} catch (\Exception $e) {
throw new ModuleException(__CLASS__, $e->getMessage());
}
}
}
The above module class is used in place of the Db
module. To come up with it, I started by digging into the logic of the Db
module class itself. Codeception has several hook methods for modules that it calls internally. One of these is _initialize()
, which is called after the module class is instantiated and configuration for it is loaded but before any tests are run.
Looking at the _initialize()
implementation in the Db
module class, I found that it makes a call to a method to obtain a driver object for the particular database in use. This driver object implements a cleanup()
method that the Db
module class's own cleanup()
method calls between tests to handle resetting the database state.
There's a problem here, though: the call to obtain the driver object is to a static method, which means there's no way for me to specify my own logic for how to obtain a driver object rather than the logic that Codeception uses by default. This inhibits extensibility as well as testability.
I could have gotten around this by extending the Db
module class and overriding its _initialize()
method to call out to different code to obtain an instance of my own driver class. However, that would have meant duplicating most of the logic of that method, which is not of a trivial size. This would raise the likelihood that my code would not work with subsequent versions of Codeception if the method I was overriding changed.
In the end, the alternative I found was to instead extend the Db
module class and override its cleanup()
method. While this still results in duplication of code, the code being duplicated (which is demarcated by /** Start **/
and /** End **/
comments in the above code sample) is shorter, simpler, and less likely to be changed such that it impacts my code's functionality. It is worth noting, however, that the above code sample will likely only work with MySQL, and would need modifications to work with other database servers.
Had the Db
module class encapsulated its call to Driver::create()
within an instance method, I could have simply overridden that method in my subclass and had a cleaner solution.
Alternatively, Codeception could have supported a solution like this:
<?php
namespace Codeception\Module\Db;
interface DriverFactoryInterface
{
public function create($dsn, $user, $password);
// ...
}
class DriverFactory implements DriverFactoryInterface
{
public function create($dsn, $user, $password)
{
// The contents of Driver::create() would go here.
}
}
namespace Codeception\Module;
class Db extends \Codeception\Module
{
protected $driverFactory;
protected $driver;
public function _initialize()
{
// ...
if (!isset($this->config['driverFactoryClass'])) {
$this->config['driverFactoryClass'] = '\Codeception\Module\Db\DriverFactory';
}
$this->driver = $this->getDriverFactory()->create(
$this->config['dsn'],
$this->config['user'],
$this->config['password']
);
// ...
}
public function getDriverFactory()
{
if (!$this->driverFactory) {
$driverFactoryClass = $this->config['driverFactoryClass'];
$this->setDriverFactory(new $driverFactoryClass);
}
return $this->driverFactory;
}
public function setDriverFactory(DriverFactoryInterface $driverFactory)
{
$this->driverFactory = $driverFactory;
}
// ...
}
In the above solution, there is a DriverFactoryInterface
interface with, among others, a create()
instance method, and a DriverFactory
class that implements this interface. The Db
module class allows the specification of a class that implements this interface via its configuration. It then handles instantiating this class and calls that object's create()
method from its _initialize()
method rather than calling Driver::create()
as it presently does. With this code in place, I could write my own class implementing the interface to return my own driver. This would allow me to accomplish my goal without having to resort to subclassing.
In any case, my hunch and solution paid off: with the solution in place, we were able to cut our test suite runtime by roughly 30%. Another pleasant side effect was that I no longer needed to maintain a copy of our database schema apart from the one we already maintain using Liquibase.
I hope this solution and my thoughts on Codeception's present design are helpful to someone. Thanks for reading.