如何在 ZF3 应用程序的功能 PHPUnit 测试中关闭数据库连接并减少它们的数量?

Posted

技术标签:

【中文标题】如何在 ZF3 应用程序的功能 PHPUnit 测试中关闭数据库连接并减少它们的数量?【英文标题】:How to close DB connections and reduce their number in functional PHPUnit tests for a ZF3 application? 【发布时间】:2019-12-24 17:59:11 【问题描述】:

几年前,我已经在 *** 上创建了一个非常相似甚至几乎相同的 question。我得到了非常详细的答案,但它们并没有解决我的问题。现在,问题变得更大,我在这里开始第二次尝试解决这个问题。由于代码已更改,我不想更新原始问题。这将是一个太大的更新,答案可能与新版本的问题不匹配。所以我将它制定为一个新的:

我正在使用 Zend Framework 3 应用程序编写功能测试

zendframework/zend-test 3.2.2, phpunit/phpunit6.5.14,和 phpunit/dbunit 3.0.3

大多数测试是一种控制器测试。测试代码通过使用Zend\Test\PHPUnit\Controller\AbstractControllerTestCase#dispatch(...) 调用一个URI / 一个动作并分析1. 响应/输出数据和2. 数据库的变化(如果是像“create foo”这样的写调用),例如:

/**
...
* @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);
    

对于每一次测试,数据库都会重置。

问题是,数据库连接的数量在不断增长——随着每次下一次测试。目前大约有350 (SHOW GLOBAL STATUS LIKE 'max_used_connections';) 连接用于102 测试。 (作为一种解决方法,我必须越来越多地增加 mysqlmax_connections。)

我试图通过将$this->dbAdapter->getDriver()->getConnection()->disconnect(); 或/和$this->entityManager->getConnection()->close(); 之类的逻辑放入我的超类的tearDown() 以进行控制器测试,从而减少连接数。这样我的连接数减少了大约90。但是大多数连接仍然没有被杀死。

如何在 ZF3 应用程序的功能/控制器 PHPUnit 测试中关闭数据库连接并显着减少同时打开的连接数?


附加信息:我的代码中最相关的部分

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();

【问题讨论】:

据我了解,每个测试都会实例化实体管理器的新实例。你能在所有测试中重复使用它吗? 感谢您的评论!我添加了一个static 变量$em 并将AbstractControllerTest 的构造函数中的$this->entityManager = $this->getApplicationServiceLocator()->get('doctrine.entitymanager.orm_default'); 行替换为:self::$em = self::$em ?: $this->getApplicationServiceLocator()->get('doctrine.entitymanager.orm_default'); $this->entityManager = self::$em; 我还设置了一个调试断点并检查了self::$em 是否实际上被重用- - 是的。但此更改并未影响数据库连接数。 好的,但是我猜应用程序代码仍然从服务定位器中获得了一个新的实体管理器实例,您是否也可以在定位器中替换它?这实际上是我评论的意图,我应该提到这一点(尽管我一直在基于 Symfony 的项目中这样做,但我不确定 zend 是否存在干净的方式......好吧,如果没有 - 尝试一些黑客方式)))) 我不确定是否正确。您的意思是跨(HTTP)请求使用相同的 EntityManager 实例?我认为,这是不可能的,因为 ServiceLocator 每次在应用程序引导时都会被实例化。好吧,我可以缓存 EntityManager,但它太老套了,而且可能还有一些副作用。 正如我在问题中所写的,我的测试向应用程序发出了很多请求。您的意思是,主要问题是,在每个应用程序请求上都会创建一个 EntityManager 并且在请求处理完成后它不会被销毁?但在这种情况下,每个 ZF 应用程序都会产生大量的 PDO 连接并且根本无法工作。 【参考方案1】:

由于我们看不到您的控制器、您的存储库等,我们不能说您“有错误”。通常,您应该在setUp 方法上建立数据库测试连接,并在tearDown 方法上销毁它。而且您不应该开始连接应用程序。应用程序应在需要时启动该连接。由于您使用的是 Doctrine,因此它可以做到这一点。为什么要在测试之前创建/调用 EntityManager?

如果你只运行一个测试而不是整个测试用例会发生什么?发生了多少连接?也许你在控制器中犯了一个错误?也许在存储库中?

当我查看该代码时,我看到了什么;

我看不到初始化程序在哪里使用。你在哪里使用它?我不喜欢其中的那些方法。 DatabaseConnectionTraitAbstractControllerTest 类都有 _construct 方法。如果一个类覆盖了 trait 中定义的方法,则类的方法有效。为什么 trait 有那个方法? 你确定你不是一遍又一遍地做同样的事情吗?getDatabaseConnection, getConnection, $this->getConnection()->getConnection(); 您在哪里定义静态$em 属性? unset 完成后的反射对象。我知道销毁所有属性是一种简单快捷的方法,但您应该考虑自己动手。这将是管理您的 ram 的更好方法。 为什么你试图用反射来破坏tearDown方法中类的所有属性?销毁连接,让 php 管理垃圾。 为什么要使用tearDown 方法销毁数据库?如果您正确地向 phpunit 提供测试数据,则不需要。检查tearDownOperationDbUnit

我建议您一个一个地运行测试,而不是整个测试用例并检查连接数。此外,您应该使用 DbUnit 测试您的存储库,以确保它们正常工作。由于存储库负责数据库,因此它们的连接数可能会增加。

【讨论】:

非常感谢您的回答!在下面的 cmets 中,我将详细介绍我的代码并回答您的问题: 通常情况下,您应该在 setUp 方法上连接数据库测试 [...] 为什么要在测试前创建/调用 EntityManager? 是的,不需要在构造函数中创建连接。我把它从构造函数移到了setUp()。但即使在setUp 中,它也不是真正需要的。所以现在我正在创建连接,当且仅在需要时——直接在 getter 中。 (请参阅 qusteion 中的更新代码。)此更改将连接总数从 263 减少到 251 - 不多,但仍然是一个改进。谢谢你的提示! 如果只运行一个测试而不是整个测试用例会发生什么?发生了多少个连接?我建议您一个一个地运行该测试,而不是整个测试用例并检查连接数。连接的增长或多或少与测试用例和应用程序请求的数量成正比。大多数连接是由具有许多情况(DataProviders 的多个变体和使用)和对应用程序(控制器)的请求的测试生成的。 我看不到初始化程序在哪里使用。您在哪里使用它? DatabaseInitializer 用于Bootstrap(刚刚添加到问题中),也用于许多测试的setUp() 方法中以重置/ (重新)初始化数据库。 为什么 trait 有那个 [__construct(...)] 方法? 我刚刚检查了我的代码。我的AbstrctDbTest 也使用了该特征,即extendsPHPUnit\DbUnit\TestCase。我不记得了,为什么我在 trait 中定义了这个构造函数。但似乎它不需要/过时并且可以删除。谢谢!

以上是关于如何在 ZF3 应用程序的功能 PHPUnit 测试中关闭数据库连接并减少它们的数量?的主要内容,如果未能解决你的问题,请参考以下文章

如何使用ZF3设置延迟加载(从任何地方都没有ServiceLocator模式)

使用 TableGateway ZF2/ZF3 编写类测试

PHPUnit 单元测试

PHPUnit中的测试方法应该如何命名

如何在 Codeception 功能测试中使用 PHPUnit 断言方法?

ZF3:设置没有布局的终端/渲染视图(Zend-Expressive)