From 4c661d96f43907b94194450efc98d2a236069262 Mon Sep 17 00:00:00 2001 From: Aaron Gustavo Nieves <64917965+TavoNiievez@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:33:37 -0500 Subject: [PATCH 1/3] Add grabEntityManager() to reach the Doctrine EntityManager _getEntityManager() is underscore-prefixed, so ModuleContainer excludes it from the Actor and it cannot be called as $I->_getEntityManager(). Reaching the EntityManager from a test therefore meant a getModule('Symfony') reach-through, a grabService() call with a hardcoded service id, or a custom Helper module. grabEntityManager() exposes it on the Actor. It delegates to _getEntityManager(), so it keeps resolving the manager from the current container on every call and honours the em_service option. Extract the service resolution into resolveEntityManager(), which also improves the failure message: the previous one reported "is not an instance of EntityManagerInterface" both when the service was missing entirely and when it was the wrong type, without hinting that doctrine-bundle might not be installed or that em_service might be misconfigured. --- src/Codeception/Module/Symfony.php | 7 +-- .../Symfony/DoctrineAssertionsTrait.php | 44 +++++++++++++++++++ tests/DoctrineAssertionsTest.php | 5 +++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index 099f249f..b31b6175 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -300,12 +300,7 @@ public function _getEntityManager(): EntityManagerInterface } } - $em = $this->getService($emService); - if (!$em instanceof EntityManagerInterface) { - Assert::fail(sprintf('Service "%s" is not an instance of EntityManagerInterface.', $emService)); - } - - return $em; + return $this->resolveEntityManager($emService); } protected function getClient(): SymfonyConnector diff --git a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php index 110d6814..15d8c63b 100644 --- a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php @@ -4,6 +4,7 @@ namespace Codeception\Module\Symfony; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Tools\SchemaValidator; use PHPUnit\Framework\Assert; @@ -53,6 +54,28 @@ public function dontSeeDuplicateQueries(): void ); } + /** + * Returns the Doctrine EntityManager the module is configured to use: + * the `em_service` option, `doctrine.orm.entity_manager` by default. + * + * The manager is resolved from the container on every call, so it always belongs to the + * kernel that is currently booted. Don't keep it in a property across requests: + * [`amOnPage()`](#amOnPage) and friends reboot the kernel, which builds a new manager. + * + * To reach a manager other than the configured one, grab it by service id: + * `$I->grabService('doctrine.orm.other_entity_manager')`. + * + * ```php + * grabEntityManager(); + * $user = $em->getRepository(User::class)->findOneBy(['email' => 'john_doe@gmail.com']); + * ``` + */ + public function grabEntityManager(): EntityManagerInterface + { + return $this->_getEntityManager(); + } + /** * Returns the number of rows that match the given criteria for the * specified Doctrine entity. @@ -244,4 +267,25 @@ private function isTransactionStatement(string $sql): bool { return preg_match('/^\s*("|`)?(START\s+TRANSACTION|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b/i', $sql) === 1; } + + /** + * Resolves the configured entity manager service, failing with a hint + * instead of a type error when Doctrine isn't wired up. + * + * @param non-empty-string $serviceId + */ + private function resolveEntityManager(string $serviceId): EntityManagerInterface + { + $em = $this->getService($serviceId); + + if (!$em instanceof EntityManagerInterface) { + Assert::fail(sprintf( + "The '%s' service is not a Doctrine EntityManager.\n" + . "Install and enable doctrine/doctrine-bundle, or point the module's 'em_service' option at your entity manager.", + $serviceId + )); + } + + return $em; + } } diff --git a/tests/DoctrineAssertionsTest.php b/tests/DoctrineAssertionsTest.php index 6b2c720b..8131e7d3 100644 --- a/tests/DoctrineAssertionsTest.php +++ b/tests/DoctrineAssertionsTest.php @@ -30,6 +30,11 @@ public function testDontSeeDuplicateQueriesDetectsDuplicates(): void $this->dontSeeDuplicateQueries(); } + public function testGrabEntityManager(): void + { + $this->assertSame($this->_getEntityManager(), $this->grabEntityManager()); + } + public function testGrabNumRecords(): void { $this->assertSame(1, $this->grabNumRecords(User::class)); From 86613cfa2b808331826885a2a6c0142fef212ca9 Mon Sep 17 00:00:00 2001 From: Aaron Gustavo Nieves <64917965+TavoNiievez@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:37:07 -0500 Subject: [PATCH 2/3] Recover from a closed Doctrine EntityManager Doctrine closes the EntityManager when an exception escapes a flush(), and every write after that throws EntityManagerClosed. Users worked around this by shipping an EntityManagerReset helper module that resets the manager in _before; it is the most copy-pasted workaround in the dependent ecosystem. Add resetDoctrineManager() as the supported replacement: an open manager is cleared, a closed one is rebuilt through Doctrine's registry, which swaps the lazy service in place so the application and the Doctrine module see the reopened manager too. When the registry cannot do it, because the manager service is not lazy on Symfony 5.4 or the app has no DoctrineBundle, the client kernel is rebooted instead. The DBAL connection stays a permanent service either way, so the open test transaction survives. _getEntityManager() applies the same registry recovery before handing the manager out, so a single broken write no longer cascades through the rest of the test. Nothing is cached; the container remains the source of truth. The fixture app wires Doctrine by hand and had no registry to reset through, so add a minimal TestManagerRegistry, rebuild the manager on the existing connection (the fixture database is in-memory, reconnecting would drop the schema), and stop sharing the entity manager service so the container hands out the rebuilt one. --- src/Codeception/Module/Symfony.php | 13 ++- .../Symfony/DoctrineAssertionsTrait.php | 79 +++++++++++++++++++ tests/DoctrineAssertionsTest.php | 19 +++++ tests/_app/Doctrine/DoctrineSetup.php | 43 +++++++--- tests/_app/Doctrine/TestManagerRegistry.php | 48 +++++++++++ tests/_app/config/services.php | 5 +- 6 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 tests/_app/Doctrine/TestManagerRegistry.php diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index b31b6175..eea14e40 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -286,6 +286,11 @@ protected function onReconfigure(array $settings = []): void * persistent: that preserves the open test transaction across reboots while * the freshly rebuilt EntityManager runs on top of it. * + * If the resolved EntityManager was closed by a failed flush, it is reopened through + * Doctrine's registry before being handed out, so one broken write does not cascade + * through the rest of the test. Nothing is cached: the container stays the single + * source of truth. + * * @see https://github.com/Codeception/module-symfony/issues/34 */ public function _getEntityManager(): EntityManagerInterface @@ -300,7 +305,13 @@ public function _getEntityManager(): EntityManagerInterface } } - return $this->resolveEntityManager($emService); + $em = $this->resolveEntityManager($emService); + + if (!$em->isOpen() && $this->resetManagerThroughRegistry(null)) { + $em = $this->resolveEntityManager($emService); + } + + return $em; } protected function getClient(): SymfonyConnector diff --git a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php index 15d8c63b..b1397b4f 100644 --- a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php @@ -7,8 +7,10 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Tools\SchemaValidator; +use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\Assert; use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector; +use Throwable; use function array_count_values; use function array_filter; @@ -158,6 +160,53 @@ public function seeNumQueriesIsLessThan(int $expectedCount): void ); } + /** + * Resets the Doctrine EntityManager. + * + * Doctrine closes the EntityManager as soon as an exception escapes a `flush()`: + * a unique constraint violation, a deadlock, a failed transaction. Every write after + * that throws `EntityManagerClosed`, which usually surfaces as an unrelated failure + * further down the test. Call this after deliberately provoking such an error to carry + * on with a healthy manager. + * + * If the manager is still open it is only cleared, detaching every managed entity, + * which is handy to prove that the next read really hits the database. + * The open test transaction is preserved either way: the manager is rebuilt, + * the DBAL connection underneath it is not. + * + * ```php + * amOnPage('/register'); + * $I->resetDoctrineManager(); + * $I->seeNumRecords(1, User::class); + * ``` + * + * @param non-empty-string|null $name Manager name as registered in Doctrine's registry, + * `null` for the default one. + */ + public function resetDoctrineManager(?string $name = null): void + { + $em = $this->_getEntityManager(); + + if ($em->isOpen()) { + $em->clear(); + + return; + } + + if (!$this->resetManagerThroughRegistry($name) || !$this->_getEntityManager()->isOpen()) { + $this->rebootClientKernel(); + } + + if (!$this->_getEntityManager()->isOpen()) { + Assert::fail( + "The Doctrine EntityManager is still closed after resetting it.\n" + . "Check that the module's 'em_service' option points at your entity manager " + . 'and that the container can rebuild it.' + ); + } + } + /** * Asserts that a given number of records exists for the entity. * 'id' is the default search parameter. @@ -268,6 +317,36 @@ private function isTransactionStatement(string $sql): bool return preg_match('/^\s*("|`)?(START\s+TRANSACTION|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE\s+SAVEPOINT)\b/i', $sql) === 1; } + /** + * Rebuilds the manager through Doctrine's registry, which swaps the lazy service in + * place: everything already holding the manager, the application and the Doctrine + * module included, sees the reopened one. + * + * Returns false when the app has no registry, or when the manager service is not lazy + * and therefore cannot be reset that way. + * + * @param non-empty-string|null $name + */ + private function resetManagerThroughRegistry(?string $name): bool + { + if (!interface_exists(ManagerRegistry::class)) { + return false; + } + + $registry = $this->getService('doctrine'); + if (!$registry instanceof ManagerRegistry) { + return false; + } + + try { + $registry->resetManager($name); + } catch (Throwable) { + return false; + } + + return true; + } + /** * Resolves the configured entity manager service, failing with a hint * instead of a type error when Doctrine isn't wired up. diff --git a/tests/DoctrineAssertionsTest.php b/tests/DoctrineAssertionsTest.php index 8131e7d3..43b343e7 100644 --- a/tests/DoctrineAssertionsTest.php +++ b/tests/DoctrineAssertionsTest.php @@ -6,6 +6,7 @@ use Codeception\Module\Symfony\DoctrineAssertionsTrait; use PHPUnit\Framework\AssertionFailedError; +use Tests\App\Doctrine\TestManagerRegistry; use Tests\App\Entity\User; use Tests\App\Repository\UserRepository; use Tests\App\Repository\UserRepositoryInterface; @@ -55,6 +56,24 @@ public function testSeeNumQueriesIsLessThan(): void $this->seeNumQueriesIsLessThan(3); } + public function testResetDoctrineManager(): void + { + $em = $this->grabEntityManager(); + $user = $em->getRepository(User::class)->findOneBy(['email' => 'john_doe@gmail.com']); + $this->assertTrue($em->contains($user)); + + $this->resetDoctrineManager(); + $this->assertFalse($this->grabEntityManager()->contains($user)); + + /** @var TestManagerRegistry $registry */ + $registry = $this->grabService('doctrine'); + $this->grabEntityManager()->close(); + $this->resetDoctrineManager(); + + $this->assertSame(1, $registry->resets); + $this->assertTrue($this->grabEntityManager()->isOpen()); + } + public function testSeeNumRecords(): void { $this->seeNumRecords(1, User::class); diff --git a/tests/_app/Doctrine/DoctrineSetup.php b/tests/_app/Doctrine/DoctrineSetup.php index 70327410..73d31437 100644 --- a/tests/_app/Doctrine/DoctrineSetup.php +++ b/tests/_app/Doctrine/DoctrineSetup.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; +use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ORMSetup; @@ -21,10 +22,40 @@ public static function createConnection(): Connection public static function createEntityManager(): EntityManagerInterface { - if (self::$entityManager !== null && self::$entityManager->isOpen()) { + if (self::$entityManager !== null) { return self::$entityManager; } + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + + return self::$entityManager = self::makeEntityManager($connection); + } + + /** + * Rebuilds the manager on the same connection, mirroring what + * ManagerRegistry::resetManager() does for a lazy service. Reusing the connection + * matters here: the fixture database is in-memory, so reconnecting would lose the schema. + */ + public static function resetEntityManager(): void + { + $connection = self::createEntityManager()->getConnection(); + + self::$entityManager = self::makeEntityManager($connection); + } + + private static function makeEntityManager(Connection $connection): EntityManagerInterface + { + $config = self::createConfiguration(); + + if (method_exists(EntityManager::class, 'create')) { + return EntityManager::create($connection, $config); + } + + return new EntityManager($connection, $config); + } + + private static function createConfiguration(): Configuration + { $entityDir = dirname(__DIR__) . '/Entity'; if (method_exists(ORMSetup::class, 'createAttributeMetadataConfig')) { @@ -46,14 +77,6 @@ public static function createEntityManager(): EntityManagerInterface $config->enableNativeLazyObjects(true); } - $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); - - if (method_exists(EntityManager::class, 'create')) { - self::$entityManager = EntityManager::create($connection, $config); - } else { - self::$entityManager = new EntityManager($connection, $config); - } - - return self::$entityManager; + return $config; } } diff --git a/tests/_app/Doctrine/TestManagerRegistry.php b/tests/_app/Doctrine/TestManagerRegistry.php new file mode 100644 index 00000000..803348dc --- /dev/null +++ b/tests/_app/Doctrine/TestManagerRegistry.php @@ -0,0 +1,48 @@ + 'doctrine.dbal.default_connection'], + ['default' => 'doctrine.orm.entity_manager'], + 'default', + 'default', + Proxy::class + ); + } + + protected function getService(string $name): object + { + if ($name === 'doctrine.dbal.default_connection') { + return DoctrineSetup::createConnection(); + } + + return DoctrineSetup::createEntityManager(); + } + + protected function resetService(string $name): void + { + ++$this->resets; + + DoctrineSetup::resetEntityManager(); + } +} diff --git a/tests/_app/config/services.php b/tests/_app/config/services.php index ad61e17c..d448d196 100644 --- a/tests/_app/config/services.php +++ b/tests/_app/config/services.php @@ -19,6 +19,7 @@ use Tests\App\Controller\AppController; use Tests\App\Doctrine\DbDataCollector; use Tests\App\Doctrine\DoctrineSetup; +use Tests\App\Doctrine\TestManagerRegistry; use Tests\App\Entity\User; use Tests\App\Event\TestEvent; use Tests\App\HttpClient\MockResponseFactory; @@ -48,10 +49,12 @@ ->tag('data_collector', ['id' => 'db', 'template' => '@WebProfiler/Collector/db.html.twig', 'priority' => 250]); $services->set('doctrine.orm.entity_manager', EntityManagerInterface::class) - ->factory([DoctrineSetup::class, 'createEntityManager']); + ->factory([DoctrineSetup::class, 'createEntityManager']) + ->share(false); $services->alias('doctrine.orm.default_entity_manager', 'doctrine.orm.entity_manager')->public(); $services->set('doctrine.dbal.default_connection', Connection::class) ->factory([DoctrineSetup::class, 'createConnection']); + $services->set('doctrine', TestManagerRegistry::class); $services->set(UserRepository::class) ->factory([service('doctrine.orm.entity_manager'), 'getRepository']) From b85451519280d8d36410abbdcdebf72f11fdfe64 Mon Sep 17 00:00:00 2001 From: Aaron Gustavo Nieves <64917965+TavoNiievez@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:38:21 -0500 Subject: [PATCH 3/3] Add grabContainer() to reach the service container _getContainer() is underscore-prefixed, so ModuleContainer excludes it from the Actor. Tests that need the container reached it through a getModule('Symfony') call in a custom Helper, or wrote $I->grabService('kernel')->getContainer(). That second form is subtly wrong: the kernel exposes the application container, which cannot see private services, while _getContainer() returns Symfony's test.service_container, which can. grabContainer() puts the correct one on the Actor, tagged as part of the services part alongside grabService(). --- src/Codeception/Module/Symfony.php | 1 + .../Symfony/ServicesAssertionsTrait.php | 25 +++++++++++++++++++ tests/ServicesAssertionsTest.php | 8 ++++++ 3 files changed, 34 insertions(+) diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index eea14e40..e4ef4b89 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -110,6 +110,7 @@ * ## Parts * * * `services`: Includes methods related to the Symfony dependency injection container (DIC): + * * grabContainer * * grabService * * mockService * * persistService diff --git a/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php b/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php index 77c1b3ac..347bfe84 100644 --- a/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use PHPUnit\Framework\Assert; +use Symfony\Component\DependencyInjection\ContainerInterface; trait ServicesAssertionsTrait { @@ -23,6 +24,30 @@ trait ServicesAssertionsTrait */ protected array $permanentServices = []; + /** + * Returns the Symfony dependency injection container (DIC). + * + * In the "test" environment this is Symfony's special `test.service_container`, + * so private services are reachable too, unlike + * `$I->grabService('kernel')->getContainer()`, which only ever exposes the public ones. + * + * The container belongs to the kernel that is currently booted, so grab it again after + * a request or a [`rebootClientKernel()`](#rebootClientKernel) instead of keeping it + * in a property. + * + * ```php + * grabContainer(); + * $isDebug = $container->getParameter('kernel.debug'); + * ``` + * + * @part services + */ + public function grabContainer(): ContainerInterface + { + return $this->_getContainer(); + } + /** * Grabs a service from the Symfony dependency injection container (DIC). * In the "test" environment, Symfony uses a special `test.service_container`. diff --git a/tests/ServicesAssertionsTest.php b/tests/ServicesAssertionsTest.php index 45c657b1..543b923a 100644 --- a/tests/ServicesAssertionsTest.php +++ b/tests/ServicesAssertionsTest.php @@ -13,6 +13,14 @@ final class ServicesAssertionsTest extends CodeceptTestCase { use ServicesAssertionsTrait; + public function testGrabContainer(): void + { + $container = $this->grabContainer(); + + $this->assertSame($this->_getContainer(), $container); + $this->assertTrue($container->hasParameter('app.param')); + } + public function testGrabService(): void { $this->assertIsObject($this->grabService('security.helper'));