From 84c2c3af0bb59ebe9e323fd976d572ec1b61f87d Mon Sep 17 00:00:00 2001 From: Benni Mack Date: Sat, 14 Nov 2026 19:53:17 +0100 Subject: [PATCH] [FEATURE] Let test case classes opt in to sharing test instances Every functional test case class gets its own test instance: directory, database, schema and compiled dependency injection container. Setting up an instance takes about a second, a further test in it a fraction of that, so for test case classes with few tests, which is typical for extensions, setting up instances dominates the run. Test case classes carrying the new ShareInstance attribute, directly or through a parent class, are identified by their instance configuration instead of their class name. Classes configured the same way share one instance, which is set up once per process. The configuration is read from the default values of the properties that shape an instance - extensions, paths to link and provide, configuration, additional folders, database initialization - plus the classes implementing the database setup and reset, since a class overriding those relies on setting up its own instance. Assigning one of these properties at runtime fails with a clear message, because the instance has to be identified before setUp() of the class runs. When a shared instance is handed over to another test case class, the state a class may leave behind is reset: * The "core" cache group is dropped, so a TCA schema or backend module registry built by one class is not seen by the next. The compiled container in "di" is kept, it only depends on the active packages. * typo3conf/system/ is restored from a copy taken at setup, since tests write to settings.php - "configuration:set" does, for instance - and add files such as file-storages.yaml. * Database snapshots are named after the test case class, so classes sharing an instance do not restore each other's snapshot. * Database connections are closed when switching between instances, as ConnectionPool keeps them in a static property before TYPO3 v15. Test case classes without the attribute behave exactly as before: same instance identifier, same paths, same setup steps. No method changes its signature, getInstanceIdentifier() and getInstancePath() stay static. --- .../Functional/Attribute/ShareInstance.php | 55 +++++ .../Snapshot/DatabaseSnapshot.php | 19 +- .../Core/Functional/FunctionalTestCase.php | 202 +++++++++++++++++- .../Functional/Fixtures/NotSharedInstance.php | 30 +++ .../Fixtures/SharedInstanceBase.php | 32 +++ .../Fixtures/SharedInstanceFirst.php | 20 ++ .../SharedInstanceOtherExtensions.php | 23 ++ .../SharedInstanceOwnDatabaseReset.php | 25 +++ .../Fixtures/SharedInstanceSecond.php | 20 ++ .../SharedInstanceIdentifierTest.php | 53 +++++ 10 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 Classes/Core/Functional/Attribute/ShareInstance.php create mode 100644 Tests/Unit/Core/Functional/Fixtures/NotSharedInstance.php create mode 100644 Tests/Unit/Core/Functional/Fixtures/SharedInstanceBase.php create mode 100644 Tests/Unit/Core/Functional/Fixtures/SharedInstanceFirst.php create mode 100644 Tests/Unit/Core/Functional/Fixtures/SharedInstanceOtherExtensions.php create mode 100644 Tests/Unit/Core/Functional/Fixtures/SharedInstanceOwnDatabaseReset.php create mode 100644 Tests/Unit/Core/Functional/Fixtures/SharedInstanceSecond.php create mode 100644 Tests/Unit/Core/Functional/SharedInstanceIdentifierTest.php diff --git a/Classes/Core/Functional/Attribute/ShareInstance.php b/Classes/Core/Functional/Attribute/ShareInstance.php new file mode 100644 index 00000000..00bc7056 --- /dev/null +++ b/Classes/Core/Functional/Attribute/ShareInstance.php @@ -0,0 +1,55 @@ +close(); copy( $this->sqliteDir . 'test_' . $this->identifier . '.sqlite', - $this->sqliteDir . 'test_' . $this->identifier . '.snapshot.sqlite' + $this->sqliteDir . 'test_' . $this->snapshotIdentifier . '.snapshot.sqlite' ); $this->inMemoryImport = [true]; } else { @@ -87,7 +98,7 @@ public function restore(DatabaseAccessor $accessor, Connection $connection): voi if ($connection->getDatabasePlatform() instanceof SQLitePlatform) { $connection->close(); copy( - $this->sqliteDir . 'test_' . $this->identifier . '.snapshot.sqlite', + $this->sqliteDir . 'test_' . $this->snapshotIdentifier . '.snapshot.sqlite', $this->sqliteDir . 'test_' . $this->identifier . '.sqlite' ); } else { diff --git a/Classes/Core/Functional/FunctionalTestCase.php b/Classes/Core/Functional/FunctionalTestCase.php index 1dbec1f4..c10356fd 100644 --- a/Classes/Core/Functional/FunctionalTestCase.php +++ b/Classes/Core/Functional/FunctionalTestCase.php @@ -17,6 +17,7 @@ * The TYPO3 project - inspiring people to share! */ +use Doctrine\DBAL\Exception as DBALException; use PHPUnit\Framework\ExpectationFailedException; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; @@ -37,6 +38,7 @@ use TYPO3\CMS\Frontend\Http\Application; use TYPO3\TestingFramework\Core\BaseTestCase; use TYPO3\TestingFramework\Core\Exception; +use TYPO3\TestingFramework\Core\Functional\Attribute\ShareInstance; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\DataSet; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Snapshot\DatabaseAccessor; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Snapshot\DatabaseSnapshot; @@ -273,6 +275,57 @@ abstract class FunctionalTestCase extends BaseTestCase implements ContainerInter private static string $currentTestCaseClass = ''; private bool $isFirstTest = true; + /** + * Whether this is the first test of its test case class. The same as $isFirstTest, + * unless the test case class shares its instance with others, see ShareInstance: + * then the instance may already have been set up by another test case class. + */ + private bool $isFirstTestOfTestCase = true; + + /** + * @var array Identifiers of shared instances set up in this process + */ + private static array $sharedInstancesSetUp = []; + + /** + * Identifier of the instance the previous test used. + */ + private static string $lastInstanceIdentifier = ''; + + /** + * @var array Whether a test case class shares its instance, by class + */ + private static array $sharesInstanceByClass = []; + + /** + * Properties that make up the configuration of a test instance. Test case classes + * sharing an instance are identified by their default values. + */ + private const INSTANCE_CONFIGURATION_PROPERTIES = [ + 'coreExtensionsToLoad', + 'testExtensionsToLoad', + 'pathsToLinkInTestInstance', + 'pathsToProvideInTestInstance', + 'configurationToUseInTestInstance', + 'additionalFoldersToCreate', + 'initializeDatabase', + ]; + + /** + * Cache group dropped when a shared instance is handed over to another test case + * class. Tests legitimately write entries depending on more than the active package + * set to it - the TCA schema, the backend module registry - and the next test case + * class must not see them. The "di" group with the compiled container depends on the + * active package set only, and keeping it is what makes sharing an instance worth it. + */ + private const SHARED_INSTANCE_RESET_CACHE_GROUP = 'core'; + + /** + * Copy of typo3conf/system/ taken when a shared instance is set up, relative to the + * instance. Outside typo3conf/ so TYPO3 never reads it. + */ + private const SHARED_INSTANCE_CONFIGURATION_COPY = 'typo3temp/var/tests-shared-instance-configuration'; + /** * Set up creates a test instance and database. * @@ -299,6 +352,29 @@ protected function setUp(): void } else { $this->isFirstTest = false; } + $this->isFirstTestOfTestCase = $this->isFirstTest; + $sharesInstance = self::sharesInstance(); + $switchesInstance = false; + if ($sharesInstance) { + $this->assertInstanceConfigurationIsDeclaredStatically(); + // Shared instances are set up once per process, not once per test case class. + $this->isFirstTest = !isset(self::$sharedInstancesSetUp[$this->identifier]); + self::$sharedInstancesSetUp[$this->identifier] = true; + $switchesInstance = !$this->isFirstTest && self::$lastInstanceIdentifier !== $this->identifier; + if ($this->isFirstTestOfTestCase) { + // Snapshots belong to a test case class, so classes sharing an instance + // must not restore each other's. + DatabaseSnapshot::initializeWithSnapshotIdentifier( + dirname($this->getInstancePath()) . '/functional-sqlite-dbs/', + $this->identifier, + $this->identifier . '-' . substr(sha1(static::class), 0, 7) + ); + } + if (!$this->isFirstTest && $this->isFirstTestOfTestCase) { + $this->resetSharedInstanceForTestCase(); + } + } + self::$lastInstanceIdentifier = $this->identifier; // sqlite db path preparation $dbPathSqlite = dirname($this->instancePath) . '/functional-sqlite-dbs/test_' . $this->identifier . '.sqlite'; @@ -309,10 +385,17 @@ protected function setUp(): void // in a test case, so environment is set up only once per test case. GeneralUtility::purgeInstances(); $this->container = $testbase->setUpBasicTypo3Bootstrap($this->instancePath); + if ($switchesInstance) { + // Before TYPO3 v15, ConnectionPool keeps connections in a static property, + // which still points to the database of the previously used instance. + $this->closeDatabaseConnections(); + } $this->initializeTestDatabaseAndTruncateTables($testbase, $this->initializeDatabase, $dbPathSqlite, $dbPathSqliteEmpty); $testbase->loadExtensionTables(); } else { - DatabaseSnapshot::initialize(dirname($this->getInstancePath()) . '/functional-sqlite-dbs/', $this->identifier); + if (!$sharesInstance) { + DatabaseSnapshot::initialize(dirname($this->getInstancePath()) . '/functional-sqlite-dbs/', $this->identifier); + } $testbase->removeOldInstanceIfExists($this->instancePath); // Basic instance directory structure $testbase->createDirectory($this->instancePath . '/fileadmin'); @@ -419,6 +502,14 @@ protected function setUp(): void $localConfiguration['SYS']['caching']['cacheConfigurations']['pages']['backend'] = 'TYPO3\\CMS\\Core\\Cache\\Backend\\NullBackend'; $localConfiguration['SYS']['caching']['cacheConfigurations']['rootline']['backend'] = 'TYPO3\\CMS\\Core\\Cache\\Backend\\NullBackend'; $testbase->setUpLocalConfiguration($this->instancePath, $localConfiguration, $this->configurationToUseInTestInstance); + if ($sharesInstance) { + // Tests may write to the configuration files, "configuration:set" does for + // instance. Keep the originals to restore them for the next test case class. + $testbase->createDirectory($this->instancePath . '/' . self::SHARED_INSTANCE_CONFIGURATION_COPY); + foreach (glob($this->instancePath . '/typo3conf/system/*') ?: [] as $file) { + copy($file, $this->instancePath . '/' . self::SHARED_INSTANCE_CONFIGURATION_COPY . '/' . basename($file)); + } + } $testbase->setUpPackageStates( $this->instancePath, $defaultCoreExtensionsToLoad, @@ -1089,7 +1180,7 @@ protected function withDatabaseSnapshot(?callable $createCallback = null, ?calla $connection = $this->getConnectionPool()->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME); $accessor = new DatabaseAccessor($connection); $snapshot = DatabaseSnapshot::instance(); - if ($this->isFirstTest) { + if ($this->isFirstTestOfTestCase) { if ($createCallback) { $createCallback(); } @@ -1105,14 +1196,121 @@ protected function withDatabaseSnapshot(?callable $createCallback = null, ?calla /** * Create a 7 char long hash of class name as identifier. * + * Test case classes sharing their instance, see ShareInstance, get a hash of their + * instance configuration instead. + * * @internal * @return non-empty-string */ protected static function getInstanceIdentifier(): string { + if (self::sharesInstance()) { + return substr(sha1(serialize(self::getDeclaredInstanceConfiguration())), 0, 7); + } return substr(sha1(static::class), 0, 7); } + /** + * Whether this test case class, or one of its parents, carries the ShareInstance attribute. + */ + private static function sharesInstance(): bool + { + if (!isset(self::$sharesInstanceByClass[static::class])) { + $class = new \ReflectionClass(static::class); + do { + $sharesInstance = $class->getAttributes(ShareInstance::class) !== []; + } while (!$sharesInstance && ($class = $class->getParentClass()) !== false); + self::$sharesInstanceByClass[static::class] = $sharesInstance; + } + return self::$sharesInstanceByClass[static::class]; + } + + /** + * The instance configuration declared by the default property values of this test + * case class, which identifies a shared instance. + * + * Test case classes overriding how the database is set up or reset - starting from a + * database without tables, for instance - rely on being the class that sets up the + * instance, so which class implements that is part of the configuration as well. + * + * @return array + */ + private static function getDeclaredInstanceConfiguration(): array + { + $defaults = (new \ReflectionClass(static::class))->getDefaultProperties(); + $configuration = []; + foreach (self::INSTANCE_CONFIGURATION_PROPERTIES as $property) { + $configuration[$property] = $defaults[$property]; + } + foreach (['initializeTestDatabase', 'initializeTestDatabaseAndTruncateTables'] as $method) { + $configuration[$method] = (new \ReflectionMethod(static::class, $method))->getDeclaringClass()->getName(); + } + return $configuration; + } + + /** + * A shared instance is identified before setUp() of the test case class runs, from + * the default property values. A test case class assigning them at runtime would get + * an instance set up for a different configuration, so fail instead. + */ + private function assertInstanceConfigurationIsDeclaredStatically(): void + { + $declared = self::getDeclaredInstanceConfiguration(); + foreach (self::INSTANCE_CONFIGURATION_PROPERTIES as $property) { + if ($this->{$property} !== $declared[$property]) { + throw new Exception( + sprintf( + 'Test case class %s shares its instance, but sets $%s at runtime. Declare it as the' + . ' default value of the property, or remove the %s attribute.', + static::class, + $property, + ShareInstance::class + ), + 1794738201 + ); + } + } + } + + /** + * Drops the state a test case class may have left in a shared instance, before + * another test case class uses it. + */ + private function resetSharedInstanceForTestCase(): void + { + foreach (['code', 'data'] as $kind) { + $directory = $this->instancePath . '/typo3temp/var/cache/' . $kind . '/' . self::SHARED_INSTANCE_RESET_CACHE_GROUP; + if (is_dir($directory)) { + GeneralUtility::rmdir($directory, true); + } + } + // Restore the configuration files as they were set up: tests write settings.php, + // and may add files such as file-storages.yaml, which the next class must not see. + $configurationPath = $this->instancePath . '/typo3conf/system'; + $originalPath = $this->instancePath . '/' . self::SHARED_INSTANCE_CONFIGURATION_COPY; + if (!is_dir($originalPath)) { + return; + } + foreach (glob($configurationPath . '/*') ?: [] as $file) { + if (is_file($file) && !is_file($originalPath . '/' . basename($file))) { + unlink($file); + } + } + foreach (glob($originalPath . '/*') ?: [] as $file) { + copy($file, $configurationPath . '/' . basename($file)); + } + } + + private function closeDatabaseConnections(): void + { + $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class); + try { + $connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME)->close(); + } catch (DBALException) { + } + $connectionPool->resetConnections(); + } + /** * @internal Extensions functional tests should usually not fiddle with this. This may break anytime. * Checking instance paths within tests is a use case extensions should face seldomly. There diff --git a/Tests/Unit/Core/Functional/Fixtures/NotSharedInstance.php b/Tests/Unit/Core/Functional/Fixtures/NotSharedInstance.php new file mode 100644 index 00000000..f35a22c8 --- /dev/null +++ b/Tests/Unit/Core/Functional/Fixtures/NotSharedInstance.php @@ -0,0 +1,30 @@ +