Skip to content
Merged
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
26 changes: 12 additions & 14 deletions plugin/lifecycle/src/Internal/LifecycleInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
* Both class-based cases (methods of a {@see \Testo\Test} class) and function-based cases (a file of
* top-level functions, whose {@see CaseDefinition::$reflection} is null) are supported. For a
* function-based case the hooks are the file's lifecycle-annotated functions, discovered from the
* source file of the case's tests.
* case's source file ({@see CaseDefinition::$file}).
*
* @internal
* @psalm-internal Testo\Lifecycle
Expand Down Expand Up @@ -167,27 +167,25 @@ private static function group(iterable $hooks): array
/**
* Collect the lifecycle-annotated free functions of a function-based case.
*
* A function-based case carries no class reflection, so the hooks are located from the source
* file of the case's tests: every function declared there is reflected and filtered down to
* those carrying a lifecycle attribute.
* A function-based case carries no class reflection, so the hooks are located from the case's
* source file ({@see CaseDefinition::$file}): every function declared there is reflected and
* filtered down to those carrying a lifecycle attribute.
*
* The path comes from the definition itself, never from the surviving tests: outer case
* interceptors may prune tests from the case, and the `#[BeforeClass]`/`#[AfterClass]` hooks
* must still run for a fully pruned case.
*
* @return list<\ReflectionFunction>
*/
private static function collectCaseFunctions(CaseDefinition $definition): array
{
$path = null;
foreach ($definition->tests->getTests() as $test) {
if ($test->reflection instanceof \ReflectionFunction) {
$path = $test->reflection->getFileName();
break;
}
}

if (!\is_string($path)) {
$path = $definition->file;
if (!$path->isFile()) {
# A synthetic definition without a real source file has no discoverable hooks.
return [];
}

$file = new TokenizedFile(file: new \SplFileInfo($path), path: $path);
$file = new TokenizedFile(file: new \SplFileInfo((string) $path), path: $path);

return \array_values(\array_filter(
DefinitionLocator::getFunctions($file),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Tests\Lifecycle\Unit\Fixture;

use Testo\Lifecycle\AfterClass;
use Testo\Lifecycle\AfterTest;
use Testo\Lifecycle\BeforeClass;
use Testo\Lifecycle\BeforeTest;

/**
* Call counters for the lifecycle functions below. Not autoloadable — the test
* `require_once`s this file before touching the counters.
*/
final class PrunedFunctionsState
{
public static int $beforeClassCalls = 0;
public static int $afterClassCalls = 0;
public static int $beforeTestCalls = 0;
public static int $afterTestCalls = 0;
}

#[BeforeClass]
function prunedFnSetUpClass(): void
{
++PrunedFunctionsState::$beforeClassCalls;
}

#[AfterClass]
function prunedFnTearDownClass(): void
{
++PrunedFunctionsState::$afterClassCalls;
}

#[BeforeTest]
function prunedFnSetUp(): void
{
++PrunedFunctionsState::$beforeTestCalls;
}

#[AfterTest]
function prunedFnTearDown(): void
{
++PrunedFunctionsState::$afterTestCalls;
}

# The case's only test. Never referenced directly: the regression test empties the test set
# to simulate an outer case interceptor pruning it away.
function prunedFnTest(): void {}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@

namespace Tests\Lifecycle\Unit\Internal;

use Internal\Path;
use Testo\Assert;
use Testo\Codecov\Covers;
use Testo\Core\Context\CaseInfo;
use Testo\Core\Context\CaseResult;
use Testo\Core\Context\Identity\SuiteIdentity;
use Testo\Core\Definition\CaseDefinition;
use Testo\Core\Definition\TestDefinitions;
use Testo\Core\Value\Status;
use Testo\Core\Value\TestType;
use Testo\Lifecycle\AfterClass;
use Testo\Lifecycle\AfterTest;
use Testo\Lifecycle\BeforeClass;
use Testo\Lifecycle\BeforeTest;
use Testo\Lifecycle\Internal\LifecycleInterceptor;
use Testo\Test;
use Testo\Tokenizer\DefinitionLocator;
Expand All @@ -15,6 +26,7 @@
use Tests\Lifecycle\Unit\Fixture\ClassWithLifecycleMethods;
use Tests\Lifecycle\Unit\Fixture\ClassWithMultipleLifecycleOnOneMethod;
use Tests\Lifecycle\Unit\Fixture\ClassWithoutLifecycle;
use Tests\Lifecycle\Unit\Fixture\PrunedFunctionsState;

#[Test]
#[Covers(LifecycleInterceptor::class)]
Expand Down Expand Up @@ -166,6 +178,61 @@ public function passesThroughWhenNoLifecycleFunctionsPresent(): void
->hasKeys('fnAlpha', 'fnBeta');
}

/**
* Regression: hook discovery for a function-based case must come from
* {@see CaseDefinition::$file}, not from the surviving tests. Any outer case interceptor
* may prune tests from the case before this interceptor runs, and the
* `#[BeforeClass]`/`#[AfterClass]` hooks must still fire — also when nothing survived.
* Discovery used to locate the source file from the first surviving test function, so a
* fully pruned case silently lost its class-level hooks.
*/
public function runsClassHooksForFunctionCaseWhoseTestsWereAllPruned(): void
{
# Functions are not autoloadable: load the fixture to reach the counters.
require_once $this->fixturesDir . 'PrunedFunctionsWithLifecycle.php';
PrunedFunctionsState::$beforeClassCalls = 0;
PrunedFunctionsState::$afterClassCalls = 0;
$info = $this->makeFunctionCaseInfoWithoutTests($this->fixturesDir . 'PrunedFunctionsWithLifecycle.php');

$hooksAtNext = null;
$beforeClassAtNext = null;
$afterClassAtNext = null;
$this->interceptor->runTestCase(
$info,
static function (CaseInfo $case) use (&$hooksAtNext, &$beforeClassAtNext, &$afterClassAtNext): CaseResult {
$hooksAtNext = $case->getAttribute(LifecycleInterceptor::class, []);
$beforeClassAtNext = PrunedFunctionsState::$beforeClassCalls;
$afterClassAtNext = PrunedFunctionsState::$afterClassCalls;
return new CaseResult(results: [], status: Status::Passed);
},
);

# All four hooks were discovered from the case file and published for the inner pipeline.
Assert::array($hooksAtNext)
->hasKeys(BeforeClass::class, AfterClass::class, BeforeTest::class, AfterTest::class);
# The class hooks fire exactly once, around the inner pipeline: BeforeClass has already
# fired when `$next` runs, AfterClass has not yet.
Assert::same($beforeClassAtNext, 1);
Assert::same($afterClassAtNext, 0);
Assert::same(PrunedFunctionsState::$beforeClassCalls, 1);
Assert::same(PrunedFunctionsState::$afterClassCalls, 1);
}

/**
* A synthetic function-based definition whose file does not exist on disk has no
* discoverable hooks — the case must still pass through the pipeline instead of
* failing on tokenization.
*/
public function passesThroughFunctionCaseWithoutRealSourceFile(): void
{
$info = $this->makeFunctionCaseInfoWithoutTests(__DIR__ . '/does-not-exist.php');
$expected = new CaseResult(results: [], status: Status::Passed);

$result = $this->interceptor->runTestCase($info, static fn(): CaseResult => $expected);

Assert::same($result, $expected);
}

private function makeDefinitionWithAllPublicMethodsAsTests(string $fixture, string $classFqn): FileDefinitions
{
$path = $this->fixturesDir . $fixture;
Expand Down Expand Up @@ -203,4 +270,22 @@ private function makeDefinitionWithAllFunctionsAsTests(string $fixture): FileDef

return $definition;
}

/**
* Build a {@see CaseInfo} over a function-based case (null reflection) whose test set is
* empty — the state an outer case interceptor leaves behind after pruning every test.
*/
private function makeFunctionCaseInfoWithoutTests(string $path): CaseInfo
{
$file = Path::create($path);
$definition = new CaseDefinition(
name: $file->name(),
type: TestType::Test->value,
file: $file,
reflection: null,
tests: new TestDefinitions(),
);

return new CaseInfo(definition: $definition, suiteIdentity: new SuiteIdentity('Lifecycle/Unit'));
}
}
Loading