Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions Classes/Core/Functional/Attribute/ShareInstance.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace TYPO3\TestingFramework\Core\Functional\Attribute;

/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/

/**
* Lets functional test case classes with the same instance configuration share one
* test instance.
*
* By default, every test case class gets its own instance: its own directory, its own
* database and schema, and its own 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 each, setting up instances dominates the run.
*
* Test case classes carrying this attribute - directly or through a parent class - are
* identified by their instance configuration instead of their class name: the
* extensions to load, the paths to link and provide, the configuration to use, the
* folders to create, whether the database is initialized, and which class implements
* the database setup. Classes configured the same way share one instance. It is set up
* once, and every later test only resets the database, as the second test of a class
* always did.
*
* Requirements for test case classes using it:
*
* - The instance configuration is declared as default values of the properties named
* above. Assigning them at runtime, in setUp() for instance, is detected and fails,
* because the instance is identified before setUp() runs.
* - Tests do not leave files in the instance that change the behavior of other tests.
* Database rows, the "core" cache group and settings.php are reset when the instance
* is handed over to another test case class.
*
* Example, sharing the instance between all test case classes of an extension:
*
* #[ShareInstance]
* abstract class AbstractFunctionalTestCase extends FunctionalTestCase
* {
* protected array $testExtensionsToLoad = ['vendor/my-extension'];
* }
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class ShareInstance {}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,17 @@ class DatabaseSnapshot

public static function initialize(string $sqliteDir, string $identifier): void
{
self::$instance = new self($sqliteDir, $identifier);
self::$instance = new self($sqliteDir, $identifier, $identifier);
}

/**
* For test case classes sharing an instance: the database file is named after the
* instance, the snapshot after the test case class, so classes sharing the instance
* do not restore each other's snapshot.
*/
public static function initializeWithSnapshotIdentifier(string $sqliteDir, string $identifier, string $snapshotIdentifier): void
{
self::$instance = new self($sqliteDir, $identifier, $snapshotIdentifier);
}

public static function instance(): self
Expand All @@ -50,7 +60,8 @@ public static function instance(): self

private function __construct(
private readonly string $sqliteDir,
private readonly string $identifier
private readonly string $identifier,
private readonly string $snapshotIdentifier
) {}

/**
Expand All @@ -63,7 +74,7 @@ public function create(DatabaseAccessor $accessor, Connection $connection): void
$connection->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 {
Expand All @@ -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 {
Expand Down
202 changes: 200 additions & 2 deletions Classes/Core/Functional/FunctionalTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<non-empty-string, true> 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<class-string, bool> 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.
*
Expand All @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This marks the shared instance as set up before the instance is built further down. If building it throws (reproduced with a missing test extension), every later class with the same configuration takes the reuse path against a half-built instance and fails with an unrelated error (ConnectionPool can not be used from failsafe container, dangling error handler) instead of the real cause. Setting the flag only after the instance is complete, e.g. after loadExtensionTables(), would keep the original error visible for every class.

$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';
Expand All @@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the lookup walks up the parents, a class below an opted-in base class cannot opt out again, e.g. one that has to assign its configuration in setUp(). The exception it then gets says to remove the attribute, which sits on the base class. An opt-out (for example #[ShareInstance(false)] evaluated from the most derived class) or a message covering the inherited case would help the base-class adoption the PR describes.

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<string, mixed>
*/
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
Expand Down
Loading