2

Some years ago I already created a very similar or even nearly the same question on StackOverflow. I got great detailed answers, but they didn't lead to a solution of my problem. Now, the problem became even bigger and I'm starting the second attempt to solve this here. Since the code has been changed, I don't want to update the original question. It would be a too big update and the answers might perhaps not match to the new version of the question. So I'm formulating it as a new one:

I'm writing functional tests for a Zend Framework 3 application by using

  • zendframework/zend-test 3.2.2,
  • phpunit/phpunit 6.5.14, and
  • phpunit/dbunit 3.0.3

The most of the tests are a kind of controller tests. The test code calls a URI / an action by using the Zend\Test\PHPUnit\Controller\AbstractControllerTestCase#dispatch(...) and analyzes 1. the response / output data and 2. the changes at the database (if it was a writing call like "create foo"), e.g.:

/**
...
* @dataProvider provideDataForShowOrderAccess
*/
public function testShowOrderAccess(string $username, int $responseStatusCode)
{
    ...
    $this->createOrder(...);
    $this->reset();
    $_SERVER['AUTH_USER'] = $username;
    ...
    $this->dispatch($showUrl);
    $this->assertResponseStatusCode($responseStatusCode);
}

/**
...
* @dataProvider provideDataForShowOrder
*/
public function testShowOrder(string $username, bool $isOwner)
{
    ...
    $this->createOrder($connectionType, $endpointSourceType);
    $this->reset();
    $_SERVER['AUTH_USER'] = $username;

    // testing the access by the owner
    $orderId = 1;
    $showUrl = '/order/show/' . $orderId;
    $this->dispatch($showUrl);

    if ($isOwner) {
        $this->assertResponseStatusCode(Response::STATUS_CODE_200);
        $this->assertModuleName('Order');
        $this->assertControllerName('Order\Controller\Process');
        $this->assertControllerClass('ProcessController');
        $this->assertMatchedRouteName('order/show');

        /** @var Foo $foo */
        $foo = $this->getApplication()->getMvcEvent()->getResult()->getVariable('foo', null);

        $fooData = $createParams['foo'];
        $barData = $barData['bar'];

        $this->assertNotNull($bar);
        $this->assertInstanceOf(Foo::class, $foo);
        $this->assertEquals($orderId, $foo->getId());
        $this->assertEquals($fooData['bar'], $foo->getBar());
        ...
    } else {
        $this->assertResponseStatusCode(Response::STATUS_CODE_302);
    }
}

For every single test the database gets reset.

The problem is, that the number of database connections is growing and growing and growing -- with every next test. Currently there are about 350 (SHOW GLOBAL STATUS LIKE 'max_used_connections';) connections for 102 tests. (As a workaround I have to increase the MySQL's max_connections more and more.)

I tried to decrease the number of connections by putting logic like $this->dbAdapter->getDriver()->getConnection()->disconnect(); or/and $this->entityManager->getConnection()->close(); to the tearDown() of my super-class for controller tests. This way I got the number of the connections reduced by about 90. But the most of the connections still don't get killed.

How to close DB connections and significantly reduce the number of concurrently open connections in functional / controller PHPUnit tests for a ZF3 application?


Additional information: the most relevant parts of my code

enter image description here

AbstractControllerTest

namespace Base\Test;

use Doctrine\ORM\EntityManager;
use PDO;
use PHPUnit\DbUnit\Database\DefaultConnection;
use Zend\Db\Adapter\Adapter;
use Zend\Db\Sql\Sql;
use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;

/**
 * Class AbstractControllerTest
 *
 * @package Base\Test
 */
abstract class AbstractControllerTest extends AbstractHttpControllerTestCase
{

    use DatabaseConnectionTrait;

    /**
     * @var string
     */
    static private $applicationConfigPath;

    /** @var Adapter */
    protected $dbAdapter;

    /** @var EntityManager */
    protected $entityManager;

    public function __construct($name = null, array $data = [], $dataName = '')
    {
        parent::__construct($name, $data, $dataName);
        $this->setApplicationConfig(include self::$applicationConfigPath);
    }

    public static function setApplicationConfigPath(string $applicationConfigPath)
    {
        self::$applicationConfigPath = $applicationConfigPath;
    }

    protected function tearDown()
    {
        // Connections: 354
        // Time: 5.7 minutes, Memory: 622.00MB
        // OK (102 tests, 367 assertions)
        // no optimization

        // Connections: 326 (26 connections less)
        // Time: 5.86 minutes, Memory: 620.00MB
        // OK (102 tests, 367 assertions)
        // if ($this->dbAdapter && $this->dbAdapter instanceof Adapter) {
        //     $this->dbAdapter->getDriver()->getConnection()->disconnect();
        // }

        // Connections: 354
        // Time: 5.67 minutes, Memory: 620.00MB
        // OK (102 tests, 367 assertions)
        // $this->entityManager->close();

        // Connections: 291 (63 connections less)
        // Time: 5.63 minutes, Memory: 622.00MB
        // OK (102 tests, 367 assertions)
        // $this->entityManager->getConnection()->close();

        // Connections: 264 (90 connections less)
        // Time: 5.7 minutes, Memory: 620.00MB
        // OK (102 tests, 367 assertions)
        // if ($this->dbAdapter && $this->dbAdapter instanceof Adapter) {
        //     $this->dbAdapter->getDriver()->getConnection()->disconnect();
        // }
        // $this->entityManager->getConnection()->close();

        // Connections: 251
        // Time: 4.71 minutes, Memory: 574.00MB
        // OK (102 tests, 367 assertions)
        // After removing initialization of the EntityManager and the DbAdapter in the constructor and the setUp().

        // closing DB connections
        if ($this->dbAdapter && $this->dbAdapter instanceof Adapter) {
            $this->dbAdapter->getDriver()->getConnection()->disconnect();
        }
        if ($this->entityManager && $this->entityManager instanceof EntityManager) {
            $this->entityManager->getConnection()->close();
        }
        $reflectionObject = new \ReflectionObject($this);
        foreach ($reflectionObject->getProperties() as $prop) {
            if (!$prop->isStatic() && 0 !== strpos($prop->getDeclaringClass()->getName(), 'PHPUnit_')) {
                $prop->setAccessible(true);
                $prop->setValue($this, null);
            }
        }

        $this->reset();
        $this->application = null;
        gc_collect_cycles();

        unset($_SERVER['AUTH_USER']);

        parent::tearDown();
    }

    protected function retrieveActualData($table, $idColumn, $idValue)
    {
        $sql = new Sql($this->getDbAdapter());
        $select = $sql->select($table);
        $select->where([$table . '.' . $idColumn . ' = ?' => $idValue]);
        $statement = $sql->prepareStatementForSqlObject($select);
        $result = $statement->execute();
        $data = $result->current();
        // Decreases the total number of the connections by 1 less.
        // $this->dbAdapter->getDriver()->getConnection()->disconnect();
        return $data;
    }

    protected function getEntityManager()
    {
        $this->entityManager = $this->entityManager
            ?: $this->getApplicationServiceLocator()->get('doctrine.entitymanager.orm_default')
        ;
        return $this->entityManager;
    }

    protected function getDbAdapter()
    {
        $this->dbAdapter = $this->dbAdapter
            ?: $this->getApplicationServiceLocator()->get('Zend\Db\Adapter\Adapter')
        ;
        return $this->dbAdapter;
    }

}

DatabaseConnectionTrait

namespace Base\Test;

use PDO;
use PHPUnit\DbUnit\Database\Connection;
use PHPUnit\DbUnit\Database\DefaultConnection;
use PHPUnit\DbUnit\InvalidArgumentException;

trait DatabaseConnectionTrait
{

    /**
     * @var array
     */
    static private $dbConfigs;
    /**
     * @var PDO
     */
    static private $pdo;
    /**
     * @var Connection
     */
    private $connection;

    public function __construct($name = null, array $data = [], $dataName = '')
    {
        parent::__construct($name, $data, $dataName);
    }

    /**
     * @return Connection
     */
    public function getConnection()
    {
        if (! $this->connection) {
            if (! self::$dbConfigs) {
                throw new InvalidArgumentException(
                    'Set the database configuration first.'
                    . ' '. 'Use the ' . self::class . '::setDbConfigs(...).'
                );
            }
            if (! self::$pdo) {
                self::$pdo = new PDO(
                    self::$dbConfigs['dsn'],
                    self::$dbConfigs['username'],
                    self::$dbConfigs['password'],
                    [PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES \'UTF8\'']
                );
            }
            $this->connection = $this->createDefaultDBConnection(self::$pdo, self::$dbConfigs['database']);
        }
        return $this->connection;
    }

    public static function setDbConfigs(array $dbConfigs)
    {
        self::$dbConfigs = $dbConfigs;
    }

    /**
     * Creates a new DefaultDatabaseConnection using the given PDO connection
     * and database schema name.
     *
     * @see The original PHPUnit\DbUnit\TestCaseTrait#createDefaultDBConnection(...).
     *
     * @param PDO    $connection
     * @param string $schema
     *
     * @return DefaultConnection
     */
    protected function createDefaultDBConnection(PDO $connection, $schema = '')
    {
        return new DefaultConnection($connection, $schema);
    }

}

DatabaseInitializer

namespace Base\Test;

/**
 * Class DatabaseInitializer
 *
 * @package Base\Test
 */
class DatabaseInitializer
{

    use DatabaseConnectionTrait;

    /**
     * @var string
     */
    private $database;

    public function __construct(array $dbConfigs)
    {
        $this->database = $dbConfigs['database'];
        self::$dbConfigs = $dbConfigs;
    }

    public function setUp()
    {
        $schemaSql = file_get_contents(self::$dbConfigs['scripts']['schema']);
        $storedProceduresSql = file_get_contents(self::$dbConfigs['scripts']['stored-procedures']);
        $basicDataSql = file_get_contents(self::$dbConfigs['scripts']['basic-data']);
        $testDataSqlSet = array_map(function ($sqlFile) {
            return file_get_contents($sqlFile);
        }, self::$dbConfigs['scripts']['test-data']);

        $this->dropDatabase();
        $this->createDatabase();
        $this->useDatabase();
        $this->createSchema($schemaSql);
        $this->createStoredProcedures($storedProceduresSql);
        $this->createBasicData($basicDataSql);
        $this->createTestData($testDataSqlSet);
    }

    public function tearDown()
    {
        self::$pdo = null;
    }

    protected function createDatabase()
    {
        $this->getDatabaseConnection()->exec('CREATE DATABASE IF NOT EXISTS ' . $this->database . ';');
    }

    protected function useDatabase()
    {
        $this->getDatabaseConnection()->exec('USE ' . $this->database . ';');
    }

    protected function createSchema(string $sql)
    {
        $this->getDatabaseConnection()->exec($sql);
    }

    protected function createBasicData(string $sql)
    {
        $this->getDatabaseConnection()->exec($sql);
    }

    protected function createTestData(array $sqlSet = [])
    {
        foreach ($sqlSet as $sql) {
            $this->getDatabaseConnection()->exec($sql);
        }
    }

    protected function createStoredProcedures(string $sql)
    {
        $statement = $this->getDatabaseConnection()->prepare($sql);
        $statement->execute();
    }

    protected function dropDatabase()
    {
        $this->getDatabaseConnection()->exec('DROP DATABASE IF EXISTS ' . $this->database . ';');
    }

    protected function getDatabaseConnection()
    {
        return $this->getConnection()->getConnection();
    }
}

Bootstrap

namespace Test;

use Base\Test\AbstractControllerTest;
use Base\Test\AbstractDbTest;
use Base\Test\DatabaseInitializer;
use Doctrine\ORM\EntityManager;
use RuntimeException;
use Zend\Loader\AutoloaderFactory;
use Zend\Mvc\Service\ServiceManagerConfig;
use Zend\ServiceManager\ServiceManager;

error_reporting(E_ALL | E_STRICT);
ini_set('memory_limit', '2048M');
chdir(__DIR__);

/**
 * Sets up the MVC (application, service manager, autoloading) and the database.
 */
class Bootstrap
{

    /** @var ServiceManager */
    protected $serviceManager;

    protected $applicationConfigPath;

    /** @var EntityManager */
    protected $entityManager;

    public function __construct()
    {
        $this->applicationConfigPath = __DIR__ . '/../config/application.config.php';
    }

    /**
     * Sets up the
     */
    public function init()
    {
        // autoloading setup
        static::initAutoloader();
        // application configuration & setup
        $applicationConfig = require_once $this->applicationConfigPath;
        $this->prepareApplication($applicationConfig);
        // database configuration & setup
        $dbConfigs = $this->serviceManager->get('Config')['db'];
        $this->setUpDatabase($dbConfigs);
        // listeners & application bootstrap
        $listeners = $this->prepareListeners();
        $this->bootstrapApplication($listeners);
    }

    public function chroot()
    {
        $rootPath = dirname(static::findParentPath('module'));
        chdir($rootPath);
    }

    protected function prepareApplication($config)
    {
        $serviceManagerConfig = isset($config['service_manager']) ? $config['service_manager'] : [];
        $serviceManagerConfigObject = new ServiceManagerConfig($serviceManagerConfig);
        $this->serviceManager = new ServiceManager();
        $serviceManagerConfigObject->configureServiceManager($this->serviceManager);
        $this->serviceManager->setService('ApplicationConfig', $config);
        $this->serviceManager->get('ModuleManager')->loadModules();
    }

    protected function prepareListeners()
    {
        $listenersFromAppConfig     = [];
        $config                     = $this->serviceManager->get('config');
        $listenersFromConfigService = isset($config['listeners']) ? $config['listeners'] : [];
        $listeners = array_unique(array_merge($listenersFromConfigService, $listenersFromAppConfig));
        return $listeners;
    }

    protected function bootstrapApplication($listeners)
    {
        $application = $this->serviceManager->get('Application');
        $application->bootstrap($listeners);
    }

    protected function setUpDatabase(array $dbConfigs)
    {
        $databaseInitializer = new DatabaseInitializer($dbConfigs);
        $databaseInitializer->setUp();
        AbstractDbTest::setDbConfigs($dbConfigs);
        AbstractControllerTest::setApplicationConfigPath($this->applicationConfigPath);
        AbstractControllerTest::setDbConfigs($dbConfigs);
    }

    protected function initAutoloader()
    {
        $vendorPath = static::findParentPath('vendor');

        if (file_exists($vendorPath.'/autoload.php')) {
            include $vendorPath.'/autoload.php';
        }

        if (! class_exists('Zend\Loader\AutoloaderFactory')) {
            throw new RuntimeException(
                'Unable to load ZF2. Run `php composer.phar install`'
            );
        }

        AutoloaderFactory::factory(array(
            'Zend\Loader\StandardAutoloader' => array(
                'autoregister_zf' => true,
                'namespaces' => array(
                    __NAMESPACE__ => __DIR__,
                ),
            ),
        ));
    }

    protected function findParentPath($path)
    {
        $dir = __DIR__;
        $previousDir = '.';
        while (!is_dir($dir . '/' . $path)) {
            $dir = dirname($dir);
            if ($previousDir === $dir) {
                return false;
            }
            $previousDir = $dir;
        }
        return $dir . '/' . $path;
    }

}

$bootstrap = new Bootstrap();
$bootstrap->init();
$bootstrap->chroot();
automatix
  • 14,018
  • 26
  • 105
  • 230
  • As far as I understand each test instantiates new instance of entity manager. Could you reuse it across all tests? – xmike Aug 20 '19 at 07:45
  • Thanks for your comment! I added a `static` variable `$em` and replaced the line `$this->entityManager = $this->getApplicationServiceLocator()->get('doctrine.entitymanager.orm_default');` in the `AbstractControllerTest`'s constructor by following: `self::$em = self::$em ?: $this->getApplicationServiceLocator()->get('doctrine.entitymanager.orm_default'); $this->entityManager = self::$em;` I also set a debugging breakpoint and checked, whether the `self::$em` is actually being reused -- yes, it is. But this change hasn't affected the number of the DB connections. – automatix Aug 20 '19 at 15:34
  • ok, but the application code still gets a fresh instance of entity manager from service locator as I guess, is there a possibility for you to substitute it in locator, too? that was the intent of my comment actually, I should've mentioned that (although I've been doing so in a Symfony based project, I am not sure clean way exists for zend... well, if not -- try some hack way ))) ) – xmike Aug 20 '19 at 16:45
  • I'm not sure, if I'm getting you correctly. You mean using the same EntityManager instance across (HTTP) requests? I think, it's not possible, since the ServiceLocator gets instantiated every time on application bootstrap. Well, I could cache the EntityManager, but it's a way too hacky and might also have some side effects. – automatix Aug 20 '19 at 19:41
  • As I wrote in the question, my tests make a lot of requests to the application. You mean, the main issue is, that on every application request an EntityManager is created and that it doesn't get destroyed after the request processing has been completed? But in this case every ZF application would generate tons of PDO connections and could not work at all. – automatix Aug 20 '19 at 19:44
  • Speaking from no experience whatsoever of the problem here (feel free to tell me if I'm way off) but, in your tear down method couldn't you just close the connection manually? Or am I being too naive in thinking closing a connection would thus free up a slot for the next test :o/ ? – Crisp Aug 21 '19 at 17:55
  • @Crisp Thanks for your comment! Yes, basically this works. Please see the commented code in my `AbstractControllerTest#tearDown()` in the question. It's actually some variants of killig the connection in the `tearDown()`. The most effective of these variants is the combination of `$this->dbAdapter->getDriver()->getConnection()->disconnect();` and `$this->entityManager->getConnection()->close();`. In my case it reduced the number of the connections from `354` to `264`. Not so bad. But `264` DB connections is still a huge number. And I don't find any way for further reducing of this value. – automatix Aug 21 '19 at 19:49
  • @automatix, thanks, I see that now. Just a(nother) thought, but have you tried PDO's `\PDO::ATTR_PERSISTENT` setting as a custom driver option for doctrine? – Crisp Aug 22 '19 at 16:27
  • @Crisp Thanks, yes I tried this out and I've jjsut tried this out again and set this option at both places -- for the application itself and in the test configs. But it haven't effected the number of connections. – automatix Aug 22 '19 at 20:34

1 Answers1

0

Since we don't see your controller, your repositories etc. we can't say you "there' mistake". Normally, you should make your connection for database test on setUp method, and destroy it on tearDown method. And you shouldn't start connection for application. Application should start that connection when it needs. Since you're using Doctrine, it does that. Why you're creating/calling EntityManager before tests?

What happens if you run only one test instead whole testcase? How many connections happens? Maybe you're making a mistake in a controller? In a repository maybe?

Here what i see when i look that code;

  • I couldn't see where initializer is using. Where do you using it? I didn't like that methods in it.
  • Your both DatabaseConnectionTrait and AbstractControllerTest classes have _construct method. If a class overwrites a method which is defined in trait, class' method works. Why trait has that method?
  • Are you sure you're not doing same thing over and over again?getDatabaseConnection, getConnection, $this->getConnection()->getConnection();
  • Where you define static $em property?
  • unset that reflection object when you done with it. I know it's easy and faster way to desctruct all properties, but you should consider to do it yourself. It'll be better way to manage your ram.
  • Why you trying to destruct all properties of class in tearDown method with reflection? Destroy connection and let php manage garbage.
  • Why you destroy database on tearDown method? If you provide your test-data to phpunit correctly, you don't need that. Check tearDownOperation of DbUnit.

I suggest you run that tests one by one instead whole test-case and check connection counts. Also you should test your Repositories with DbUnit to be sure they're working right. Since Repositories responsible on database, maybe that connection count increasing on them.

  • Thank you very much for your answer! In the following comments I'm going to give more details to my code and answer your questions: – automatix Aug 26 '19 at 17:04
  • **_Normally, you should make your connection for database test on `setUp` method [...] Why you're creating/calling EntityManager before tests?_** Yes, creating connections in the constructor is not needed. I moved this from the constructor to the `setUp()`. But even in the `setUp` it's not really needed. So now I'm creating the connection, when and only if it's needed -- directly in the getter. (See the updated code in the qusteion.) This change decreased the total number of the connections from `263` to `251` -- not much, but still an improvement. Thank you for the hint! – automatix Aug 26 '19 at 17:04
  • **_What happens if you run only one test instead whole testcase? How many connections happens?_** and **_I suggest you run that tests one by one instead whole test-case and check connection counts._** The number of the connections is growing more or less proportionally to the number of the test cases and app requests in them. The most connections are generated by the tests with many cases (multiple variants and usage of `DataProvider`s) and requests to the (controllers of the) app. – automatix Aug 26 '19 at 17:04
  • **_I couldn't see where initializer is using. Where do you using it?_** The `DatabaseInitializer` is used in the `Bootstrap` (just added to the question) and is also used in the `setUp()` methods of many tests in order to reset/(re-)initialize the database. – automatix Aug 26 '19 at 17:04
  • **_Why trait has that [`__construct(...)`] method?_** I've just checked my code. The trait is also used in my `AbstrctDbTest`, that `extends` the `PHPUnit\DbUnit\TestCase`. I don' remember anymore, why I defined this constructor in the trait. But it seems, that it's not need / obsolete and can be remoed. Thanks! – automatix Aug 26 '19 at 17:05
  • **_Are you sure you're not doing same thing over and over again? [...] `$this->getConnection()->getConnection();`_** Just checked. No, it should not be the problem. This call in the `DatabaseInitializer#getDatabaseConnection()` goes in the first step to the `DatabaseConnectionTrait#getConnection()`, that returns an instance of `PHPUnit\DbUnit\Database\Connection`. It's an interface with one implementation -- the `PHPUnit\DbUnit\Database\DefaultConnection`. The `DefaultConnection#getConnection()` (the second and last call in the chain) simply returns the `DefaultConnection#$connection`'s value. – automatix Aug 26 '19 at 17:07
  • **_Where you define static `$em` property?_** Do you mean my [comment](https://stackoverflow.com/questions/57558028/how-to-close-db-connections-and-reduce-their-number-in-functional-phpunit-tests/57599312#comment101614351_57558028)? No, I don't define that. I tried this out, but rolled my changes back, since it doesn't affected the number of connections. – automatix Aug 26 '19 at 17:07
  • **_`unset` that reflection object when you done with it_** I've just tried this out, but this change haven't affected the RAM. – automatix Aug 26 '19 at 17:07
  • **_Why you trying to destruct all properties of class in `tearDown` method with reflection?_** and **_Why you destroy database on `tearDown` method?_** I started doing that, when I got this issue with the number of connections. Just to be sure and exclude some cases. But you are right, it's not needed. – automatix Aug 26 '19 at 17:07
  • i remember last time when i build a scalable app. It growed really mad to multiple tenant e-commerce system than single user one. When i was writing it, i did things like that; First of first, i tested all methods i 've in repositories with DBUnit. When i'm sure everything works well, i started to do controller tests with DBunit also. Actually i didn't check how many connections i'm using, but since i was working on someone else's server, i assume it's not high as much as this. – Mehmet SÖĞÜNMEZ Aug 26 '19 at 17:18