Skip to content

Re-register the DI container in setUp() of tests with PHP-version-dependent reflection assertions - fixes racy/flaky tests#5917

Open
phpstan-bot wants to merge 6 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-d7cdkuu
Open

Re-register the DI container in setUp() of tests with PHP-version-dependent reflection assertions - fixes racy/flaky tests#5917
phpstan-bot wants to merge 6 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-d7cdkuu

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

HasPropertyTypeTest::testIsSuperTypeOf was intermittently failing in CI on the
hasProperty(foo) -> isSuperTypeOf(Closure) data set with Expected 'Maybe', Actual 'No'.

The data set's expected value is PHP_VERSION_ID < 80200 ? Maybe : No, while the actual
value comes from ObjectType(Closure)->hasInstanceProperty('foo'), which for a final class
returns Maybe/No depending on ClassReflection::allowsDynamicProperties() — and that is
version-gated (PhpVersion::deprecatesDynamicProperties(), i.e. >= 8.2).

These tests construct Type objects and call their methods directly, so reflection is
resolved through the global ReflectionProviderStaticAccessor. When another test leaves a
container configured with a different PhpVersion registered there, the Closure
ClassReflection is built with that foreign version and the result no longer matches
PHP_VERSION_ID. An existing PHPUnit event subscriber re-initializes the container before
each test, but it does not fire reliably under the actual test runner (paratest), so the
flake persisted. setUp() is a runner-agnostic guarantee.

Changes

  • tests/PHPStan/Type/Accessory/HasPropertyTypeTest.php
    • Add setUp() calling self::getContainer() to re-register the default (runtime)
      container before every test.
    • Add a deterministic regression test testIsSuperTypeOfClosureRespectsActivePhpVersion
      that asserts Maybe under PHP 8.1 and No under PHP 8.2 by building containers with
      explicit PhpVersions, restoring the default container in a finally block so no
      global state leaks.
  • tests/PHPStan/Type/ObjectTypeTest.php, tests/PHPStan/Type/TypeCombinatorTest.php,
    tests/PHPStan/Type/Generic/GenericObjectTypeTest.php
    • Add the same setUp() re-registration. These are the sibling type unit tests that call
      Type methods directly and have version-dependent assertions (dynamic-property handling
      of final classes / reflected variance of built-in generics), so they share the same
      latent flakiness.

Root cause

A family of type unit tests assert PHP-version-dependent reflection results (computed from
the active container's PhpVersion) against expectations derived from the global
PHP_VERSION_ID constant. These two sources diverge whenever the globally-registered
reflection provider belongs to a container whose PhpVersion differs from the runtime —
which happens when another test registered such a container and nothing re-registers the
default container before the test executes. Calling self::getContainer() in setUp()
pins the runtime container before each test, eliminating the coupling.

Tests that go through the analyser (RuleTestCase, TypeInferenceTestCase) already call
self::getContainer() during their analyse/assert flow, so they are not affected.

Test

  • testIsSuperTypeOfClosureRespectsActivePhpVersion deterministically verifies the
    version-dependent behavior on every runtime by switching the active container's
    PhpVersion.
  • Verified the flake reproduces: with the container-init subscriber disabled and setUp()
    removed, the Closure data set fails (Expected 'No', Actual 'Maybe') once a foreign
    container is registered; re-enabling setUp() fixes it without the subscriber.
  • Probed HasMethodTypeTest (the closest sibling): its Closure case is No regardless of
    version, so it is not affected and was left unchanged.

Fixes phpstan/phpstan#14860

@staabm staabm force-pushed the create-pull-request/patch-d7cdkuu branch from 89b991c to feb08f2 Compare June 23, 2026 05:20
@staabm staabm changed the title Re-register the runtime container in setUp() of type tests with PHP-version-dependent reflection assertions Re-register the DI container in setUp() of tests with PHP-version-dependent reflection assertions - fixes racy/flaky tests Jun 23, 2026
@staabm staabm requested a review from VincentLanglet June 23, 2026 05:21
Comment thread tests/PHPStan/Type/Accessory/HasPropertyTypeTest.php Outdated
@VincentLanglet

Copy link
Copy Markdown
Contributor

Should the fix be in the base TestCase then @staabm ?

@staabm

staabm commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

my understanding is, that the lazy construction of the DI container is a performance optimization.
if we add the suggested workaround to the base TestCase we will kill the optimization in all tests.

@VincentLanglet VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What about mooving this logic in the PHPStanTestCase setup inside a condition (set to false by default). This way it's easy to enable on the right files

staabm and others added 4 commits June 23, 2026 16:11
…-version-dependent reflection assertions

- Add `setUp()` to `HasPropertyTypeTest`, `ObjectTypeTest`, `TypeCombinatorTest`
  and `GenericObjectTypeTest` that calls `self::getContainer()`. These tests call
  `Type` methods directly (without going through the analyser), so they resolve
  reflection through the global `ReflectionProviderStaticAccessor`. Another test
  can leave a container configured with a different `PhpVersion` registered there;
  re-registering the default (runtime) container before each test makes the
  version-dependent assertions deterministic, independent of test ordering or the
  test runner (the existing PHPUnit event subscriber does not fire reliably under
  paratest).
- Add a deterministic regression test `testIsSuperTypeOfClosureRespectsActivePhpVersion`
  that exercises both sides of the dynamic-properties boundary (PHP 8.1 -> `Maybe`,
  PHP 8.2 -> `No`) by building containers with explicit `PhpVersion`s, and restores
  the default container afterwards so it never leaks global state.
- The flaky `hasProperty(foo) -> isSuperTypeOf(Closure)` assertion was caused by the
  result of `ObjectType::hasInstanceProperty()` for a final class depending on
  `ClassReflection::allowsDynamicProperties()`, which is version-gated, while the
  expected value was derived from `PHP_VERSION_ID`; the two diverged when the active
  container's `PhpVersion` did not match the runtime.
- Probed `HasMethodTypeTest`: its `Closure` case is `No` regardless of version
  (method existence is not affected by dynamic properties), so it is not affected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-d7cdkuu branch from 46b6e1d to 38dd381 Compare June 23, 2026 14:11
phpstan-bot and others added 2 commits June 23, 2026 14:21
…flag

Replace the per-class setUp() duplication with a $reinitializeContainerBeforeEachTest
flag (default false) on PHPStanTestCase. Tests with PHP-version-dependent reflection
assertions opt in by setting it to true, so the runtime container is pinned before each
test without each class re-implementing setUp().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PHPStanTestCase now defines setUp(), so subclasses that override it must call
parent::setUp() to run the (opt-in) container re-registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed VincentLanglet's review by moving the container re-registration logic into the base PHPStanTestCase, gated behind an opt-in flag.

What changed

src/Testing/PHPStanTestCase.php — added a protected bool $reinitializeContainerBeforeEachTest = false; property and a setUp() that re-registers the runtime container only when the flag is enabled. The default (false) preserves the lazy-container performance optimization staabm flagged in the discussion — the work only happens in tests that explicitly opt in, not in every test.

The four affected type unit tests (HasPropertyTypeTest, ObjectTypeTest, GenericObjectTypeTest, TypeCombinatorTest) — replaced their duplicated setUp() methods (and the now-unused Override imports) with a single line: protected bool $reinitializeContainerBeforeEachTest = true;. The explanatory comment stays so it's obvious why each one opts in.

Seven other subclasses that override setUp() — added parent::setUp(). This was a required follow-up: once the base class defines setUp(), PHPStan's own phpunit.callParent rule (correctly) flags any override that doesn't call the parent.

Verification

  • make tests — 17446 tests pass (96 skipped, as usual)
  • make phpstan — no errors
  • make cs — no errors

Committed as two logical commits (the refactor, then the parent::setUp() follow-ups), rebased onto the force-updated remote branch, and pushed.

One trade-off worth noting for the reviewer: making this opt-in via the base class means every existing setUp() override in the suite now needs parent::setUp() to stay correct — that's why the second commit touches seven unrelated test files. That's the cost of centralizing it versus the previous per-file approach.

@VincentLanglet

Copy link
Copy Markdown
Contributor

WDYT of this way @staabm ?

staabm
staabm previously approved these changes Jun 23, 2026
@staabm staabm dismissed their stale review June 23, 2026 17:06

Broken build

@VincentLanglet

Copy link
Copy Markdown
Contributor

Broken build

Which one ?

Extensions need to be updated after

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.

CI: Intermittent in HasPropertyTypeTest

3 participants