diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index 099f249f..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 @@ -286,6 +287,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,9 +306,10 @@ public function _getEntityManager(): EntityManagerInterface } } - $em = $this->getService($emService); - if (!$em instanceof EntityManagerInterface) { - Assert::fail(sprintf('Service "%s" is not an instance of EntityManagerInterface.', $emService)); + $em = $this->resolveEntityManager($emService); + + if (!$em->isOpen() && $this->resetManagerThroughRegistry(null)) { + $em = $this->resolveEntityManager($emService); } return $em; diff --git a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php index 110d6814..b1397b4f 100644 --- a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php @@ -4,10 +4,13 @@ namespace Codeception\Module\Symfony; +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; @@ -53,6 +56,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. @@ -135,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. @@ -244,4 +316,55 @@ 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. + * + * @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/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/DoctrineAssertionsTest.php b/tests/DoctrineAssertionsTest.php index 6b2c720b..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; @@ -30,6 +31,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)); @@ -50,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/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')); 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'])