Skip to content

[!!!][FEATURE] Share test instances between identically configured test cases - #740

Open
bmack wants to merge 2 commits into
TYPO3:mainfrom
bmack:layer0-share-test-instances
Open

[!!!][FEATURE] Share test instances between identically configured test cases#740
bmack wants to merge 2 commits into
TYPO3:mainfrom
bmack:layer0-share-test-instances

Conversation

@bmack

@bmack bmack commented Aug 10, 2026

Copy link
Copy Markdown
Member

Functional test instances are identified by sha1(static::class), so every test case class
provisions its own instance: its own directory tree, its own compiled dependency injection
container and its own database schema. Across the TYPO3 core corpus that is 764 instances,
while only 207 distinct instance configurations exist.

This series keys instances on what actually shapes them — the extensions to load, the paths to
link and provide, the configuration overrides, the folders to create, whether the database is
initialised — so test cases configured identically share one instance. It is provisioned once,
its container is compiled once and its schema is created once.

Measurements

Layer 0 in isolation, full core corpus, measured 2026-07-31 against the then-current main:

DBMS baseline shared delta
sqlite 1341.6 s 847.8 s −36.8 %
Postgres 2109.8 s 1672.4 s −20.7 %
MariaDB identical outcome ~−20 %
MySQL ~−11 %

A more recent full-corpus run (2026-08-04, with the composer-mode work of the follow-up PR also
applied, but in classic mode where that work is largely inert) measured 1379.2 s → 945.9 s,
−31.4 % on sqlite.

In every case the comparison was made on assertion counts and failing test sets, not just
totals — a suite that merely stays green while doing less work would otherwise look like a win.

Sharing state that used to be private

Sharing an instance between test case classes exposes state that was previously private simply
because every test case owned an instance. Six such channels were found and are addressed here;
the commit message lists them individually. The compiled dependency injection container is
deliberately not flushed: it depends only on the active package set, and keeping it is what
makes sharing worthwhile at all.

Breaking change

getInstanceIdentifier() and getInstancePath() are no longer static — they cannot be, because
test cases may assign the configuration properties in their own setUp() before calling
parent::setUp(). Both are @internal, and getInstancePath() already documented that it may
break at any time. The first commit carries a migration note for the three cases a downstream
test case can be in. Neither typo3/cms nor the testing framework itself calls these statically
any more.

Keeping them static via a holder was considered and rejected; the reasoning is in the commit
message.

Follow-up

A second PR provisions functional test instances in composer mode, which builds on this one.
The TYPO3 core side of that work is prepared and waits on a release of this.

Verification

Each commit was verified green individually against the full TYPO3 core functional corpus, with
the upstream testing framework run as a control arm in the same job. On the current base both
commits produce results identical to upstream: 12473 tests, 72962 assertions, 0 errors, the same
6 pre-existing failures.

bmack added 2 commits August 10, 2026 23:20
…st cases

Functional test instances are identified by sha1(static::class), so every test
case class provisions its own instance: its own directory tree, its own
compiled dependency injection container and its own database schema. Across the
TYPO3 core corpus that is 764 instances, while only 207 distinct instance
configurations exist. Measured on ext:core, provisioning accounts for roughly a
third of the suite's wall clock, and a cold bootstrap costs about 25 times a
warm one because the container has to be compiled again.

Instances are now identified by what actually shapes them - the extensions to
load, the paths to link and provide, the configuration overrides, the folders
to create and whether the database is initialised - so test cases configured
identically share one instance. It is provisioned once, its container is
compiled once and its schema is created once.

Sharing an instance between test case classes exposes state that used to be
private simply because every test case owned an instance. Six such channels
were found and are addressed here:

* The instance cache directory is shared, because the package dependent cache
  identifier derives from the project path. The whole "core" cache group is
  dropped when one test case class hands the instance to the next, so a cached
  TCA schema or a backend module registry written by one test case cannot be
  seen by another. The compiled container is deliberately kept: it depends only
  on the active package set, and keeping it is what makes sharing worthwhile.
* Database snapshots were keyed on the instance and created on the first test
  of the instance rather than of the test case. They are now scoped to the test
  case class, so a test case that did not provision the instance no longer
  restores a snapshot it never created.
* Test cases write to typo3conf/system/settings.php, for example through the
  "configuration:set" command. A pristine copy is kept at provisioning time and
  restored at a test case class boundary.
* Test cases that override how the database is provisioned - the core schema
  test cases start from a database with no tables - rely on being the test case
  that provisions the instance. Such test cases now get their own instance
  family, keyed on the declaring class of initializeTestDatabase() and
  initializeTestDatabaseAndTruncateTables().

Breaking changes:

* getInstanceIdentifier() and getInstancePath() are no longer static. They
  cannot be, because test cases may assign the configuration properties in
  their own setUp() before calling parent::setUp(), and a static method cannot
  see those assignments. Both are marked @internal, and getInstancePath()
  already documented that it may break at any time.

  Migration, for test cases that touch them:

    - Calling them: use $this->getInstanceIdentifier() and
      $this->getInstancePath() instead of self:: or static::.
    - Overriding them: drop the "static" keyword from the declaration.
      A subclass that still declares them static will fail to load.
    - Overriding getInstanceIdentifier() to pin an instance is usually the
      wrong hook now. Anything that shapes what the instance *contains*
      belongs in getInstanceConfiguration(), so that test cases configured
      the same way keep sharing an instance; an overridden identifier opts
      the test case out of sharing entirely.

  Keeping the methods static was considered, by computing the identifier in
  setUp() and handing it out through a static property. That preserves both
  static call sites and static overrides, but it puts mutable static state
  back into FunctionalTestCase - the same class of coupling as the six
  channels fixed above - and leaves a static signature whose result is only
  meaningful between setUp() and tearDown(). The honest break was preferred.
  Neither typo3/cms nor the testing framework itself calls these statically
  any more.
* DatabaseSnapshot::initialize() gains an optional third argument naming the
  snapshot separately from the instance. Existing two argument calls behave as
  before.

Validated against the full functional suite of TYPO3 core - all system
extensions - comparing assertion counts and failing test sets, not just totals:

  DBMS        tests   assertions   baseline     shared    delta
  sqlite      12392        72711    1341.6 s    847.8 s   -36.8 %
  MariaDB     12427        72966    identical outcome, timing per notes
  Postgres    12388        72693    2109.8 s   1672.4 s   -20.7 %

Identical failure sets in every case. A controlled back to back measurement of
ext:core on MariaDB, three runs in one job, shows 563.6 s against 450.0 s, a
20.2 per cent improvement with a 3.6 per cent spread between repeats.
The test instance directory and the test database are named after the instance
identifier alone, so two functional test runs sharing a working copy address the
same instance directory and the same database. A run that provisions an instance
then deletes it while the other run is still using it, and both collapse.

Reproduced on an unmodified checkout by running one test case twice at the same
time: 41 errors and 10 failures in one run, 34 errors and 12 failures in the
other, one shared instance directory.

Setting TYPO3_TESTING_WORKER to a different value per run appends it to the
instance identifier, so the runs get their own instance directory and their own
database. The same two runs then pass with 70 tests each.

The variable is unset by default and naming is then unchanged.

It becomes part of database names, so the value is restricted to one to eight
alphanumeric characters. An invalid value is rejected rather than sanitised:
silently folding "1-a" and "1a" onto one instance would reintroduce exactly the
collision this prevents.

This is a prerequisite for running the suite with several workers, but it is
useful on its own - it is what makes it possible to run two database backends,
or one suite and one test file, against a single working copy.
@bmack
bmack force-pushed the layer0-share-test-instances branch from 544a014 to c5369a3 Compare August 10, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant