From be1fb8ddca81f5b93874998c83d94adf6c3d635b Mon Sep 17 00:00:00 2001 From: Meacue Date: Wed, 2 Sep 2026 17:14:43 +0500 Subject: [PATCH 01/35] feat(test): add #[Skip] attribute to skip tests declaratively A plain marker attribute (method, class, or free function) plus a registered case-level TestCaseRunInterceptor: parked tests are filtered out of the case before any lifecycle hooks and reported back as synthetic Skipped results with a composed reason ("{testId} is skipped via #[Skip] ==> {reason}"), delivered through a batch-runner wrapper so reporters render them inside the case block with no reporter changes. Assisted-By: Claude Fable 5 --- plugin/test/src/Internal/SkipInterceptor.php | 165 +++++++++++++++++++ plugin/test/src/Skip.php | 76 +++++++++ plugin/test/src/TestPlugin.php | 6 +- 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 plugin/test/src/Internal/SkipInterceptor.php create mode 100644 plugin/test/src/Skip.php diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php new file mode 100644 index 00000000..2416ff09 --- /dev/null +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -0,0 +1,165 @@ +findParked($info); + + if ($parked === []) { + return $next($info); + } + + foreach ($parked as $name => $_) { + $info->definition->tests->undefine($name); + } + + # The case still runs (class-level hooks, events, the remaining tests): the parked + # results are appended by the batch runner inside the case window. + $inner = $info->batchRunner; + return $next($info->withBatchRunner( + function (array $handlers) use ($inner, $info, $parked): array { + $results = $inner === null + ? \array_map(static fn(callable $handler): TestResult => $handler(), $handlers) + : $inner($handlers); + + foreach ($parked as $name => [$definition, $attribute]) { + $results[] = $this->reportSkipped($info, $name, $definition, $attribute); + } + + return $results; + }, + )); + } + + /** + * Collects the parked tests of the case: a method/function-level `#[Skip]` wins over the + * class-level one; the class-level attribute is inherited from parents and traits. + * + * @return array + */ + private function findParked(CaseInfo $info): array + { + $classAttribute = null; + $reflection = $info->definition->reflection; + if ($reflection !== null) { + $attributes = Reflection::fetchClassAttributes($reflection, attributeClass: Skip::class, limit: 1); + $attributes === [] or $classAttribute = $attributes[0]->newInstance(); + } + + $parked = []; + foreach ($info->definition->tests->getTests() as $name => $definition) { + $attributes = Reflection::fetchFunctionAttributes( + $definition->reflection, + attributeClass: Skip::class, + limit: 1, + ); + $attribute = $attributes === [] ? $classAttribute : $attributes[0]->newInstance(); + + $attribute === null or $parked[$name] = [$definition, $attribute]; + } + + return $parked; + } + + /** + * Builds the synthetic result for a parked test and dispatches its pipeline events, so + * reporters that render test lines from those events see the test as any other. + */ + private function reportSkipped( + CaseInfo $case, + string $name, + TestDefinition $definition, + Skip $attribute, + ): TestResult { + $testInfo = (new TestInfo(name: $name, caseInfo: $case, testDefinition: $definition)) + ->withAttributes([Skip::class => [$attribute]]); + + $this->eventDispatcher->dispatch(new TestPipelineStarting($testInfo)); + + $result = new TestResult( + info: $testInfo, + status: Status::Skipped, + failure: new SkipTest(self::reason($testInfo, $attribute)), + attributes: ['duration' => 0], + summary: Summary::forTest(Status::Skipped), + ); + + $this->eventDispatcher->dispatch(new TestPipelineFinished($testInfo, $result)); + + return $result; + } + + /** + * Composes the reported message: `{testId} is skipped via #[Skip]`, extended with + * ` ==> {reason}` when a reason is given. The generated part is always present so every + * reporter shows the origin of the skip even with an empty reason. + */ + private static function reason(TestInfo $info, Skip $attribute): string + { + $class = $info->caseInfo->definition->reflection?->getName(); + $testId = $class !== null + ? "{$class}::{$info->name}" + : $info->testDefinition->reflection->getName(); + + $message = "{$testId} is skipped via #[Skip]"; + + return $attribute->reason === '' ? $message : "{$message} ==> {$attribute->reason}"; + } +} diff --git a/plugin/test/src/Skip.php b/plugin/test/src/Skip.php new file mode 100644 index 00000000..65567ed8 --- /dev/null +++ b/plugin/test/src/Skip.php @@ -0,0 +1,76 @@ + {reason}` when a reason is given — so JUnit/TeamCity/HTML output always shows + * the origin of the skip, even with an empty reason. + * + * Runtime contract (v1): + * + * - The skipped test never enters the per-test pipeline: `#[BeforeTest]`/`#[AfterTest]` + * hooks, data providers, `#[Retry]`/`#[Repeat]`, fibers and coverage never engage. + * A data-driven test yields a single Skipped entry (providers are not expanded). + * - `#[BeforeClass]`/`#[AfterClass]` hooks still run — also when every test of the case + * is skipped. Full case suppression is a possible follow-up. + * - The case class is not instantiated, unless a non-static class-level hook forces + * construction (class-level hooks may be non-static; that builds the class). + * - A run consisting only of `#[Skip]`-marked tests is successful (exit code 0): + * Skipped is neither a success nor a failure. + * - On a non-test method the attribute is inert (like `#[Group]` on a helper). + * + * The attribute is a plain marker: {@see Internal\SkipInterceptor} (registered by + * {@see TestPlugin}) looks it up itself and reports the synthetic Skipped results. + * For skipping at runtime — from the test body, based on the environment — throw + * {@see SkipTest} instead. + * + * @see SkipTest for the runtime counterpart and the `is skipped via #[Skip]` marker + * distinguishing declarative skips in reports. + * + * @api + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final readonly class Skip +{ + /** + * @param string $reason Why the test is parked. Optional, but a reference to an issue + * (`'flaky on CI, see ISSUE-123'`) keeps the skip reviewable. + */ + public function __construct( + public string $reason = '', + ) {} +} diff --git a/plugin/test/src/TestPlugin.php b/plugin/test/src/TestPlugin.php index f8969d25..84f5c15d 100644 --- a/plugin/test/src/TestPlugin.php +++ b/plugin/test/src/TestPlugin.php @@ -8,6 +8,7 @@ use Testo\Common\PluginConfigurator; use Testo\Pipeline\InterceptorCollector; use Testo\Test; +use Testo\Test\Internal\SkipInterceptor; use Testo\Test\Internal\TestoAttributesLocatorInterceptor; /** @@ -20,6 +21,9 @@ #[\Override] public function configure(Container $container): void { - $container->get(InterceptorCollector::class)->addInterceptor(new TestoAttributesLocatorInterceptor()); + $collector = $container->get(InterceptorCollector::class); + $collector->addInterceptor(new TestoAttributesLocatorInterceptor()); + # Registered as a class-string: the container injects the event dispatcher on resolve. + $collector->addInterceptor(SkipInterceptor::class); } } From 79109b6721469990c71c1b2366c6dfe7e0f9b253 Mon Sep 17 00:00:00 2001 From: Meacue Date: Wed, 2 Sep 2026 17:14:43 +0500 Subject: [PATCH 02/35] test(test): cover the #[Skip] v1 semantics with unit and feature tests Unit: interceptor filtration before $next, reason composition, synthetic result shape, origin attribute, batch-runner wrapping, pipeline events. Feature: full v1 contract over a stub catalog - method/class/function targets, inheritance from parent and trait, lifecycle hook contract, instantiation rules, DataProvider/Retry/Repeat/fiber composition, summary arithmetic and the success of an only-parked run. Plus one narrow JUnitWriter case pinning that a Skipped result renders its failure message. Assisted-By: Claude Fable 5 --- plugin/test/tests/Feature/SkipFeatureTest.php | 210 +++++++++++++ plugin/test/tests/Feature/SkipSummaryTest.php | 77 +++++ plugin/test/tests/Stub/Skip/SkipChildStub.php | 16 + .../Stub/Skip/SkipClassAndMethodStub.php | 24 ++ .../tests/Stub/Skip/SkipClassLevelStub.php | 23 ++ .../Stub/Skip/SkipConstructorSpyStub.php | 33 ++ .../test/tests/Stub/Skip/SkipInFiberStub.php | 31 ++ .../test/tests/Stub/Skip/SkipMarkerTrait.php | 10 + .../test/tests/Stub/Skip/SkipMethodStub.php | 31 ++ .../tests/Stub/Skip/SkipNonStaticHookStub.php | 34 ++ .../test/tests/Stub/Skip/SkipParentStub.php | 14 + plugin/test/tests/Stub/Skip/SkipTraitStub.php | 18 ++ .../Stub/Skip/SkipWithDataProviderStub.php | 29 ++ .../tests/Stub/Skip/SkipWithHooksStub.php | 61 ++++ .../tests/Stub/Skip/SkipWithRepeatStub.php | 23 ++ .../tests/Stub/Skip/SkipWithRetryStub.php | 23 ++ .../test/tests/Stub/Skip/skip_functions.php | 23 ++ .../SkipSummary/Mixed/SummaryMixedStub.php | 49 +++ .../SkipSummary/OnlyParked/OnlyParkedStub.php | 26 ++ .../Unit/Fixture/SkipClassLevelFixture.php | 26 ++ .../Unit/Fixture/SkipMixedMethodsFixture.php | 28 ++ .../Unit/Internal/SkipInterceptorTest.php | 293 ++++++++++++++++++ plugin/test/tests/Unit/SkipAttributeTest.php | 67 ++++ tests/Output/Unit/JUnit/JUnitWriterTest.php | 23 ++ 24 files changed, 1192 insertions(+) create mode 100644 plugin/test/tests/Feature/SkipFeatureTest.php create mode 100644 plugin/test/tests/Feature/SkipSummaryTest.php create mode 100644 plugin/test/tests/Stub/Skip/SkipChildStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipClassLevelStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipInFiberStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipMarkerTrait.php create mode 100644 plugin/test/tests/Stub/Skip/SkipMethodStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipParentStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipTraitStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipWithHooksStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipWithRetryStub.php create mode 100644 plugin/test/tests/Stub/Skip/skip_functions.php create mode 100644 plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php create mode 100644 plugin/test/tests/Stub/SkipSummary/OnlyParked/OnlyParkedStub.php create mode 100644 plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php create mode 100644 plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php create mode 100644 plugin/test/tests/Unit/Internal/SkipInterceptorTest.php create mode 100644 plugin/test/tests/Unit/SkipAttributeTest.php diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php new file mode 100644 index 00000000..a879a0e2 --- /dev/null +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -0,0 +1,210 @@ +status, Status::Skipped); + Assert::true($result->failure instanceof SkipTest); + Assert::same( + $result->failure->getMessage(), + SkipMethodStub::class . '::parked is skipped via #[Skip] ==> broken by the pricing rework, see ISSUE-123', + ); + } + + public function emptyReasonFallsBackToGeneratedMessage(): void + { + $result = TestRunner::runTest([SkipMethodStub::class, 'parkedNoReason']); + + Assert::same($result->status, Status::Skipped); + Assert::same( + $result->failure?->getMessage(), + SkipMethodStub::class . '::parkedNoReason is skipped via #[Skip]', + ); + } + + public function controlNeighborNextToParkedTestsStillRuns(): void + { + $result = TestRunner::runTest([SkipMethodStub::class, 'enabled']); + + Assert::same($result->status, Status::Passed); + } + + public function classLevelSkipParksEveryTestWithClassReason(): void + { + $first = TestRunner::runTest([SkipClassLevelStub::class, 'firstParked']); + $second = TestRunner::runTest([SkipClassLevelStub::class, 'secondParked']); + + Assert::same($first->status, Status::Skipped); + Assert::same($second->status, Status::Skipped); + Assert::true(\str_ends_with((string) $first->failure?->getMessage(), ' ==> the whole case is parked')); + Assert::true(\str_ends_with((string) $second->failure?->getMessage(), ' ==> the whole case is parked')); + } + + public function methodReasonWinsOverClassReason(): void + { + $own = TestRunner::runTest([SkipClassAndMethodStub::class, 'ownReason']); + $inherited = TestRunner::runTest([SkipClassAndMethodStub::class, 'classReason']); + + Assert::true(\str_ends_with((string) $own->failure?->getMessage(), ' ==> method-specific reason')); + Assert::true(\str_ends_with((string) $inherited->failure?->getMessage(), ' ==> class-wide reason')); + } + + public function functionalTestUsesFunctionFqnInMessage(): void + { + $result = TestRunner::runTest('Tests\Test\Stub\Skip\parked_function'); + + Assert::same($result->status, Status::Skipped); + Assert::same( + $result->failure?->getMessage(), + 'Tests\Test\Stub\Skip\parked_function is skipped via #[Skip] ==> functional test is parked', + ); + } + + /** + * The origin contract for downstream consumers: a `#[Skip]`-parked result carries the + * attribute instances in `$result->info`, unlike a runtime `throw SkipTest` skip. + */ + public function parkedResultCarriesOriginAttribute(): void + { + $result = TestRunner::runTest([SkipMethodStub::class, 'parked']); + + $origin = $result->info->getAttribute(Skip::class); + Assert::true(\is_array($origin) && $origin !== []); + Assert::true($origin[0] instanceof Skip); + } + + /** + * The parked test is filtered out before the case runs: class-level hooks fire as usual + * (once per catalog run), per-test hooks fire only for the enabled control test. + */ + public function classHooksRunButTestHooksDoNot(): void + { + $beforeClass = SkipWithHooksStub::$beforeClass; + $afterClass = SkipWithHooksStub::$afterClass; + $beforeTest = SkipWithHooksStub::$beforeTest; + $afterTest = SkipWithHooksStub::$afterTest; + + $result = TestRunner::runTest([SkipWithHooksStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::same(SkipWithHooksStub::$beforeClass - $beforeClass, 1); + Assert::same(SkipWithHooksStub::$afterClass - $afterClass, 1); + # Only the enabled control test of the case went through the per-test pipeline. + Assert::same(SkipWithHooksStub::$beforeTest - $beforeTest, 1); + Assert::same(SkipWithHooksStub::$afterTest - $afterTest, 1); + } + + public function fullyParkedCaseWithoutHooksIsNeverInstantiated(): void + { + $result = TestRunner::runTest([SkipConstructorSpyStub::class, 'firstParked']); + + Assert::same($result->status, Status::Skipped); + Assert::false(SkipConstructorSpyStub::$constructed); + } + + /** + * Documented caveat: a non-static class-level hook builds the class even when every + * test is parked — pinned so a future change is conscious, not accidental. + */ + public function nonStaticClassHookStillBuildsTheClass(): void + { + $result = TestRunner::runTest([SkipNonStaticHookStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::true(SkipNonStaticHookStub::$constructed); + } + + public function classLevelSkipIsInheritedFromParent(): void + { + $result = TestRunner::runTest([SkipChildStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::true(\str_ends_with((string) $result->failure?->getMessage(), ' ==> inherited from the parent class')); + } + + public function classLevelSkipIsInheritedFromTrait(): void + { + $result = TestRunner::runTest([SkipTraitStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::true(\str_ends_with((string) $result->failure?->getMessage(), ' ==> inherited from the trait')); + } + + /** + * A data-driven parked test yields a single Skipped node: providers are not expanded + * (and not even called), no `MultipleResult` aggregate is attached. + */ + public function dataProviderIsNotExpandedForParkedTest(): void + { + $result = TestRunner::runTest([SkipWithDataProviderStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::null($result->getAttribute(MultipleResult::class)); + Assert::false(SkipWithDataProviderStub::$providerCalled); + } + + public function retryDoesNotEngageForParkedTest(): void + { + $attempts = SkipWithRetryStub::$attempts; + + $result = TestRunner::runTest([SkipWithRetryStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::same(SkipWithRetryStub::$attempts - $attempts, 0); + } + + public function repeatDoesNotEngageForParkedTest(): void + { + $result = TestRunner::runTest([SkipWithRepeatStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::false(SkipWithRepeatStub::$bodyRan); + } + + /** + * Fiber compatibility: the skip interceptor wraps the fiber batch runner instead of + * replacing it — the enabled test still runs on the scheduler, the parked one is skipped. + */ + public function fiberBatchRunnerSurvivesTheWrap(): void + { + $enabled = TestRunner::runTest([SkipInFiberStub::class, 'enabled']); + $parked = TestRunner::runTest([SkipInFiberStub::class, 'parked']); + + Assert::same($enabled->status, Status::Passed); + Assert::same($parked->status, Status::Skipped); + } +} diff --git a/plugin/test/tests/Feature/SkipSummaryTest.php b/plugin/test/tests/Feature/SkipSummaryTest.php new file mode 100644 index 00000000..bd80b988 --- /dev/null +++ b/plugin/test/tests/Feature/SkipSummaryTest.php @@ -0,0 +1,77 @@ +summary; + + Assert::same($summary->count(Status::Passed), 1); + Assert::same($summary->count(Status::Failed), 1); + Assert::same($summary->count(Status::Skipped), 2); + Assert::same( + $summary->total(), + $summary->passed() + $summary->failed() + $summary->count(Status::Skipped), + ); + } + + public function failingNeighborStillFailsTheRun(): void + { + $result = self::run(__DIR__ . '/../Stub/SkipSummary/Mixed'); + + Assert::same($result->status, Status::Failed); + } + + /** + * A run consisting only of `#[Skip]`-marked tests is a success: Skipped is neither a + * success nor a failure, so nothing fails the run. + */ + public function runOfOnlyParkedTestsIsSuccessful(): void + { + $result = self::run(__DIR__ . '/../Stub/SkipSummary/OnlyParked'); + + Assert::same($result->status, Status::Passed); + Assert::same($result->summary->count(Status::Skipped), 2); + Assert::same($result->summary->total(), 2); + } + + private static function run(string $catalog): RunResult + { + return Application::createFromConfig(new ApplicationConfig( + src: [], + suites: [ + new SuiteConfig( + 'SkipSummary', + location: new FinderConfig(include: [$catalog]), + ), + ], + ))->run(); + } +} diff --git a/plugin/test/tests/Stub/Skip/SkipChildStub.php b/plugin/test/tests/Stub/Skip/SkipChildStub.php new file mode 100644 index 00000000..2a113cfd --- /dev/null +++ b/plugin/test/tests/Stub/Skip/SkipChildStub.php @@ -0,0 +1,16 @@ +runTestCase($info, self::coreNext($seenTests)); + + Assert::same($seenTests, ['enabled']); + } + + /** + * The parked tests still come back in the case result — as synthetic Skipped results + * with a SkipTest failure and a self-stamped summary. + */ + public function returnsSyntheticSkippedResults(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked', 'enabled'); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + $parked = self::findResult($result, 'parked'); + Assert::same($parked->status, Status::Skipped); + Assert::true($parked->failure instanceof SkipTest); + Assert::same($parked->summary->count(Status::Skipped), 1); + Assert::same($result->summary->count(Status::Skipped), 1); + Assert::same($result->summary->count(Status::Passed), 1); + } + + public function composesReasonAfterGeneratedPart(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked'); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + Assert::same( + self::findResult($result, 'parked')->failure?->getMessage(), + SkipMixedMethodsFixture::class + . '::parked is skipped via #[Skip] ==> broken by the pricing rework, see ISSUE-123', + ); + } + + /** + * An empty reason falls back to the generated part alone — no reporter ever shows an + * empty skip message. + */ + public function fallsBackToGeneratedMessageWithoutReason(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parkedNoReason'); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + Assert::same( + self::findResult($result, 'parkedNoReason')->failure?->getMessage(), + SkipMixedMethodsFixture::class . '::parkedNoReason is skipped via #[Skip]', + ); + } + + /** + * The origin contract: a `#[Skip]`-parked result carries the attribute instances in its + * info, so downstream consumers can tell a declarative skip from a runtime one. + */ + public function stampsOriginAttributeOnSyntheticInfo(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked', 'enabled'); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + $origin = self::findResult($result, 'parked')->info->getAttribute(Skip::class); + Assert::true(\is_array($origin) && $origin !== []); + Assert::true($origin[0] instanceof Skip); + Assert::same($origin[0]->reason, 'broken by the pricing rework, see ISSUE-123'); + Assert::null(self::findResult($result, 'enabled')->info->getAttribute(Skip::class)); + } + + public function classLevelSkipParksEveryTest(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipClassLevelFixture::class, 'first', 'second'); + $seenTests = null; + + $result = $interceptor->runTestCase($info, self::coreNext($seenTests)); + + Assert::same($seenTests, []); + Assert::same(self::findResult($result, 'first')->status, Status::Skipped); + Assert::same(self::findResult($result, 'second')->status, Status::Skipped); + } + + public function methodReasonWinsOverClassReason(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipClassLevelFixture::class, 'first', 'second'); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + Assert::true(\str_ends_with( + (string) self::findResult($result, 'first')->failure?->getMessage(), + ' ==> entire case is parked', + )); + Assert::true(\str_ends_with( + (string) self::findResult($result, 'second')->failure?->getMessage(), + ' ==> method beats class', + )); + } + + /** + * A case with no parked tests passes through untouched: same test set, no batch runner + * installed. + */ + public function passesThroughCaseWithoutParkedTests(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'enabled'); + $batchRunner = false; + + $interceptor->runTestCase($info, static function (CaseInfo $inner) use (&$batchRunner): CaseResult { + $batchRunner = $inner->batchRunner; + return new CaseResult(results: [], status: Status::Passed); + }); + + Assert::null($batchRunner); + } + + /** + * A batch runner already installed by an outer interceptor (e.g. testo/fiber's) keeps + * driving the remaining tests — the wrapper wraps it instead of replacing it. + */ + public function wrapsExistingBatchRunnerInsteadOfReplacing(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $innerRunnerCalls = 0; + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked', 'enabled') + ->withBatchRunner(static function (array $handlers) use (&$innerRunnerCalls): array { + ++$innerRunnerCalls; + return \array_map(static fn(callable $handler): TestResult => $handler(), $handlers); + }); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + Assert::same($innerRunnerCalls, 1); + Assert::same(self::findResult($result, 'enabled')->status, Status::Passed); + Assert::same(self::findResult($result, 'parked')->status, Status::Skipped); + } + + /** + * Reporters render test lines from the pipeline events, so the interceptor dispatches + * them for every synthetic result. + */ + public function dispatchesPipelineEventsForParkedTests(): void + { + $dispatcher = self::createDispatcher(); + $interceptor = new SkipInterceptor($dispatcher); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked'); + + $interceptor->runTestCase($info, self::coreNext()); + + /** @psalm-suppress UndefinedPropertyFetch The anonymous dispatcher exposes $dispatched. */ + $events = $dispatcher->dispatched; + $starting = \array_values(\array_filter($events, static fn(object $e): bool => $e instanceof TestPipelineStarting)); + $finished = \array_values(\array_filter($events, static fn(object $e): bool => $e instanceof TestPipelineFinished)); + + Assert::count($starting, 1); + Assert::count($finished, 1); + Assert::same($starting[0]->testInfo->name, 'parked'); + Assert::same($finished[0]->testResult->status, Status::Skipped); + } + + private static function createDispatcher(): EventDispatcherInterface + { + return new class implements EventDispatcherInterface { + /** @var list */ + public array $dispatched = []; + + #[\Override] + public function dispatch(object $event): object + { + $this->dispatched[] = $event; + return $event; + } + }; + } + + /** + * @param class-string $class + * @param non-empty-string ...$methods + */ + private static function createCaseInfo(string $class, string ...$methods): CaseInfo + { + $definitions = []; + foreach ($methods as $method) { + $definitions[$method] = new TestDefinition(new \ReflectionMethod($class, $method)); + } + + $caseDefinition = new CaseDefinition( + name: $class, + type: 'test', + file: Path::create(__FILE__), + reflection: new \ReflectionClass($class), + tests: TestDefinitions::fromArray(...$definitions), + ); + + return new CaseInfo(definition: $caseDefinition, suiteIdentity: new SuiteIdentity('Test/Unit')); + } + + /** + * A `$next` that mimics the core case loop: runs the surviving tests as Passed through + * the case's batch runner (or inline without one) and aggregates the case summary. + * + * @param list|null $seenTests Filled with the test names that survived to `$next`. + */ + private static function coreNext(?array &$seenTests = null): \Closure + { + return static function (CaseInfo $info) use (&$seenTests): CaseResult { + $seenTests = \array_keys($info->definition->tests->getTests()); + + $handlers = []; + foreach ($info->definition->tests->getTests() as $name => $definition) { + $handlers[] = static fn(): TestResult => new TestResult( + info: new TestInfo(name: $name, caseInfo: $info, testDefinition: $definition), + status: Status::Passed, + summary: Summary::forTest(Status::Passed), + ); + } + + $runner = $info->batchRunner; + /** @var list $results */ + $results = $runner === null + ? \array_map(static fn(\Closure $handler): TestResult => $handler(), $handlers) + : $runner($handlers); + + return new CaseResult( + results: $results, + status: Status::Passed, + summary: Summary::combine(\array_map(static fn(TestResult $r): Summary => $r->summary, $results)), + ); + }; + } + + private static function findResult(CaseResult $result, string $name): TestResult + { + foreach ($result as $testResult) { + if ($testResult->info->name === $name) { + return $testResult; + } + } + + throw new \LogicException("No result for test {$name}."); + } +} diff --git a/plugin/test/tests/Unit/SkipAttributeTest.php b/plugin/test/tests/Unit/SkipAttributeTest.php new file mode 100644 index 00000000..59c34f91 --- /dev/null +++ b/plugin/test/tests/Unit/SkipAttributeTest.php @@ -0,0 +1,67 @@ +reason, ''); + } + + public function customReason(): void + { + $skip = new Skip('flaky on CI, see ISSUE-123'); + + Assert::same($skip->reason, 'flaky on CI, see ISSUE-123'); + } + + public function namedReasonArgument(): void + { + $skip = new Skip(reason: 'named'); + + Assert::same($skip->reason, 'named'); + } + + public function targetsClassMethodAndFunction(): void + { + $flags = self::attributeFlags(); + + Assert::same($flags & \Attribute::TARGET_CLASS, \Attribute::TARGET_CLASS); + Assert::same($flags & \Attribute::TARGET_METHOD, \Attribute::TARGET_METHOD); + Assert::same($flags & \Attribute::TARGET_FUNCTION, \Attribute::TARGET_FUNCTION); + } + + /** + * A skip carries a single reason — a second attribute on the same target has nowhere + * to go, so PHP itself rejects the duplicate at reflection time. + */ + public function isNotRepeatable(): void + { + Assert::same(self::attributeFlags() & \Attribute::IS_REPEATABLE, 0); + } + + private static function attributeFlags(): int + { + $attributes = (new \ReflectionClass(Skip::class))->getAttributes(\Attribute::class); + + /** @var \Attribute $attribute */ + $attribute = $attributes[0]->newInstance(); + + return $attribute->flags; + } +} diff --git a/tests/Output/Unit/JUnit/JUnitWriterTest.php b/tests/Output/Unit/JUnit/JUnitWriterTest.php index 77a4e4cc..3f6c04bb 100644 --- a/tests/Output/Unit/JUnit/JUnitWriterTest.php +++ b/tests/Output/Unit/JUnit/JUnitWriterTest.php @@ -18,6 +18,7 @@ use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; use Testo\Core\Definition\TestDefinition; +use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; use Testo\Output\JUnit\Internal\JUnitWriter; use Testo\Test; @@ -163,6 +164,28 @@ public function skippedTestRendersSkippedElement(): void Assert::count($xml->testsuite->testcase->skipped, 1); } + public function skippedTestCarriesTheReasonFromTheFailureMessage(): void + { + // Arrange: the failure message is the single source of truth for the skip reason — + // every producer (a runtime throw, a declarative skip) delivers it the same way. + $writer = new JUnitWriter(); + $writer->startSuite('MySuite'); + $writer->addTestResult(self::makeResult( + 'passingTest', + Status::Skipped, + failure: new SkipTest('sqlite extension is missing'), + )); + $writer->finishSuite(); + + // Act + $xml = self::loadXml($writer->generate('Testo')); + + // Assert + $skipped = $xml->testsuite->testcase->skipped; + Assert::count($skipped, 1); + Assert::same((string) $skipped['message'], 'sqlite extension is missing'); + } + public function cancelledTestCountsAsSkipped(): void { // Arrange From dd86e2ce52041718a6a565f01c2c07e2d280b074 Mon Sep 17 00:00:00 2001 From: Meacue Date: Wed, 2 Sep 2026 17:14:43 +0500 Subject: [PATCH 03/35] docs(skills): document #[Skip] in write-tests, plugin-author and flaky-tests write-tests gains a "Parking a test" section with the skip-tool comparison table and the runtime contract; plugin-author points to SkipInterceptor as the canonical shipped "return, do not throw" example; flaky-tests adds the parking branch to the decision flow. Assisted-By: Claude Fable 5 --- skills/testo-flaky-tests/SKILL.md | 2 ++ skills/testo-plugin-author/SKILL.md | 5 +++++ skills/testo-write-tests/SKILL.md | 33 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/skills/testo-flaky-tests/SKILL.md b/skills/testo-flaky-tests/SKILL.md index e1d4e440..4dcc1c85 100644 --- a/skills/testo-flaky-tests/SKILL.md +++ b/skills/testo-flaky-tests/SKILL.md @@ -87,6 +87,8 @@ Don't ship `#[Repeat(times: 50)]` long-term on a fast suite — CI cost adds up. - Use `#[Repeat]`, never `#[Retry]`. 3. Is the flakiness from shared state inside the suite (ordering)? - Don't reach for either attribute. Fix isolation (lifecycle hooks, fresh fixtures). +4. Parking the test for a longer while (root cause known but not fixable now)? + - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays visible in reports as Skipped with the reason. `#[Retry]` is for stabilizing, not parking. ## Pitfalls diff --git a/skills/testo-plugin-author/SKILL.md b/skills/testo-plugin-author/SKILL.md index 9fb0310a..2167be24 100644 --- a/skills/testo-plugin-author/SKILL.md +++ b/skills/testo-plugin-author/SKILL.md @@ -172,6 +172,11 @@ if (!$reachable) { } ``` +The canonical shipped example is `Testo\Test\Internal\SkipInterceptor` (`plugin/test`): a +case-level interceptor that filters `#[Skip]`-marked tests out of the case before lifecycle +hooks and returns synthetic Skipped results for them — constructing each `TestResult` by hand +(status, `SkipTest` failure, self-stamped `Summary::forTest(...)`) instead of throwing. + ## Container scopes — provision per-case / per-suite resources `$container->scope($closure)` runs `$closure` in a **child scope**: services bound inside live only for diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index c76bd286..69b79402 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -136,6 +136,39 @@ Constraints: - Subclasses work: `class MissingExtensionSkip extends SkipTest {}` is still recognized. - Return type stays `void`, or `never` if the throw is unconditional. +## Parking a test: `#[Skip]` + +To skip a test declaratively — without running any of its code — put `Testo\Test\Skip` on the +test method, the class (skips every test of the case; inherited from parents and traits, a +method-level reason wins), or a free function: + +```php +use Testo\Test\Skip; + +#[Test] +#[Skip('broken by the pricing rework, see ISSUE-123')] +public function calculatesTotal(): void { ... } // reported as Skipped, body never runs +``` + +The test stays visible in every report as `Status::Skipped` with the message +`{testId} is skipped via #[Skip] ==> {reason}` (without ` ==> ...` when the reason is empty). +`reason` is optional and the attribute is not repeatable. + +Which skipping tool to reach for: + +| Tool | Decided by | Visibility | Use when | +|---|---|---|---| +| `#[Skip('...')]` | code, ahead of time | always reported, with reason | test is parked and must be returned to | +| `throw SkipTest` | test body, at runtime | reported when the run gets there | test isn't applicable in this environment | +| `#[Group]` + `--group=!x` | runner invocation | invisible — filtered out of reports | a category you sometimes don't run | + +Runtime contract of `#[Skip]`: the test never enters the per-test pipeline, so +`#[BeforeTest]`/`#[AfterTest]`, data providers, `#[Retry]`/`#[Repeat]` and coverage never +engage, and a data-driven test yields a single Skipped entry. `#[BeforeClass]`/`#[AfterClass]` +still run (also when every test of the case is parked), and the case class is only +instantiated if a non-static class-level hook forces it. A run of only `#[Skip]`-marked +tests is a success (exit 0). + ## Tests that intentionally perform no assertions A test that finishes successfully without recording a single assertion is reported as From e0e2858e7e87e59cc36dc60b4903ebf2b713851a Mon Sep 17 00:00:00 2001 From: Meacue Date: Wed, 2 Sep 2026 17:14:43 +0500 Subject: [PATCH 04/35] build: prepare root constraints for the testo/test 0.2.0 release feat bumps testo/test 0.1.6 -> 0.2.0 on release: widen the root require to "0.1.6 - 1" so composer resolves both before and after the release, and move the path-repository version to 0.2.x-dev per docs/spec/plugin-creation.md. Assisted-By: Claude Fable 5 --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 652996fd..ce80aafa 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "testo/lifecycle": "^0.1.6", "testo/repeat": "^0.1.9", "testo/retry": "^0.1.5", - "testo/test": "^0.1.7", + "testo/test": "0.1.7 - 1", "yiisoft/injector": "^1.2" }, "require-dev": { @@ -129,7 +129,7 @@ "testo/lifecycle": "0.1.x-dev", "testo/repeat": "0.1.x-dev", "testo/retry": "0.1.x-dev", - "testo/test": "0.1.x-dev" + "testo/test": "0.2.x-dev" } } }, From 708787cfabf14d0e6139e6f0c4ea2eb03147898d Mon Sep 17 00:00:00 2001 From: Meacue Date: Wed, 2 Sep 2026 20:40:30 +0500 Subject: [PATCH 05/35] fix(test): mark #[Skip] as Interceptable with SkipInterceptor as fallback Review feedback on #314: the attribute now implements Interceptable and declares #[FallbackInterceptor(SkipInterceptor::class)], mirroring Retry. The interface alone would break class-level usage (an Interceptable without a fallback alias makes the attributes interceptor throw at pipeline build), so the pair goes together. The TestPlugin registration stays: the case-level fallback path only reads class attributes, so a method-level #[Skip] still needs the registered instance; the duplicate spawn for class-level cases is collapsed by the sorter's conflict policy. Assisted-By: Claude Fable 5 --- plugin/test/src/Skip.php | 13 ++++++++--- plugin/test/tests/Unit/SkipAttributeTest.php | 24 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/plugin/test/src/Skip.php b/plugin/test/src/Skip.php index 65567ed8..e25072e4 100644 --- a/plugin/test/src/Skip.php +++ b/plugin/test/src/Skip.php @@ -6,6 +6,9 @@ use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; +use Testo\Pipeline\Attribute\FallbackInterceptor; +use Testo\Pipeline\Attribute\Interceptable; +use Testo\Test\Internal\SkipInterceptor; /** * Marks a test as skipped without deleting or hiding it. @@ -53,8 +56,11 @@ * Skipped is neither a success nor a failure. * - On a non-test method the attribute is inert (like `#[Group]` on a helper). * - * The attribute is a plain marker: {@see Internal\SkipInterceptor} (registered by - * {@see TestPlugin}) looks it up itself and reports the synthetic Skipped results. + * The attribute is handled by {@see SkipInterceptor} — registered by {@see TestPlugin} + * and declared as the {@see FallbackInterceptor} for standalone use. The interceptor does + * its own lookup over the case's tests (a case-level fallback would not see a method-level + * attribute), so the registered instance covers every target; the fallback spawn is + * deduplicated by the pipeline's conflict policy. * For skipping at runtime — from the test body, based on the environment — throw * {@see SkipTest} instead. * @@ -64,7 +70,8 @@ * @api */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] -final readonly class Skip +#[FallbackInterceptor(SkipInterceptor::class)] +final readonly class Skip implements Interceptable { /** * @param string $reason Why the test is parked. Optional, but a reference to an issue diff --git a/plugin/test/tests/Unit/SkipAttributeTest.php b/plugin/test/tests/Unit/SkipAttributeTest.php index 59c34f91..e293ae0d 100644 --- a/plugin/test/tests/Unit/SkipAttributeTest.php +++ b/plugin/test/tests/Unit/SkipAttributeTest.php @@ -6,7 +6,10 @@ use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Pipeline\Attribute\FallbackInterceptor; +use Testo\Pipeline\Attribute\Interceptable; use Testo\Test; +use Testo\Test\Internal\SkipInterceptor; use Testo\Test\Skip; /** @@ -55,6 +58,27 @@ public function isNotRepeatable(): void Assert::same(self::attributeFlags() & \Attribute::IS_REPEATABLE, 0); } + /** + * The pipeline collects `Interceptable` attributes; without the marker a class-level + * `#[Skip]` would be invisible to the attributes interceptor. + */ + public function isInterceptable(): void + { + Assert::true(\is_a(Skip::class, Interceptable::class, true)); + } + + /** + * An `Interceptable` attribute must resolve to an interceptor, or the attributes + * interceptor throws at pipeline build time; the fallback names the handler. + */ + public function declaresSkipInterceptorAsFallback(): void + { + $attributes = (new \ReflectionClass(Skip::class))->getAttributes(FallbackInterceptor::class); + + Assert::count($attributes, 1); + Assert::same($attributes[0]->newInstance()->class, SkipInterceptor::class); + } + private static function attributeFlags(): int { $attributes = (new \ReflectionClass(Skip::class))->getAttributes(\Attribute::class); From 62a12da61c9143c23f1bbc2c012ee8f9e1b135b3 Mon Sep 17 00:00:00 2001 From: Meacue Date: Thu, 3 Sep 2026 02:51:41 +0500 Subject: [PATCH 06/35] refactor(test): declare SkipInterceptor conflict policy explicitly Class-level #[Skip] spawns a duplicate interceptor instance through the fallback alias; ConflictPolicy::First (previously implicit) collapses it onto the instance registered by TestPlugin. Spell the policy out and explain why, mirroring DataProviderInterceptor. Assisted-By: Claude Fable 5 --- plugin/test/src/Internal/SkipInterceptor.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index 2416ff09..1bdd4471 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -19,6 +19,7 @@ use Testo\Event\Test\TestPipelineStarting; use Testo\Pipeline\Attribute\InterceptorOptions; use Testo\Pipeline\Middleware\TestCaseRunInterceptor; +use Testo\Pipeline\Policy\ConflictPolicy; use Testo\Test\Skip; use Testo\Test\TestPlugin; @@ -49,6 +50,11 @@ */ #[InterceptorOptions( order: InterceptorOptions::ORDER_DEFAULT, + # A class-level #[Skip] spawns a second instance of this interceptor through the fallback + # alias, next to the one registered by TestPlugin; First collapses the duplicate onto the + # registered one. The interceptor is stateless, so either instance would do — keeping the + # registered one preserves its stable position in the chain. + onConflict: ConflictPolicy::First, testType: TestType::Test, )] final readonly class SkipInterceptor implements TestCaseRunInterceptor From e766061d064b2957e1b45f83b6307f12afc40b25 Mon Sep 17 00:00:00 2001 From: Meacue Date: Thu, 3 Sep 2026 02:52:00 +0500 Subject: [PATCH 07/35] docs(test): correct the fallback coverage claim in the Skip docblock The fallback interceptor is spawned from class attributes only (AttributesInterceptor::runTestCase), so without TestPlugin it rescues a class-level #[Skip] alone; a method- or function-level #[Skip] is inert in that setup. The previous wording implied full standalone coverage. Assisted-By: Claude Fable 5 --- plugin/test/src/Skip.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/plugin/test/src/Skip.php b/plugin/test/src/Skip.php index e25072e4..287e5c84 100644 --- a/plugin/test/src/Skip.php +++ b/plugin/test/src/Skip.php @@ -56,11 +56,12 @@ * Skipped is neither a success nor a failure. * - On a non-test method the attribute is inert (like `#[Group]` on a helper). * - * The attribute is handled by {@see SkipInterceptor} — registered by {@see TestPlugin} - * and declared as the {@see FallbackInterceptor} for standalone use. The interceptor does - * its own lookup over the case's tests (a case-level fallback would not see a method-level - * attribute), so the registered instance covers every target; the fallback spawn is - * deduplicated by the pipeline's conflict policy. + * The attribute is handled by {@see SkipInterceptor}, registered by {@see TestPlugin}. + * It is also declared as the {@see FallbackInterceptor}, which covers a class-level + * `#[Skip]` when the plugin is not registered — a case-level fallback is spawned from + * class attributes only. A method- or function-level `#[Skip]` needs the TestPlugin + * registration; without it the attribute is inert. When both paths are live, the + * duplicate spawn is collapsed by the interceptor's `ConflictPolicy::First`. * For skipping at runtime — from the test body, based on the environment — throw * {@see SkipTest} instead. * From 29eb90027f4ee6213cbe9889d769a89b00465a74 Mon Sep 17 00:00:00 2001 From: Meacue Date: Thu, 3 Sep 2026 02:52:13 +0500 Subject: [PATCH 08/35] docs(test): explain why SkipInterceptor takes no Skip constructor parameter The instance is built both by the container (TestPlugin registration, where an attribute cannot be resolved) and by the injector on a fallback spawn (where the attribute is passed and ignored); attributes are looked up per case in findParked(). A Skip parameter would break the container path at pipeline construction. Assisted-By: Claude Fable 5 --- plugin/test/src/Internal/SkipInterceptor.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index 1bdd4471..63df3d49 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -59,6 +59,13 @@ )] final readonly class SkipInterceptor implements TestCaseRunInterceptor { + /** + * Intentionally takes no {@see Skip} parameter (contrast `RetryPolicyRunInterceptor`): + * the instance is built on two paths — by the container on the {@see TestPlugin} + * registration (`container->get()` cannot resolve an attribute) and by the injector on + * a fallback spawn (the attribute arrives in the arguments and is silently ignored). + * The attributes are looked up per case in {@see self::findParked()} instead. + */ public function __construct( private EventDispatcherInterface $eventDispatcher, ) {} From 93f8580561fa44f43b634ac3e867dc5041c4d1d5 Mon Sep 17 00:00:00 2001 From: Meacue Date: Thu, 3 Sep 2026 02:52:49 +0500 Subject: [PATCH 09/35] docs(test): document that #[Skip] is inert on bench and inline cases test(test): pin the declared testType of SkipInterceptor SkipInterceptor declares testType: TestType::Test, so the type filter drops it for #[Bench] and #[TestInline] cases and the attribute has no effect there. Add the point to the Skip runtime contract and the write-tests skill, and pin the declaration with a unit test. Assisted-By: Claude Fable 5 --- plugin/test/src/Skip.php | 3 +++ .../tests/Unit/Internal/SkipInterceptorTest.php | 15 +++++++++++++++ skills/testo-write-tests/SKILL.md | 3 ++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/plugin/test/src/Skip.php b/plugin/test/src/Skip.php index 287e5c84..753a3ba2 100644 --- a/plugin/test/src/Skip.php +++ b/plugin/test/src/Skip.php @@ -55,6 +55,9 @@ * - A run consisting only of `#[Skip]`-marked tests is successful (exit code 0): * Skipped is neither a success nor a failure. * - On a non-test method the attribute is inert (like `#[Group]` on a helper). + * - Only plain test cases are handled ({@see SkipInterceptor} declares + * `testType: TestType::Test`): on a `#[Bench]` or `#[TestInline]` target the attribute + * is inert — the benchmark or inline case runs as usual. * * The attribute is handled by {@see SkipInterceptor}, registered by {@see TestPlugin}. * It is also declared as the {@see FallbackInterceptor}, which covers a class-level diff --git a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php index c87e60fa..b85038bf 100644 --- a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php +++ b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php @@ -19,8 +19,10 @@ use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; use Testo\Core\Value\Summary; +use Testo\Core\Value\TestType; use Testo\Event\Test\TestPipelineFinished; use Testo\Event\Test\TestPipelineStarting; +use Testo\Pipeline\Attribute\InterceptorOptions; use Testo\Test; use Testo\Test\Internal\SkipInterceptor; use Testo\Test\Skip; @@ -209,6 +211,19 @@ public function dispatchesPipelineEventsForParkedTests(): void Assert::same($finished[0]->testResult->status, Status::Skipped); } + /** + * `#[Skip]` is a plain-test feature: the interceptor declares `testType: TestType::Test`, + * so on a bench or inline case the type filter drops it and the attribute is inert. + */ + public function declaresTestTypeScopingSkipToPlainTests(): void + { + $attributes = (new \ReflectionClass(SkipInterceptor::class)) + ->getAttributes(InterceptorOptions::class); + + Assert::count($attributes, 1); + Assert::same($attributes[0]->newInstance()->testType, TestType::Test); + } + private static function createDispatcher(): EventDispatcherInterface { return new class implements EventDispatcherInterface { diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index 69b79402..3f8da700 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -167,7 +167,8 @@ Runtime contract of `#[Skip]`: the test never enters the per-test pipeline, so engage, and a data-driven test yields a single Skipped entry. `#[BeforeClass]`/`#[AfterClass]` still run (also when every test of the case is parked), and the case class is only instantiated if a non-static class-level hook forces it. A run of only `#[Skip]`-marked -tests is a success (exit 0). +tests is a success (exit 0). `#[Skip]` applies to plain tests only: on a `#[Bench]` or +`#[TestInline]` target it is inert — the benchmark or inline case runs as usual. ## Tests that intentionally perform no assertions From 9f44b4a2823d58460445b4823838929c8c63c409 Mon Sep 17 00:00:00 2001 From: Meacue Date: Thu, 3 Sep 2026 02:53:24 +0500 Subject: [PATCH 10/35] fix(test): carry the test description into the synthetic Skipped result TerminalLogger reads the description from the result attributes, where the regular test path stamps it; the synthetic result built for a parked test lacked it, so the PHPDoc description was not rendered. Stamp TestDefinition::getDescription() the way TestRunner does. Assisted-By: Claude Fable 5 --- plugin/test/src/Internal/SkipInterceptor.php | 2 +- .../Unit/Fixture/SkipMixedMethodsFixture.php | 3 +++ .../tests/Unit/Internal/SkipInterceptorTest.php | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index 63df3d49..a10115a2 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -150,7 +150,7 @@ private function reportSkipped( info: $testInfo, status: Status::Skipped, failure: new SkipTest(self::reason($testInfo, $attribute)), - attributes: ['duration' => 0], + attributes: ['duration' => 0, 'description' => $definition->getDescription()], summary: Summary::forTest(Status::Skipped), ); diff --git a/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php b/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php index bc23cb70..e682ebc6 100644 --- a/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php +++ b/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php @@ -12,6 +12,9 @@ */ final class SkipMixedMethodsFixture { + /** + * Checks that order totals include the reworked pricing. + */ #[Skip('broken by the pricing rework, see ISSUE-123')] public function parked(): void { diff --git a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php index b85038bf..3d70adc5 100644 --- a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php +++ b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php @@ -211,6 +211,23 @@ public function dispatchesPipelineEventsForParkedTests(): void Assert::same($finished[0]->testResult->status, Status::Skipped); } + /** + * The terminal renders a test's PHPDoc description from the result attributes (as the + * regular test path stamps it), so the synthetic result must carry it too. + */ + public function carriesDescriptionInSyntheticResult(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked'); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + Assert::same( + self::findResult($result, 'parked')->attributes['description'], + 'Checks that order totals include the reworked pricing.', + ); + } + /** * `#[Skip]` is a plain-test feature: the interceptor declares `testType: TestType::Test`, * so on a bench or inline case the type filter drops it and the attribute is inert. From 9cc7603af5da994c96e76136d55343b31ce5d9b8 Mon Sep 17 00:00:00 2001 From: Meacue Date: Thu, 3 Sep 2026 02:54:39 +0500 Subject: [PATCH 11/35] test(test): cover class-level #[Skip] standalone fallback without TestPlugin Every existing feature test runs through suite defaults where TestPlugin is registered, so the FallbackInterceptor declaration itself was never exercised. Run a convention-discovered catalog with SuitePlugins::without(TestPlugin::class) and assert the class-level #[Skip] still parks every test, with exactly one result per test. Assisted-By: Claude Fable 5 --- .../Feature/SkipFallbackStandaloneTest.php | 62 +++++++++++++++++++ .../SkipStandalone/StandaloneParkedTest.php | 27 ++++++++ 2 files changed, 89 insertions(+) create mode 100644 plugin/test/tests/Feature/SkipFallbackStandaloneTest.php create mode 100644 plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php diff --git a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php b/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php new file mode 100644 index 00000000..49b24476 --- /dev/null +++ b/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php @@ -0,0 +1,62 @@ +with(new NamingConventionPlugin()), + ), + ], + ))->run(); + + /** @var list $tests */ + $tests = []; + foreach ($run as $suite) { + foreach ($suite as $case) { + foreach ($case as $test) { + $tests[] = $test; + } + } + } + + # Exactly one result per stub test: the fallback spawn does not duplicate delivery. + Assert::count($tests, 2); + foreach ($tests as $test) { + Assert::same($test->status, Status::Skipped); + Assert::true(\str_contains((string) $test->failure?->getMessage(), ' ==> ')); + } + } +} diff --git a/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php b/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php new file mode 100644 index 00000000..1029dc73 --- /dev/null +++ b/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php @@ -0,0 +1,27 @@ + Date: Thu, 3 Sep 2026 02:55:12 +0500 Subject: [PATCH 12/35] test(test): pin single delivery of class-level #[Skip] with both paths live The dedup invariant (registered interceptor + fallback spawn collapse into one delivery) was held only by the arithmetic of the summary tests. Name it: a full-application run of a class-level parked catalog yields exactly one result per test in its CaseResult. Assisted-By: Claude Fable 5 --- plugin/test/tests/Feature/SkipSummaryTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/plugin/test/tests/Feature/SkipSummaryTest.php b/plugin/test/tests/Feature/SkipSummaryTest.php index bd80b988..786077a9 100644 --- a/plugin/test/tests/Feature/SkipSummaryTest.php +++ b/plugin/test/tests/Feature/SkipSummaryTest.php @@ -62,6 +62,32 @@ public function runOfOnlyParkedTestsIsSuccessful(): void Assert::same($result->summary->total(), 2); } + /** + * The dedup invariant, pinned by name: with `TestPlugin` registered, a class-level + * `#[Skip]` also spawns a fallback instance of the interceptor, and the conflict policy + * must collapse the duplicate — each parked test yields exactly one result, not one per + * delivery path. + */ + public function classLevelSkipIsNotHandledTwice(): void + { + $run = self::run(__DIR__ . '/../Stub/SkipSummary/OnlyParked'); + + $cases = []; + foreach ($run as $suite) { + foreach ($suite as $case) { + $cases[] = $case; + } + } + + # The catalog holds one class with two parked tests. + Assert::count($cases, 1); + $results = \iterator_to_array($cases[0], preserve_keys: false); + Assert::count($results, 2); + $names = \array_map(static fn($result) => $result->info->name, $results); + \sort($names); + Assert::same($names, ['firstParked', 'secondParked']); + } + private static function run(string $catalog): RunResult { return Application::createFromConfig(new ApplicationConfig( From ce847446fab81a002a72a75de128fb5762d7ad3c Mon Sep 17 00:00:00 2001 From: Meacue Date: Fri, 4 Sep 2026 18:11:34 +0500 Subject: [PATCH 13/35] test(lifecycle): pin class-level hooks for fully parked cases end-to-end Integration proof of the #[Skip] contract documented on the attribute: BeforeClass/AfterClass hooks still run when every test of the case is parked. Drives the real pipeline over a function-based and a class-based catalog and checks the hooks fire exactly once while the per-test hooks stay silent. Complements the Skip-independent unit regression test that landed with the lifecycle fix: that one pins hook discovery on a pruned case in isolation, this one pins the contract with the actual #[Skip] interceptor doing the pruning. Assisted-By: Claude Fable 5 --- .../Feature/FullyParkedCaseFeatureTest.php | 75 +++++++++++++++++++ .../Stub/FullyParked/FullyParkedClassStub.php | 43 +++++++++++ .../FullyParked/fully_parked_functions.php | 69 +++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php create mode 100644 plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php create mode 100644 plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php diff --git a/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php b/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php new file mode 100644 index 00000000..84139b3e --- /dev/null +++ b/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php @@ -0,0 +1,75 @@ +status, Status::Skipped); + Assert::same(FullyParkedFunctionState::$beforeClassCalls - $beforeClass, 1); + Assert::same(FullyParkedFunctionState::$afterClassCalls - $afterClass, 1); + # No test of the case ran, so the per-test hooks never fired. + Assert::same(FullyParkedFunctionState::$beforeTestCalls - $beforeTest, 0); + Assert::same(FullyParkedFunctionState::$afterTestCalls - $afterTest, 0); + } + + /** + * The class-based analog: hooks come from the case's class reflection and must keep firing + * for a fully parked class exactly as before. + */ + public function classHooksRunForFullyParkedClassCase(): void + { + $beforeClass = FullyParkedClassStub::$beforeClassCalls; + $afterClass = FullyParkedClassStub::$afterClassCalls; + + $result = TestRunner::runTest([FullyParkedClassStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::same(FullyParkedClassStub::$beforeClassCalls - $beforeClass, 1); + Assert::same(FullyParkedClassStub::$afterClassCalls - $afterClass, 1); + } +} diff --git a/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php b/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php new file mode 100644 index 00000000..dd3add7c --- /dev/null +++ b/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php @@ -0,0 +1,43 @@ + Date: Fri, 4 Sep 2026 18:50:20 +0500 Subject: [PATCH 14/35] test(test): drop a cannot-fail assertion and pin the enabled function neighbor Audit follow-up. namedReasonArgument asserted a PHP language guarantee (named argument binding to the single promoted parameter) and could not fail while its positional twin passes, so it is removed. enabled_function in skip_functions.php was asserted by nothing: TestRunner::runTest() returns only the requested result, so a throwing neighbor would go unnoticed. The new feature test runs it and expects Passed, closing the one untested cell of the semantics matrix: an enabled function of a partially parked file survives the wrapped batch runner. Assisted-By: Claude Fable 5 --- plugin/test/tests/Feature/SkipFeatureTest.php | 11 +++++++++++ plugin/test/tests/Unit/SkipAttributeTest.php | 7 ------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index a879a0e2..03711300 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -94,6 +94,17 @@ public function functionalTestUsesFunctionFqnInMessage(): void ); } + /** + * The function-based analog of the control neighbor: an enabled function of a partially + * parked file still runs through the wrapped batch runner and passes. + */ + public function controlNeighborFunctionNextToParkedFunctionStillRuns(): void + { + $result = TestRunner::runTest('Tests\Test\Stub\Skip\enabled_function'); + + Assert::same($result->status, Status::Passed); + } + /** * The origin contract for downstream consumers: a `#[Skip]`-parked result carries the * attribute instances in `$result->info`, unlike a runtime `throw SkipTest` skip. diff --git a/plugin/test/tests/Unit/SkipAttributeTest.php b/plugin/test/tests/Unit/SkipAttributeTest.php index e293ae0d..747222b4 100644 --- a/plugin/test/tests/Unit/SkipAttributeTest.php +++ b/plugin/test/tests/Unit/SkipAttributeTest.php @@ -33,13 +33,6 @@ public function customReason(): void Assert::same($skip->reason, 'flaky on CI, see ISSUE-123'); } - public function namedReasonArgument(): void - { - $skip = new Skip(reason: 'named'); - - Assert::same($skip->reason, 'named'); - } - public function targetsClassMethodAndFunction(): void { $flags = self::attributeFlags(); From 806ab59529bc868e1e5fad2a428f5ac3605fe20e Mon Sep 17 00:00:00 2001 From: Meacue Date: Sat, 5 Sep 2026 02:59:18 +0500 Subject: [PATCH 15/35] fix(test): load the function stub in SkipFeatureTest regardless of run order The two function-based tests resolved their targets only because an earlier test's inner catalog run happened to include skip_functions.php: functions are not autoloadable, and TestRunner::runTest() throws "Invalid test function provided." when the name does not exist yet. Running either test in isolation (--filter) errored before any assertion. Load the stub in the constructor, the same way the other function-based feature tests already do. Assisted-By: Claude Fable 5 --- plugin/test/tests/Feature/SkipFeatureTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index 03711300..4e1e3946 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -33,6 +33,14 @@ #[Covers(SkipInterceptor::class)] final class SkipFeatureTest { + public function __construct() + { + # Functions are not autoloadable: load the stub so TestRunner::runTest() can resolve the + # function names below regardless of which test runs first. The pipeline re-includes the + # same file (include_once) when it runs. + require_once __DIR__ . '/../Stub/Skip/skip_functions.php'; + } + public function methodLevelSkipReportsSkippedWithComposedReason(): void { $result = TestRunner::runTest([SkipMethodStub::class, 'parked']); From 9f4f75c9a15f6272d9debef49617a43b541a55b7 Mon Sep 17 00:00:00 2001 From: Meacue Date: Sat, 5 Sep 2026 05:33:12 +0500 Subject: [PATCH 16/35] docs(test): fix #[Skip] docs and strengthen its tests The docs no longer promise the skip reason in every report, and the construction, fiber and provider checks now assert per-run evidence. Assisted-By: Claude Fable 5.1 --- plugin/test/src/Internal/SkipInterceptor.php | 56 ++++++-------- plugin/test/src/Skip.php | 54 ++++++------- plugin/test/src/TestPlugin.php | 1 - plugin/test/tests/Feature/SkipFeatureTest.php | 77 +++++++++++++++---- plugin/test/tests/Feature/SkipSummaryTest.php | 45 +++++------ .../tests/Stub/PipelineEntrySpyPlugin.php | 42 ++++++++++ .../Stub/Skip/SkipClassAndMethodStub.php | 6 ++ .../test/tests/Stub/Skip/SkipInFiberStub.php | 35 +++++++-- .../tests/Stub/Skip/SkipNonStaticHookStub.php | 6 +- .../Stub/Skip/SkipWithDataProviderStub.php | 20 +++-- .../Unit/Fixture/SkipClassLevelFixture.php | 9 ++- .../Unit/Internal/SkipInterceptorTest.php | 37 +++++---- plugin/test/tests/Unit/SkipAttributeTest.php | 43 ++++++----- skills/testo-flaky-tests/SKILL.md | 2 +- skills/testo-write-tests/SKILL.md | 21 ++--- tests/Output/Unit/JUnit/JUnitWriterTest.php | 10 ++- 16 files changed, 297 insertions(+), 167 deletions(-) create mode 100644 plugin/test/tests/Stub/PipelineEntrySpyPlugin.php diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index a10115a2..506ee826 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -26,45 +26,37 @@ /** * Reports {@see Skip}-marked tests as skipped without running them. * - * A case-level interceptor (registered by {@see TestPlugin}): before handing the case on, it - * removes every `#[Skip]` test from the case's test set — so by the time lifecycle hooks run - * and the per-test pipeline starts, the parked tests are simply not there — and delivers a - * synthetic {@see Status::Skipped} result for each of them instead. + * A case-level interceptor (registered by {@see TestPlugin}, also the attribute's fallback): + * it removes every parked test from the case's test set before handing the case on — so + * lifecycle hooks and the per-test pipeline never see them — and appends a synthetic + * {@see Status::Skipped} result for each through the case's batch runner + * ({@see CaseInfo::withBatchRunner}). A runner already installed by an outer interceptor + * (e.g. testo/fiber's) is wrapped, never replaced. Every synthetic result is announced with + * the {@see TestPipelineStarting}/{@see TestPipelineFinished} pair, so reporters render the + * skipped lines inside the case block; the core aggregates the case as usual. * - * Delivery rides the case's batch runner ({@see CaseInfo::withBatchRunner}): a wrapper runs - * the real handlers (or the already-installed runner, e.g. testo/fiber's — wrapped, never - * replaced), then appends the synthetic results, dispatching {@see TestPipelineStarting}/ - * {@see TestPipelineFinished} for each so reporters render the skipped lines inside the - * case block. The core aggregates case status and summary from the returned list as usual. + * Ordering: {@see InterceptorOptions::ORDER_DEFAULT} sits outer to the lifecycle interceptor + * (so filtering happens before `#[BeforeClass]`) and inner to the fiber interceptor (so a + * fiber batch runner is already on the case and gets wrapped). * - * Ordering: {@see InterceptorOptions::ORDER_DEFAULT} keeps this interceptor outer to the - * lifecycle interceptor (`PHP_INT_MAX`, so filtering happens before `#[BeforeClass]`) and - * inner to the fiber interceptor (`ORDER_DATA_PROVIDER - 1`, so a fiber batch runner is - * already on the case and gets wrapped). - * - * Never throws for a parked test — a throw from a case interceptor would abort the whole - * case; skipping is expressed by returning results ("return, do not throw"). + * Never throws for a parked test — a throw from a case interceptor aborts the whole case. * * @internal * @psalm-internal Testo\Test */ #[InterceptorOptions( order: InterceptorOptions::ORDER_DEFAULT, - # A class-level #[Skip] spawns a second instance of this interceptor through the fallback - # alias, next to the one registered by TestPlugin; First collapses the duplicate onto the - # registered one. The interceptor is stateless, so either instance would do — keeping the - # registered one preserves its stable position in the chain. + # A class-level #[Skip] spawns a second instance through the fallback alias, next to the + # one registered by TestPlugin; First collapses the duplicate onto the registered one. onConflict: ConflictPolicy::First, testType: TestType::Test, )] final readonly class SkipInterceptor implements TestCaseRunInterceptor { /** - * Intentionally takes no {@see Skip} parameter (contrast `RetryPolicyRunInterceptor`): - * the instance is built on two paths — by the container on the {@see TestPlugin} - * registration (`container->get()` cannot resolve an attribute) and by the injector on - * a fallback spawn (the attribute arrives in the arguments and is silently ignored). - * The attributes are looked up per case in {@see self::findParked()} instead. + * Takes no {@see Skip} parameter on purpose: the container also builds the instance for + * the {@see TestPlugin} registration, where no attribute is at hand. The attributes are + * looked up per case in {@see self::findParked()} instead. */ public function __construct( private EventDispatcherInterface $eventDispatcher, @@ -160,18 +152,14 @@ private function reportSkipped( } /** - * Composes the reported message: `{testId} is skipped via #[Skip]`, extended with - * ` ==> {reason}` when a reason is given. The generated part is always present so every - * reporter shows the origin of the skip even with an empty reason. + * `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` when a reason is given. + * The generated part is always present, so every reporter shows the origin of the skip; + * the test id is the test's address ({@see \Testo\Core\Context\Identity\TestIdentity::fqn()}), + * the string `--filter` takes back. */ private static function reason(TestInfo $info, Skip $attribute): string { - $class = $info->caseInfo->definition->reflection?->getName(); - $testId = $class !== null - ? "{$class}::{$info->name}" - : $info->testDefinition->reflection->getName(); - - $message = "{$testId} is skipped via #[Skip]"; + $message = "{$info->identity->fqn()} is skipped via #[Skip]"; return $attribute->reason === '' ? $message : "{$message} ==> {$attribute->reason}"; } diff --git a/plugin/test/src/Skip.php b/plugin/test/src/Skip.php index 753a3ba2..425943a1 100644 --- a/plugin/test/src/Skip.php +++ b/plugin/test/src/Skip.php @@ -13,14 +13,10 @@ /** * Marks a test as skipped without deleting or hiding it. * - * The test is not executed, but stays visible in every report as {@see Status::Skipped} - * with a composed reason, so parked tests are counted and reviewable instead of silently - * rotting. Contrast with a group filter (`#[Group('x')]` + `--group=!x`), which makes the - * test disappear from reports entirely: a group is "a category I sometimes don't run" - * (decided by the runner, invisible), while `#[Skip]` is "this test is parked and must be - * returned to" (decided in code, always visible, with a reason). - * - * Behavior depends on the target: + * The test is not executed, but stays in the results as {@see Status::Skipped}: it is counted + * in the totals and carries its reason in the result's failure message, so parked tests are + * reviewable instead of silently rotting. Contrast with a group filter (`#[Group('x')]` + + * `--group=!x`), which drops the test from the results entirely. * * On a method or function — only that test is skipped: * @@ -36,40 +32,36 @@ * ``` * * On a class — every test of the case is skipped. The attribute is inherited from parent - * classes and traits (like `#[Group]`); a method-level `#[Skip]` reason wins over the - * class-level one. + * classes and traits (like `#[Group]`); a method-level `#[Skip]` wins over the class-level + * one, reason included. * - * The reported failure message is composed as `{testId} is skipped via #[Skip]`, extended - * with ` ==> {reason}` when a reason is given — so JUnit/TeamCity/HTML output always shows - * the origin of the skip, even with an empty reason. + * The failure message reads `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` + * when a reason is given. The JUnit, TeamCity and HTML reporters show that message; the + * terminal prints the skipped line without it, and the compact `--json` report counts the + * test in its totals. * * Runtime contract (v1): * * - The skipped test never enters the per-test pipeline: `#[BeforeTest]`/`#[AfterTest]` * hooks, data providers, `#[Retry]`/`#[Repeat]`, fibers and coverage never engage. - * A data-driven test yields a single Skipped entry (providers are not expanded). + * A data-driven test yields a single Skipped entry (providers are not called). * - `#[BeforeClass]`/`#[AfterClass]` hooks still run — also when every test of the case - * is skipped. Full case suppression is a possible follow-up. - * - The case class is not instantiated, unless a non-static class-level hook forces - * construction (class-level hooks may be non-static; that builds the class). + * is skipped. + * - A skipped test never requires an instance of the case class. A fully parked class is + * built only when a non-static class-level hook forces it; next to enabled tests the + * class is constructed for them as usual. * - A run consisting only of `#[Skip]`-marked tests is successful (exit code 0): * Skipped is neither a success nor a failure. - * - On a non-test method the attribute is inert (like `#[Group]` on a helper). - * - Only plain test cases are handled ({@see SkipInterceptor} declares - * `testType: TestType::Test`): on a `#[Bench]` or `#[TestInline]` target the attribute - * is inert — the benchmark or inline case runs as usual. + * - On a non-test method the attribute is inert (like `#[Group]` on a helper). So is it on + * a `#[Bench]` or `#[TestInline]` target: only plain test cases are handled. * - * The attribute is handled by {@see SkipInterceptor}, registered by {@see TestPlugin}. - * It is also declared as the {@see FallbackInterceptor}, which covers a class-level - * `#[Skip]` when the plugin is not registered — a case-level fallback is spawned from - * class attributes only. A method- or function-level `#[Skip]` needs the TestPlugin - * registration; without it the attribute is inert. When both paths are live, the - * duplicate spawn is collapsed by the interceptor's `ConflictPolicy::First`. - * For skipping at runtime — from the test body, based on the environment — throw - * {@see SkipTest} instead. + * Prerequisite: the handler, {@see SkipInterceptor}, is registered by {@see TestPlugin}. + * Without the plugin only a class-level `#[Skip]` keeps working — through the + * {@see FallbackInterceptor} declared below, which the pipeline spawns from class attributes + * only; a method- or function-level `#[Skip]` is then inert. * - * @see SkipTest for the runtime counterpart and the `is skipped via #[Skip]` marker - * distinguishing declarative skips in reports. + * For skipping at runtime — from the test body, based on the environment — throw + * {@see SkipTest} instead; the `is skipped via #[Skip]` marker tells the two apart in reports. * * @api */ diff --git a/plugin/test/src/TestPlugin.php b/plugin/test/src/TestPlugin.php index 84f5c15d..b5daebce 100644 --- a/plugin/test/src/TestPlugin.php +++ b/plugin/test/src/TestPlugin.php @@ -23,7 +23,6 @@ public function configure(Container $container): void { $collector = $container->get(InterceptorCollector::class); $collector->addInterceptor(new TestoAttributesLocatorInterceptor()); - # Registered as a class-string: the container injects the event dispatcher on resolve. $collector->addInterceptor(SkipInterceptor::class); } } diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index 4e1e3946..25e910da 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -14,6 +14,7 @@ use Testo\Test\Skip; use Testo\Testing\Attribute\TestingSuite; use Testo\Testing\Helper\TestRunner; +use Tests\Test\Stub\PipelineEntrySpyPlugin; use Tests\Test\Stub\Skip\SkipChildStub; use Tests\Test\Stub\Skip\SkipClassAndMethodStub; use Tests\Test\Stub\Skip\SkipClassLevelStub; @@ -28,7 +29,7 @@ use Tests\Test\Stub\Skip\SkipWithRetryStub; #[Test] -#[TestingSuite(path: __DIR__ . '/../Stub/Skip')] +#[TestingSuite(path: __DIR__ . '/../Stub/Skip', plugins: [PipelineEntrySpyPlugin::class])] #[Covers(Skip::class)] #[Covers(SkipInterceptor::class)] final class SkipFeatureTest @@ -46,9 +47,9 @@ public function methodLevelSkipReportsSkippedWithComposedReason(): void $result = TestRunner::runTest([SkipMethodStub::class, 'parked']); Assert::same($result->status, Status::Skipped); - Assert::true($result->failure instanceof SkipTest); + Assert::instanceOf($result->failure, SkipTest::class); Assert::same( - $result->failure->getMessage(), + $result->failure?->getMessage(), SkipMethodStub::class . '::parked is skipped via #[Skip] ==> broken by the pricing rework, see ISSUE-123', ); } @@ -91,6 +92,21 @@ public function methodReasonWinsOverClassReason(): void Assert::true(\str_ends_with((string) $inherited->failure?->getMessage(), ' ==> class-wide reason')); } + /** + * The method-level attribute wins as a whole: an empty method reason is not filled in + * from the class reason. + */ + public function emptyMethodReasonStillWinsOverClassReason(): void + { + $result = TestRunner::runTest([SkipClassAndMethodStub::class, 'emptyOwnReason']); + + Assert::same($result->status, Status::Skipped); + Assert::same( + $result->failure?->getMessage(), + SkipClassAndMethodStub::class . '::emptyOwnReason is skipped via #[Skip]', + ); + } + public function functionalTestUsesFunctionFqnInMessage(): void { $result = TestRunner::runTest('Tests\Test\Stub\Skip\parked_function'); @@ -122,8 +138,9 @@ public function parkedResultCarriesOriginAttribute(): void $result = TestRunner::runTest([SkipMethodStub::class, 'parked']); $origin = $result->info->getAttribute(Skip::class); - Assert::true(\is_array($origin) && $origin !== []); - Assert::true($origin[0] instanceof Skip); + Assert::true(\is_array($origin)); + Assert::count($origin, 1); + Assert::instanceOf($origin[0], Skip::class); } /** @@ -161,10 +178,12 @@ public function fullyParkedCaseWithoutHooksIsNeverInstantiated(): void */ public function nonStaticClassHookStillBuildsTheClass(): void { + $constructions = SkipNonStaticHookStub::$constructions; + $result = TestRunner::runTest([SkipNonStaticHookStub::class, 'parked']); Assert::same($result->status, Status::Skipped); - Assert::true(SkipNonStaticHookStub::$constructed); + Assert::same(SkipNonStaticHookStub::$constructions - $constructions, 1); } public function classLevelSkipIsInheritedFromParent(): void @@ -184,16 +203,17 @@ public function classLevelSkipIsInheritedFromTrait(): void } /** - * A data-driven parked test yields a single Skipped node: providers are not expanded - * (and not even called), no `MultipleResult` aggregate is attached. + * A data-driven parked test yields a single Skipped node: the provider is never called + * (not once across all catalog runs of this class), no `MultipleResult` aggregate is + * attached. */ - public function dataProviderIsNotExpandedForParkedTest(): void + public function dataProviderIsNotCalledForParkedTest(): void { $result = TestRunner::runTest([SkipWithDataProviderStub::class, 'parked']); Assert::same($result->status, Status::Skipped); Assert::null($result->getAttribute(MultipleResult::class)); - Assert::false(SkipWithDataProviderStub::$providerCalled); + Assert::same(SkipWithDataProviderStub::$providerCalls, 0); } public function retryDoesNotEngageForParkedTest(): void @@ -214,16 +234,47 @@ public function repeatDoesNotEngageForParkedTest(): void Assert::false(SkipWithRepeatStub::$bodyRan); } + /** + * The common ground of the hook/provider/retry/repeat checks above: a parked test never + * enters the per-test pipeline at all. A spy interceptor on that pipeline sees the + * enabled neighbors of the catalog and none of the parked tests. + */ + public function parkedTestsNeverEnterThePerTestPipeline(): void + { + $offset = \count(PipelineEntrySpyPlugin::$entered); + + TestRunner::runTest([SkipMethodStub::class, 'parked']); + + $entered = \array_slice(PipelineEntrySpyPlugin::$entered, $offset); + Assert::contains($entered, SkipMethodStub::class . '::enabled'); + Assert::same(\array_intersect($entered, [ + SkipMethodStub::class . '::parked', + SkipMethodStub::class . '::parkedNoReason', + SkipWithHooksStub::class . '::parked', + SkipWithDataProviderStub::class . '::parked', + SkipWithRetryStub::class . '::parked', + SkipWithRepeatStub::class . '::parked', + SkipInFiberStub::class . '::parked', + 'Tests\Test\Stub\Skip\parked_function', + ]), []); + } + /** * Fiber compatibility: the skip interceptor wraps the fiber batch runner instead of - * replacing it — the enabled test still runs on the scheduler, the parked one is skipped. + * replacing it. The round-robin interleaving of the two enabled tests is produced only by + * the case scheduler — run sequentially, their `\Fiber::suspend()` would throw and the + * log would stop short — while the parked test is still skipped. */ public function fiberBatchRunnerSurvivesTheWrap(): void { - $enabled = TestRunner::runTest([SkipInFiberStub::class, 'enabled']); + $offset = \count(SkipInFiberStub::$log); + $parked = TestRunner::runTest([SkipInFiberStub::class, 'parked']); - Assert::same($enabled->status, Status::Passed); Assert::same($parked->status, Status::Skipped); + Assert::same( + \array_slice(SkipInFiberStub::$log, $offset), + ['first.1', 'second.1', 'first.2', 'second.2'], + ); } } diff --git a/plugin/test/tests/Feature/SkipSummaryTest.php b/plugin/test/tests/Feature/SkipSummaryTest.php index 786077a9..9cbc4f50 100644 --- a/plugin/test/tests/Feature/SkipSummaryTest.php +++ b/plugin/test/tests/Feature/SkipSummaryTest.php @@ -11,6 +11,7 @@ use Testo\Assert; use Testo\Codecov\Covers; use Testo\Core\Context\RunResult; +use Testo\Core\Context\TestResult; use Testo\Core\Value\Status; use Testo\Test; use Testo\Test\Internal\SkipInterceptor; @@ -26,13 +27,16 @@ final class SkipSummaryTest { /** - * The classic off-by-parked bug: totals must satisfy `total = passed + failed + skipped`, - * and the data-driven parked test counts exactly once. + * The mixed catalog holds one passing, one failing and two parked tests (one of them + * data-driven). The classic off-by-parked bug: totals must satisfy + * `total = passed + failed + skipped` with the data-driven parked test counted exactly + * once — and the failing neighbor still fails the run. */ - public function totalsAddUpWithParkedTests(): void + public function parkedTestsAddUpAndFailingNeighborStillFailsTheRun(): void { - $summary = self::run(__DIR__ . '/../Stub/SkipSummary/Mixed')->summary; + $result = self::run(__DIR__ . '/../Stub/SkipSummary/Mixed'); + $summary = $result->summary; Assert::same($summary->count(Status::Passed), 1); Assert::same($summary->count(Status::Failed), 1); Assert::same($summary->count(Status::Skipped), 2); @@ -40,50 +44,37 @@ public function totalsAddUpWithParkedTests(): void $summary->total(), $summary->passed() + $summary->failed() + $summary->count(Status::Skipped), ); - } - - public function failingNeighborStillFailsTheRun(): void - { - $result = self::run(__DIR__ . '/../Stub/SkipSummary/Mixed'); - Assert::same($result->status, Status::Failed); } /** * A run consisting only of `#[Skip]`-marked tests is a success: Skipped is neither a * success nor a failure, so nothing fails the run. - */ - public function runOfOnlyParkedTestsIsSuccessful(): void - { - $result = self::run(__DIR__ . '/../Stub/SkipSummary/OnlyParked'); - - Assert::same($result->status, Status::Passed); - Assert::same($result->summary->count(Status::Skipped), 2); - Assert::same($result->summary->total(), 2); - } - - /** - * The dedup invariant, pinned by name: with `TestPlugin` registered, a class-level + * + * The same run pins the dedup invariant: with `TestPlugin` registered, a class-level * `#[Skip]` also spawns a fallback instance of the interceptor, and the conflict policy * must collapse the duplicate — each parked test yields exactly one result, not one per * delivery path. */ - public function classLevelSkipIsNotHandledTwice(): void + public function runOfOnlyParkedTestsIsSuccessfulAndDeliveredOnce(): void { $run = self::run(__DIR__ . '/../Stub/SkipSummary/OnlyParked'); + Assert::same($run->status, Status::Passed); + Assert::same($run->summary->count(Status::Skipped), 2); + Assert::same($run->summary->total(), 2); $cases = []; foreach ($run as $suite) { foreach ($suite as $case) { $cases[] = $case; } } - # The catalog holds one class with two parked tests. Assert::count($cases, 1); - $results = \iterator_to_array($cases[0], preserve_keys: false); - Assert::count($results, 2); - $names = \array_map(static fn($result) => $result->info->name, $results); + $names = \array_map( + static fn(TestResult $result): string => $result->info->name, + \iterator_to_array($cases[0], preserve_keys: false), + ); \sort($names); Assert::same($names, ['firstParked', 'secondParked']); } diff --git a/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php b/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php new file mode 100644 index 00000000..870f3675 --- /dev/null +++ b/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php @@ -0,0 +1,42 @@ + */ + public static array $entered = []; + + #[\Override] + public function configure(Container $container): void + { + $container->get(InterceptorCollector::class)->addInterceptor( + new class implements TestRunInterceptor { + #[\Override] + public function runTest(TestInfo $info, callable $next): TestResult + { + PipelineEntrySpyPlugin::$entered[] = $info->identity->fqn(); + + return $next($info); + } + }, + ); + } +} diff --git a/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php b/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php index 078bf8db..fde6077b 100644 --- a/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php +++ b/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php @@ -21,4 +21,10 @@ public function classReason(): void { throw new \LogicException('Must never run: the case is parked.'); } + + #[Skip] + public function emptyOwnReason(): void + { + throw new \LogicException('Must never run: the test is parked.'); + } } diff --git a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php b/plugin/test/tests/Stub/Skip/SkipInFiberStub.php index b3a0d567..cc7f3412 100644 --- a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php +++ b/plugin/test/tests/Stub/Skip/SkipInFiberStub.php @@ -6,26 +6,49 @@ use Testo\Assert; use Testo\Fiber\RunInFiber; +use Testo\Fiber\Schedule; use Testo\Test; use Testo\Test\Skip; /** - * A class-level `#[RunInFiber]` installs a fiber batch runner on the case; the skip - * interceptor must wrap that runner, not replace it — the enabled test still runs on the - * scheduler, the parked one is still reported as skipped. + * A class-level `#[RunInFiber(Schedule::RoundRobin)]` installs a fiber batch runner on the + * case; the skip interceptor must wrap that runner, not replace it. The two enabled tests + * suspend once each and write to a shared log: only the case scheduler produces the + * round-robin interleaving `first.1, second.1, first.2, second.2` — run sequentially, the + * `\Fiber::suspend()` outside a fiber would throw and the log would stop short. + * + * The log accumulates across catalog runs — the stubs and the feature test assert the tail + * written by their own run. */ #[Test] -#[RunInFiber] +#[RunInFiber(Schedule::RoundRobin)] final class SkipInFiberStub { + /** @var list */ + public static array $log = []; + #[Skip('parked inside a fiber-driven case')] public function parked(): void { throw new \LogicException('Must never run: the test is parked.'); } - public function enabled(): void + public function first(): void + { + self::$log[] = 'first.1'; + \Fiber::suspend(); + self::$log[] = 'first.2'; + + # Round-robin: after the yield, "second" has had its first step in between. + Assert::same(\array_slice(self::$log, -3), ['first.1', 'second.1', 'first.2']); + } + + public function second(): void { - Assert::true(true); + self::$log[] = 'second.1'; + \Fiber::suspend(); + self::$log[] = 'second.2'; + + Assert::same(\array_slice(self::$log, -4), ['first.1', 'second.1', 'first.2', 'second.2']); } } diff --git a/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php b/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php index 82cb06fc..e7968fcc 100644 --- a/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php +++ b/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php @@ -12,16 +12,18 @@ * Documented caveat: a non-static class-level hook forces construction even when every * test of the case is parked. The stub pins that behavior so a future change is a * conscious one, not an accident. + * + * The construction counter accumulates across catalog runs — feature tests assert deltas. */ #[Test] #[Skip('fully parked, but the non-static hook builds the class')] final class SkipNonStaticHookStub { - public static bool $constructed = false; + public static int $constructions = 0; public function __construct() { - self::$constructed = true; + ++self::$constructions; } #[BeforeClass] diff --git a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php b/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php index e4c8687a..52fe1787 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php @@ -8,9 +8,13 @@ use Testo\Test; use Testo\Test\Skip; +/** + * The provider counts its calls before returning anything, so the counter tells "never + * called" apart from "called but not iterated" — a generator body would only run on iteration. + */ final class SkipWithDataProviderStub { - public static bool $providerCalled = false; + public static int $providerCalls = 0; #[Test] #[Skip('data-driven test is parked as a whole')] @@ -20,10 +24,16 @@ public function parked(int $value): void throw new \LogicException('Must never run: the test is parked.'); } - public static function provide(): iterable + /** + * @return array + */ + public static function provide(): array { - self::$providerCalled = true; - yield [1]; - yield [2]; + ++self::$providerCalls; + + return [ + 'one' => [1], + 'two' => [2], + ]; } } diff --git a/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php b/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php index cfb84c3b..15135a6c 100644 --- a/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php +++ b/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php @@ -8,7 +8,8 @@ /** * Used by {@see \Tests\Test\Unit\Internal\SkipInterceptorTest}: a class-level `#[Skip]` - * parks every test; a method-level reason wins over the class-level one. + * parks every test; a method-level `#[Skip]` wins over the class-level one, reason included — + * also when its own reason is empty. */ #[Skip('entire case is parked')] final class SkipClassLevelFixture @@ -23,4 +24,10 @@ public function second(): void { throw new \LogicException('Must never run: the test is parked.'); } + + #[Skip] + public function third(): void + { + throw new \LogicException('Must never run: the test is parked.'); + } } diff --git a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php index 3d70adc5..d1f4aeb3 100644 --- a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php +++ b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php @@ -64,7 +64,7 @@ public function returnsSyntheticSkippedResults(): void $parked = self::findResult($result, 'parked'); Assert::same($parked->status, Status::Skipped); - Assert::true($parked->failure instanceof SkipTest); + Assert::instanceOf($parked->failure, SkipTest::class); Assert::same($parked->summary->count(Status::Skipped), 1); Assert::same($result->summary->count(Status::Skipped), 1); Assert::same($result->summary->count(Status::Passed), 1); @@ -113,8 +113,9 @@ public function stampsOriginAttributeOnSyntheticInfo(): void $result = $interceptor->runTestCase($info, self::coreNext()); $origin = self::findResult($result, 'parked')->info->getAttribute(Skip::class); - Assert::true(\is_array($origin) && $origin !== []); - Assert::true($origin[0] instanceof Skip); + Assert::true(\is_array($origin)); + Assert::count($origin, 1); + Assert::instanceOf($origin[0], Skip::class); Assert::same($origin[0]->reason, 'broken by the pricing rework, see ISSUE-123'); Assert::null(self::findResult($result, 'enabled')->info->getAttribute(Skip::class)); } @@ -132,10 +133,14 @@ public function classLevelSkipParksEveryTest(): void Assert::same(self::findResult($result, 'second')->status, Status::Skipped); } + /** + * The method-level attribute wins as a whole: an empty method reason is not filled in + * from the class reason. + */ public function methodReasonWinsOverClassReason(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); - $info = self::createCaseInfo(SkipClassLevelFixture::class, 'first', 'second'); + $info = self::createCaseInfo(SkipClassLevelFixture::class, 'first', 'second', 'third'); $result = $interceptor->runTestCase($info, self::coreNext()); @@ -147,6 +152,10 @@ public function methodReasonWinsOverClassReason(): void (string) self::findResult($result, 'second')->failure?->getMessage(), ' ==> method beats class', )); + Assert::same( + self::findResult($result, 'third')->failure?->getMessage(), + SkipClassLevelFixture::class . '::third is skipped via #[Skip]', + ); } /** @@ -189,8 +198,8 @@ public function wrapsExistingBatchRunnerInsteadOfReplacing(): void } /** - * Reporters render test lines from the pipeline events, so the interceptor dispatches - * them for every synthetic result. + * Reporters render test lines from the pipeline events: Starting before Finished, both + * carrying the same address, so a reporter keyed on the identity closes what it opened. */ public function dispatchesPipelineEventsForParkedTests(): void { @@ -198,17 +207,17 @@ public function dispatchesPipelineEventsForParkedTests(): void $interceptor = new SkipInterceptor($dispatcher); $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked'); - $interceptor->runTestCase($info, self::coreNext()); + $result = $interceptor->runTestCase($info, self::coreNext()); /** @psalm-suppress UndefinedPropertyFetch The anonymous dispatcher exposes $dispatched. */ $events = $dispatcher->dispatched; - $starting = \array_values(\array_filter($events, static fn(object $e): bool => $e instanceof TestPipelineStarting)); - $finished = \array_values(\array_filter($events, static fn(object $e): bool => $e instanceof TestPipelineFinished)); - - Assert::count($starting, 1); - Assert::count($finished, 1); - Assert::same($starting[0]->testInfo->name, 'parked'); - Assert::same($finished[0]->testResult->status, Status::Skipped); + Assert::count($events, 2); + [$starting, $finished] = $events; + Assert::instanceOf($starting, TestPipelineStarting::class); + Assert::instanceOf($finished, TestPipelineFinished::class); + Assert::same($starting->testInfo->name, 'parked'); + Assert::same($finished->testInfo->identity, $starting->testInfo->identity); + Assert::same($finished->testResult, self::findResult($result, 'parked')); } /** diff --git a/plugin/test/tests/Unit/SkipAttributeTest.php b/plugin/test/tests/Unit/SkipAttributeTest.php index 747222b4..5c3f4ee8 100644 --- a/plugin/test/tests/Unit/SkipAttributeTest.php +++ b/plugin/test/tests/Unit/SkipAttributeTest.php @@ -6,6 +6,7 @@ use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Expect; use Testo\Pipeline\Attribute\FallbackInterceptor; use Testo\Pipeline\Attribute\Interceptable; use Testo\Test; @@ -33,22 +34,36 @@ public function customReason(): void Assert::same($skip->reason, 'flaky on CI, see ISSUE-123'); } - public function targetsClassMethodAndFunction(): void + /** + * Exactly class, method and function — and nothing else, so no `IS_REPEATABLE`. + */ + public function targetsClassMethodAndFunctionOnly(): void { - $flags = self::attributeFlags(); + $attributes = (new \ReflectionClass(Skip::class))->getAttributes(\Attribute::class); + + /** @var \Attribute $attribute */ + $attribute = $attributes[0]->newInstance(); - Assert::same($flags & \Attribute::TARGET_CLASS, \Attribute::TARGET_CLASS); - Assert::same($flags & \Attribute::TARGET_METHOD, \Attribute::TARGET_METHOD); - Assert::same($flags & \Attribute::TARGET_FUNCTION, \Attribute::TARGET_FUNCTION); + Assert::same( + $attribute->flags, + \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION, + ); } /** - * A skip carries a single reason — a second attribute on the same target has nowhere - * to go, so PHP itself rejects the duplicate at reflection time. + * A skip carries a single reason — a second `#[Skip]` on the same target has nowhere + * to go, so PHP itself rejects the duplicate when the attribute is instantiated. This + * is the diagnostic the skip interceptor surfaces for such a target. */ - public function isNotRepeatable(): void + public function duplicateOnOneTargetIsRejected(): never { - Assert::same(self::attributeFlags() & \Attribute::IS_REPEATABLE, 0); + $attributes = (new \ReflectionObject(new #[Skip('first')] #[Skip('second')] class {})) + ->getAttributes(Skip::class); + + Expect::exception(\Error::class) + ->withMessage('Attribute "Testo\Test\Skip" must not be repeated'); + + $attributes[0]->newInstance(); } /** @@ -71,14 +86,4 @@ public function declaresSkipInterceptorAsFallback(): void Assert::count($attributes, 1); Assert::same($attributes[0]->newInstance()->class, SkipInterceptor::class); } - - private static function attributeFlags(): int - { - $attributes = (new \ReflectionClass(Skip::class))->getAttributes(\Attribute::class); - - /** @var \Attribute $attribute */ - $attribute = $attributes[0]->newInstance(); - - return $attribute->flags; - } } diff --git a/skills/testo-flaky-tests/SKILL.md b/skills/testo-flaky-tests/SKILL.md index 4dcc1c85..a4a4280c 100644 --- a/skills/testo-flaky-tests/SKILL.md +++ b/skills/testo-flaky-tests/SKILL.md @@ -88,7 +88,7 @@ Don't ship `#[Repeat(times: 50)]` long-term on a fast suite — CI cost adds up. 3. Is the flakiness from shared state inside the suite (ordering)? - Don't reach for either attribute. Fix isolation (lifecycle hooks, fresh fixtures). 4. Parking the test for a longer while (root cause known but not fixable now)? - - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays visible in reports as Skipped with the reason. `#[Retry]` is for stabilizing, not parking. + - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays counted in reports as Skipped; the reason is carried in the result and shown by the JUnit/TeamCity/HTML reporters (full contract in testo-write-tests). `#[Retry]` is for stabilizing, not parking. ## Pitfalls diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index 3f8da700..eb15c962 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -150,25 +150,28 @@ use Testo\Test\Skip; public function calculatesTotal(): void { ... } // reported as Skipped, body never runs ``` -The test stays visible in every report as `Status::Skipped` with the message -`{testId} is skipped via #[Skip] ==> {reason}` (without ` ==> ...` when the reason is empty). -`reason` is optional and the attribute is not repeatable. +The test is reported as `Status::Skipped` and counted in the totals; its reason travels in the +result's failure message `{testId} is skipped via #[Skip] ==> {reason}` (without ` ==> ...` when +the reason is empty). The JUnit, TeamCity and HTML reports show that message; the terminal prints +the skipped line without it, and the compact `--json` report only counts the test in +`totals.skipped`. `reason` is optional and the attribute is not repeatable. Which skipping tool to reach for: | Tool | Decided by | Visibility | Use when | |---|---|---|---| -| `#[Skip('...')]` | code, ahead of time | always reported, with reason | test is parked and must be returned to | +| `#[Skip('...')]` | code, ahead of time | always reported; reason in JUnit/TeamCity/HTML | test is parked and must be returned to | | `throw SkipTest` | test body, at runtime | reported when the run gets there | test isn't applicable in this environment | | `#[Group]` + `--group=!x` | runner invocation | invisible — filtered out of reports | a category you sometimes don't run | Runtime contract of `#[Skip]`: the test never enters the per-test pipeline, so `#[BeforeTest]`/`#[AfterTest]`, data providers, `#[Retry]`/`#[Repeat]` and coverage never -engage, and a data-driven test yields a single Skipped entry. `#[BeforeClass]`/`#[AfterClass]` -still run (also when every test of the case is parked), and the case class is only -instantiated if a non-static class-level hook forces it. A run of only `#[Skip]`-marked -tests is a success (exit 0). `#[Skip]` applies to plain tests only: on a `#[Bench]` or -`#[TestInline]` target it is inert — the benchmark or inline case runs as usual. +engage, and a data-driven test yields a single Skipped entry (the provider is not called). +`#[BeforeClass]`/`#[AfterClass]` still run (also when every test of the case is parked). A +skipped test never requires an instance of the case class: a fully parked class is built only +when a non-static class-level hook forces it, while enabled neighbors construct it as usual. A +run of only `#[Skip]`-marked tests is a success (exit 0). `#[Skip]` applies to plain tests only: +on a `#[Bench]` or `#[TestInline]` target it is inert — the benchmark or inline case runs as usual. ## Tests that intentionally perform no assertions diff --git a/tests/Output/Unit/JUnit/JUnitWriterTest.php b/tests/Output/Unit/JUnit/JUnitWriterTest.php index 3f6c04bb..c3669dfb 100644 --- a/tests/Output/Unit/JUnit/JUnitWriterTest.php +++ b/tests/Output/Unit/JUnit/JUnitWriterTest.php @@ -164,10 +164,14 @@ public function skippedTestRendersSkippedElement(): void Assert::count($xml->testsuite->testcase->skipped, 1); } + /** + * The failure message is the single source of truth for the skip reason: every producer + * (a runtime throw, a declarative `#[Skip]`) delivers it the same way, and the writer + * renders it as the `message` of ``. + */ + #[Covers(JUnitWriter::class)] public function skippedTestCarriesTheReasonFromTheFailureMessage(): void { - // Arrange: the failure message is the single source of truth for the skip reason — - // every producer (a runtime throw, a declarative skip) delivers it the same way. $writer = new JUnitWriter(); $writer->startSuite('MySuite'); $writer->addTestResult(self::makeResult( @@ -177,10 +181,8 @@ public function skippedTestCarriesTheReasonFromTheFailureMessage(): void )); $writer->finishSuite(); - // Act $xml = self::loadXml($writer->generate('Testo')); - // Assert $skipped = $xml->testsuite->testcase->skipped; Assert::count($skipped, 1); Assert::same((string) $skipped['message'], 'sqlite extension is missing'); From 26dd926bd61584636f5145f36df7c45a1429dc03 Mon Sep 17 00:00:00 2001 From: Meacue Date: Sat, 5 Sep 2026 16:03:25 +0500 Subject: [PATCH 17/35] docs(test): clarify skip reporter visibility Assisted-By: GPT-5.6 Terra --- plugin/test/src/Internal/SkipInterceptor.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index 506ee826..90347c69 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -153,7 +153,8 @@ private function reportSkipped( /** * `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` when a reason is given. - * The generated part is always present, so every reporter shows the origin of the skip; + * The generated part is always present, so reporters that render skip failure messages + * can show that the skip came from #[Skip]; * the test id is the test's address ({@see \Testo\Core\Context\Identity\TestIdentity::fqn()}), * the string `--filter` takes back. */ From 7764b4629586c8d1bc0c05c9a44b3f7b7ddc2a54 Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 06:19:44 +0500 Subject: [PATCH 18/35] fix(test): deactivate #[Skip] tests instead of undefining them #318 removed TestDefinitions::undefine() and gave TestDefinition an `active` flag. The interceptor now flips that flag the way the filter plugin does, walks the case's active tests only, and the docblocks describe the mechanic that is left instead of the one that is gone. Three unit tests pin deactivation-not-discard, an inert #[Skip] on a non-test member, and no Skipped report for an already-filtered test; a fourth pins the declared order and conflict policy. Assisted-By: Claude Fable 5.1 --- .../Feature/FullyParkedCaseFeatureTest.php | 21 ++-- .../FullyParked/fully_parked_functions.php | 8 +- plugin/test/src/Internal/SkipInterceptor.php | 65 ++++++++++--- .../Unit/Internal/SkipInterceptorTest.php | 97 ++++++++++++++++++- 4 files changed, 163 insertions(+), 28 deletions(-) diff --git a/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php b/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php index 84139b3e..daec8e5f 100644 --- a/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php +++ b/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php @@ -18,10 +18,17 @@ * End-to-end proof of the `#[Skip]` contract: `#[BeforeClass]`/`#[AfterClass]` hooks of a case * still run when every test of the case is parked with `#[Skip]`. * - * The `#[Skip]` case interceptor prunes the parked tests before the {@see LifecycleInterceptor} - * collects the case's hooks, so hook discovery must not depend on the surviving tests: for a - * function-based case it reads {@see \Testo\Core\Definition\CaseDefinition::$file}. Both case - * shapes are pinned through the real pipeline. + * The `#[Skip]` case interceptor deactivates the parked tests before the {@see LifecycleInterceptor} + * collects the case's hooks, so hook discovery must not depend on the surviving tests. It does not: + * the hooks are the case's non-tests. Prefilling defines every member as a non-test, + * {@see LifecycleInterceptor} demotes back the ones a finder took for tests (a class-level `#[Test]` + * promotes the hook methods of a class case first), and it then reads them all back with + * `filter(isTest: false)` — non-tests outlive the deactivation of the tests. + * + * Both case shapes are pinned here through the real pipeline. Their members are prefilled by + * {@see \Testo\Core\Definition\CaseDefinitions::define()} from the two sources it knows: the + * methods of {@see \Testo\Core\Definition\CaseDefinition::$reflection} for a class-based case, + * the file's free functions for a function-based one. */ #[Test] #[Covers(LifecycleInterceptor::class)] @@ -37,7 +44,7 @@ public function __construct() /** * The `#[Skip]` contract for a function-based case: class-level hooks fire exactly once per - * catalog run even though no test of the case survives the pruning; per-test hooks have + * catalog run even though no test of the case stays active; per-test hooks have * nothing to wrap and stay silent. */ public function classHooksRunForFullyParkedFunctionCase(): void @@ -58,8 +65,8 @@ public function classHooksRunForFullyParkedFunctionCase(): void } /** - * The class-based analog: hooks come from the case's class reflection and must keep firing - * for a fully parked class exactly as before. + * The class-based analog: hooks are the non-tests prefilled from the case's class reflection + * and must keep firing for a fully parked class exactly as before. */ public function classHooksRunForFullyParkedClassCase(): void { diff --git a/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php b/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php index 62e14a25..bc0b05d6 100644 --- a/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php +++ b/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php @@ -14,10 +14,10 @@ /** * A fully parked function-based case: every `#[Test]` function is under `#[Skip]`. * - * The `#[Skip]` case interceptor removes the parked tests from the case's test set before the - * {@see \Testo\Lifecycle\Internal\LifecycleInterceptor} runs, so hook discovery must not depend - * on the surviving tests: `#[BeforeClass]`/`#[AfterClass]` still run for the case (the `#[Skip]` - * contract), while the per-test hooks have nothing to wrap. + * The `#[Skip]` case interceptor deactivates the skipped tests — they leave the case's active + * test set — before the {@see \Testo\Lifecycle\Internal\LifecycleInterceptor} runs, so hook + * discovery must not depend on the surviving tests: `#[BeforeClass]`/`#[AfterClass]` still run + * for the case (the `#[Skip]` contract), while the per-test hooks have nothing to wrap. * * Static hook counters accumulate across catalog runs — feature tests assert deltas. * State is shared through {@see FullyParkedFunctionState} because functions have no `$this`. diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index 90347c69..8f542db9 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -27,19 +27,48 @@ * Reports {@see Skip}-marked tests as skipped without running them. * * A case-level interceptor (registered by {@see TestPlugin}, also the attribute's fallback): - * it removes every parked test from the case's test set before handing the case on — so - * lifecycle hooks and the per-test pipeline never see them — and appends a synthetic - * {@see Status::Skipped} result for each through the case's batch runner - * ({@see CaseInfo::withBatchRunner}). A runner already installed by an outer interceptor - * (e.g. testo/fiber's) is wrapped, never replaced. Every synthetic result is announced with - * the {@see TestPipelineStarting}/{@see TestPipelineFinished} pair, so reporters render the - * skipped lines inside the case block; the core aggregates the case as usual. + * it deactivates every `#[Skip]`-marked test of the case before handing the case on — so lifecycle hooks + * and the per-test pipeline never see them ({@see \Testo\Core\Definition\TestDefinitions::getTests()} + * yields only the active tests) — and appends a synthetic {@see Status::Skipped} result for each + * through the case's batch runner ({@see CaseInfo::withBatchRunner}). A runner already installed + * by an outer interceptor (e.g. testo/fiber's) is wrapped, never replaced. Every synthetic result + * is announced with the {@see TestPipelineStarting}/{@see TestPipelineFinished} pair, so reporters + * render the skipped lines inside the case block; the core aggregates the case as usual. + * + * The case level, the cut-off before the hooks and the delivery after the pipeline handler are + * the design settled in issue #313. + * + * Deactivation happens while the case runs, not while it is located, because a case whose active + * test set is empty does not survive location: {@see \Testo\Application\Internal\SuiteFactory::create()} + * drops it and returns `null` for a suite left without cases. A fully skipped case would + * disappear that way, taking its `#[BeforeClass]`/`#[AfterClass]` hooks and the only place to + * deliver its results with it. By run time the case and its {@see CaseInfo} already exist, which + * makes this the one stage where the contract holds. * * Ordering: {@see InterceptorOptions::ORDER_DEFAULT} sits outer to the lifecycle interceptor * (so filtering happens before `#[BeforeClass]`) and inner to the fiber interceptor (so a * fiber batch runner is already on the case and gets wrapped). * - * Never throws for a parked test — a throw from a case interceptor aborts the whole case. + * `testType: TestType::Test` keeps the interceptor off `#[Bench]` and `#[TestInline]` cases; those + * carry no foreign members to skip anyway, their finders (`BenchFinder`, `InlineFinder`) define + * the case with `prefill: false`. + * + * Two deliberate consequences of delivering results this way: + * + * - The {@see \Testo\Event\Test\TestStarting}/{@see \Testo\Event\Test\TestFinished} pair is not + * emitted. Those announce a test body that begins and ends, and a skipped test has none; the + * TeamCity reporter covers the gap itself, emitting `testStarted` from + * {@see \Testo\Output\Teamcity\TeamcityPlugin::onTestPipelineFinished()} when the body never + * ran. + * - Installing a batch runner takes the case off the core's inline path, which runs each test + * "without a runner/handler call frame so the stack stays shallow for deeply-recursive tests" + * ({@see \Testo\Application\Internal\Runner\CaseRunner::run()}). One `#[Skip]` in a case moves + * its remaining tests onto a handler frame. + * + * The flag is flipped once on the shared case definition, so a second `runTestCase()` over the + * same {@see \Testo\Core\Definition\CaseDefinition} finds no skipped tests left to report. + * + * Never throws for a skipped test — a throw from a case interceptor aborts the whole case. * * @internal * @psalm-internal Testo\Test @@ -71,11 +100,14 @@ public function runTestCase(CaseInfo $info, callable $next): CaseResult return $next($info); } - foreach ($parked as $name => $_) { - $info->definition->tests->undefine($name); + # Deactivated, not discarded — the same way filtering narrows a case + # (FilterInterceptor::locateTestCases()). The core runs only the active tests + # (CaseRunner::run()), so the synthetic results below are their only delivery. + foreach ($parked as [$definition, $_]) { + $definition->active = false; } - # The case still runs (class-level hooks, events, the remaining tests): the parked + # The case still runs (class-level hooks, events, the remaining tests): the skipped # results are appended by the batch runner inside the case window. $inner = $info->batchRunner; return $next($info->withBatchRunner( @@ -109,6 +141,9 @@ private function findParked(CaseInfo $info): array } $parked = []; + # Only the case's active tests: a non-test member (a helper, a lifecycle hook) carries no + # skip semantics, and a test already deactivated by a filter is not part of this run — + # reporting it as Skipped would resurrect what --filter/--group threw away. foreach ($info->definition->tests->getTests() as $name => $definition) { $attributes = Reflection::fetchFunctionAttributes( $definition->reflection, @@ -153,10 +188,10 @@ private function reportSkipped( /** * `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` when a reason is given. - * The generated part is always present, so reporters that render skip failure messages - * can show that the skip came from #[Skip]; - * the test id is the test's address ({@see \Testo\Core\Context\Identity\TestIdentity::fqn()}), - * the string `--filter` takes back. + * The generated part is always present, so reporters that render skip failure messages can + * show that the skip came from `#[Skip]`. The test id is the test's address + * ({@see \Testo\Core\Context\Identity\TestIdentity::fqn()}) — the exact string `--filter` + * takes back. */ private static function reason(TestInfo $info, Skip $attribute): string { diff --git a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php index d1f4aeb3..d76d354a 100644 --- a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php +++ b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php @@ -23,6 +23,7 @@ use Testo\Event\Test\TestPipelineFinished; use Testo\Event\Test\TestPipelineStarting; use Testo\Pipeline\Attribute\InterceptorOptions; +use Testo\Pipeline\Policy\ConflictPolicy; use Testo\Test; use Testo\Test\Internal\SkipInterceptor; use Testo\Test\Skip; @@ -38,7 +39,7 @@ final class SkipInterceptorTest { /** * By the time `$next` (and with it every inner interceptor and lifecycle hook) runs, - * the parked tests are no longer in the case's test set. + * the parked tests are no longer in the case's active test set. */ public function filtersParkedTestsBeforeNext(): void { @@ -209,7 +210,6 @@ public function dispatchesPipelineEventsForParkedTests(): void $result = $interceptor->runTestCase($info, self::coreNext()); - /** @psalm-suppress UndefinedPropertyFetch The anonymous dispatcher exposes $dispatched. */ $events = $dispatcher->dispatched; Assert::count($events, 2); [$starting, $finished] = $events; @@ -237,6 +237,69 @@ public function carriesDescriptionInSyntheticResult(): void ); } + /** + * A skipped test is deactivated, not discarded: it leaves the case's active test set — the + * only set the core runs — yet stays a member of the case for anything that reads them all. + */ + public function skippedTestIsDeactivatedNotDiscarded(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked', 'enabled'); + + $interceptor->runTestCase($info, self::coreNext()); + + $tests = $info->definition->tests; + Assert::array($tests->getTests())->hasKeys('enabled')->doesNotHaveKeys('parked'); + Assert::array($tests->getTests(active: false))->hasKeys('parked'); + Assert::array($tests->all())->hasKeys('parked', 'enabled'); + } + + /** + * `#[Skip]` on a non-test member is inert. A case is prefilled with every member of the class + * — helpers and lifecycle hooks included — and the interceptor walks its tests only, so an + * attribute on a non-test has nothing to act on. + */ + public function skipOnANonTestMemberIsInert(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfoWith(SkipMixedMethodsFixture::class, [ + 'parked' => new TestDefinition( + new \ReflectionMethod(SkipMixedMethodsFixture::class, 'parked'), + isTest: false, + ), + 'enabled' => new TestDefinition(new \ReflectionMethod(SkipMixedMethodsFixture::class, 'enabled')), + ]); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + Assert::same($result->summary->count(Status::Skipped), 0); + Assert::same($result->summary->count(Status::Passed), 1); + Assert::same(self::findResult($result, 'enabled')->status, Status::Passed); + } + + /** + * A test an earlier filter already dropped is not resurrected as Skipped: `--filter`/`--group` + * deactivate at location time, and reporting such a test would put it back into a run it was + * excluded from. + */ + public function alreadyFilteredTestIsNotReportedAsSkipped(): void + { + $interceptor = new SkipInterceptor(self::createDispatcher()); + $info = self::createCaseInfoWith(SkipMixedMethodsFixture::class, [ + 'parked' => new TestDefinition( + new \ReflectionMethod(SkipMixedMethodsFixture::class, 'parked'), + active: false, + ), + 'enabled' => new TestDefinition(new \ReflectionMethod(SkipMixedMethodsFixture::class, 'enabled')), + ]); + + $result = $interceptor->runTestCase($info, self::coreNext()); + + Assert::same($result->summary->count(Status::Skipped), 0); + Assert::same($result->summary->count(Status::Passed), 1); + Assert::same(self::findResult($result, 'enabled')->status, Status::Passed); + } + /** * `#[Skip]` is a plain-test feature: the interceptor declares `testType: TestType::Test`, * so on a bench or inline case the type filter drops it and the attribute is inert. @@ -250,6 +313,24 @@ public function declaresTestTypeScopingSkipToPlainTests(): void Assert::same($attributes[0]->newInstance()->testType, TestType::Test); } + /** + * The rest of the placement contract: `ORDER_DEFAULT` is the slot the class docblock claims + * (outer to the lifecycle interceptor, inner to the fiber one), and `ConflictPolicy::First` + * is what collapses the duplicate instance the class-level fallback alias spawns. + */ + public function declaresOrderAndConflictPolicy(): void + { + $attributes = (new \ReflectionClass(SkipInterceptor::class)) + ->getAttributes(InterceptorOptions::class); + + Assert::count($attributes, 1); + # Both values equal the InterceptorOptions defaults, so pin that they are written out. + Assert::array($attributes[0]->getArguments())->hasKeys('order', 'onConflict'); + $options = $attributes[0]->newInstance(); + Assert::same($options->order, InterceptorOptions::ORDER_DEFAULT); + Assert::same($options->onConflict, ConflictPolicy::First); + } + private static function createDispatcher(): EventDispatcherInterface { return new class implements EventDispatcherInterface { @@ -276,6 +357,18 @@ private static function createCaseInfo(string $class, string ...$methods): CaseI $definitions[$method] = new TestDefinition(new \ReflectionMethod($class, $method)); } + return self::createCaseInfoWith($class, $definitions); + } + + /** + * The same case built from definitions that carry their own flags — the shape a case has after + * prefilling (non-test members) or after an earlier filter deactivated one of its tests. + * + * @param class-string $class + * @param array $definitions + */ + private static function createCaseInfoWith(string $class, array $definitions): CaseInfo + { $caseDefinition = new CaseDefinition( name: $class, type: 'test', From 19b65803a85a238e999725a35ee4ee8e2fa17719 Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 06:22:22 +0500 Subject: [PATCH 19/35] style(test): order static methods first in the #[Skip] sources The repository php-cs-fixer config puts static methods before instance methods; SkipInterceptor::reason() and the two static data providers in the stubs sat at the bottom of their classes. Assisted-By: Claude Fable 5.1 --- plugin/test/src/Internal/SkipInterceptor.php | 28 +++++++++---------- .../Stub/Skip/SkipWithDataProviderStub.php | 16 +++++------ .../SkipSummary/Mixed/SummaryMixedStub.php | 12 ++++---- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index 8f542db9..989294cf 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -125,6 +125,20 @@ function (array $handlers) use ($inner, $info, $parked): array { )); } + /** + * `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` when a reason is given. + * The generated part is always present, so reporters that render skip failure messages can + * show that the skip came from `#[Skip]`. The test id is the test's address + * ({@see \Testo\Core\Context\Identity\TestIdentity::fqn()}) — the exact string `--filter` + * takes back. + */ + private static function reason(TestInfo $info, Skip $attribute): string + { + $message = "{$info->identity->fqn()} is skipped via #[Skip]"; + + return $attribute->reason === '' ? $message : "{$message} ==> {$attribute->reason}"; + } + /** * Collects the parked tests of the case: a method/function-level `#[Skip]` wins over the * class-level one; the class-level attribute is inherited from parents and traits. @@ -185,18 +199,4 @@ private function reportSkipped( return $result; } - - /** - * `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` when a reason is given. - * The generated part is always present, so reporters that render skip failure messages can - * show that the skip came from `#[Skip]`. The test id is the test's address - * ({@see \Testo\Core\Context\Identity\TestIdentity::fqn()}) — the exact string `--filter` - * takes back. - */ - private static function reason(TestInfo $info, Skip $attribute): string - { - $message = "{$info->identity->fqn()} is skipped via #[Skip]"; - - return $attribute->reason === '' ? $message : "{$message} ==> {$attribute->reason}"; - } } diff --git a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php b/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php index 52fe1787..ebcccd9b 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php @@ -16,14 +16,6 @@ final class SkipWithDataProviderStub { public static int $providerCalls = 0; - #[Test] - #[Skip('data-driven test is parked as a whole')] - #[DataProvider('provide')] - public function parked(int $value): void - { - throw new \LogicException('Must never run: the test is parked.'); - } - /** * @return array */ @@ -36,4 +28,12 @@ public static function provide(): array 'two' => [2], ]; } + + #[Test] + #[Skip('data-driven test is parked as a whole')] + #[DataProvider('provide')] + public function parked(int $value): void + { + throw new \LogicException('Must never run: the test is parked.'); + } } diff --git a/plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php b/plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php index 71269a8a..9a9afe18 100644 --- a/plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php +++ b/plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php @@ -17,6 +17,12 @@ #[Test] final class SummaryMixedStub { + public static function provide(): iterable + { + yield [1]; + yield [2]; + } + public function passes(): void { Assert::true(true); @@ -40,10 +46,4 @@ public function parkedDataDriven(int $value): void { throw new \LogicException('Must never run: the test is parked.'); } - - public static function provide(): iterable - { - yield [1]; - yield [2]; - } } From 618231030c7410bb4aa6ea814884cd488469b8b7 Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:14:27 +0500 Subject: [PATCH 20/35] docs(test): say what each #[Skip] stub proves and tighten the attribute docs Every stub in Stub/Skip and Stub/FullyParked opens with the behaviour it pins instead of its mechanics, and the sentences that described a pre-#318 contrast (hooks resolved from the class reflection, tests removed from the case) now match the deactivation the code performs. Skip::class drops the "(v1)" tag from its runtime contract and states the TestPlugin prerequisite as a fact; TestPlugin and the plugin README mention #[Skip] next to #[Test]. Assisted-By: Claude Fable 5.1 --- .../tests/Feature/FullyParkedCaseFeatureTest.php | 10 +++++----- .../tests/Stub/FullyParked/FullyParkedClassStub.php | 6 +++--- .../tests/Stub/FullyParked/fully_parked_functions.php | 9 ++++++++- plugin/test/README.md | 2 +- plugin/test/src/Skip.php | 9 ++++----- plugin/test/src/TestPlugin.php | 3 +++ plugin/test/tests/Stub/Skip/SkipChildStub.php | 3 +++ plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php | 7 +++++++ plugin/test/tests/Stub/Skip/SkipClassLevelStub.php | 4 ++++ plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php | 2 ++ plugin/test/tests/Stub/Skip/SkipInFiberStub.php | 8 +++++--- plugin/test/tests/Stub/Skip/SkipMethodStub.php | 7 ++++++- plugin/test/tests/Stub/Skip/SkipTraitStub.php | 3 +++ plugin/test/tests/Stub/Skip/skip_functions.php | 5 ++++- .../tests/Stub/SkipStandalone/StandaloneParkedTest.php | 4 ++-- .../tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php | 9 +++++---- 16 files changed, 65 insertions(+), 26 deletions(-) diff --git a/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php b/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php index daec8e5f..f6990c59 100644 --- a/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php +++ b/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php @@ -15,8 +15,9 @@ use Tests\Lifecycle\Stub\FullyParked\FullyParkedFunctionState; /** - * End-to-end proof of the `#[Skip]` contract: `#[BeforeClass]`/`#[AfterClass]` hooks of a case - * still run when every test of the case is parked with `#[Skip]`. + * End-to-end regression test for {@see LifecycleInterceptor}: the `#[BeforeClass]`/`#[AfterClass]` hooks + * of a case still run when an outer case interceptor — here `#[Skip]` from `testo/test` — leaves + * the case without a single active test. * * The `#[Skip]` case interceptor deactivates the parked tests before the {@see LifecycleInterceptor} * collects the case's hooks, so hook discovery must not depend on the surviving tests. It does not: @@ -43,9 +44,8 @@ public function __construct() } /** - * The `#[Skip]` contract for a function-based case: class-level hooks fire exactly once per - * catalog run even though no test of the case stays active; per-test hooks have - * nothing to wrap and stay silent. + * The function-based case shape: class-level hooks fire exactly once per catalog run even + * though no test of the case stays active; per-test hooks have nothing to wrap and stay silent. */ public function classHooksRunForFullyParkedFunctionCase(): void { diff --git a/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php b/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php index dd3add7c..05ef4717 100644 --- a/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php +++ b/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php @@ -10,9 +10,9 @@ use Testo\Test\Skip; /** - * Class-based analog of the fully parked function case: for a class the hooks are resolved from - * {@see \Testo\Core\Definition\CaseDefinition::$reflection}, so they never depended on the - * surviving tests — pinned here so both flavors stay in lockstep. + * Class-based analog of the fully skipped function case in the same directory + * ({@see FullyParkedFunctionState}): the hooks are the case's non-tests, so they never + * depended on the surviving tests — pinned here so both flavors stay in lockstep. * * Static hook counters accumulate across catalog runs — feature tests assert deltas. The hooks * are static so the fully parked class is never instantiated. diff --git a/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php b/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php index bc0b05d6..16a7bbfd 100644 --- a/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php +++ b/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php @@ -12,7 +12,10 @@ use Testo\Test\Skip; /** - * A fully parked function-based case: every `#[Test]` function is under `#[Skip]`. + * A fully parked function-based case: every `#[Test]` function is under `#[Skip]`. Mirrors + * {@see FullyParkedClassStub} for the function-based shape of the same scenario. Its two tests + * spell the attribute both ways — `parkedFnOne` with a reason, `parkedFnTwo` without — so neither + * form leaves the case with an active test. * * The `#[Skip]` case interceptor deactivates the skipped tests — they leave the case's active * test set — before the {@see \Testo\Lifecycle\Internal\LifecycleInterceptor} runs, so hook @@ -60,6 +63,10 @@ function parkedFnTwo(): void throw new \LogicException('Must never run: the test is parked.'); } +/** + * Call counters for the lifecycle functions above. Not autoloadable — the feature test + * `require_once`s this file before touching the counters. + */ final class FullyParkedFunctionState { public static int $beforeClassCalls = 0; diff --git a/plugin/test/README.md b/plugin/test/README.md index 8b873db9..d8dea55c 100644 --- a/plugin/test/README.md +++ b/plugin/test/README.md @@ -25,7 +25,7 @@ ## About -Provides the `#[Test]` attribute and the locator that picks up attribute-marked test classes and methods. Without this plugin Testo can still run tests via naming conventions or other locators, but the canonical attribute-driven discovery comes from here. +Provides the `#[Test]` attribute and the locator that picks up attribute-marked test classes and methods. Without this plugin Testo can still run tests via naming conventions or other locators, but the canonical attribute-driven discovery comes from here. It also provides `#[Skip]`, which reports a marked test — or every test of a marked class — as skipped instead of running it. ## Install diff --git a/plugin/test/src/Skip.php b/plugin/test/src/Skip.php index 425943a1..4602928d 100644 --- a/plugin/test/src/Skip.php +++ b/plugin/test/src/Skip.php @@ -40,7 +40,7 @@ * terminal prints the skipped line without it, and the compact `--json` report counts the * test in its totals. * - * Runtime contract (v1): + * Runtime contract: * * - The skipped test never enters the per-test pipeline: `#[BeforeTest]`/`#[AfterTest]` * hooks, data providers, `#[Retry]`/`#[Repeat]`, fibers and coverage never engage. @@ -55,10 +55,9 @@ * - On a non-test method the attribute is inert (like `#[Group]` on a helper). So is it on * a `#[Bench]` or `#[TestInline]` target: only plain test cases are handled. * - * Prerequisite: the handler, {@see SkipInterceptor}, is registered by {@see TestPlugin}. - * Without the plugin only a class-level `#[Skip]` keeps working — through the - * {@see FallbackInterceptor} declared below, which the pipeline spawns from class attributes - * only; a method- or function-level `#[Skip]` is then inert. + * Prerequisite: {@see TestPlugin}, which registers the handler {@see SkipInterceptor}. Without + * the plugin only a class-level `#[Skip]` keeps working — through the {@see FallbackInterceptor} + * declared below; a method- or function-level `#[Skip]` is then inert. * * For skipping at runtime — from the test body, based on the environment — throw * {@see SkipTest} instead; the `is skipped via #[Skip]` marker tells the two apart in reports. diff --git a/plugin/test/src/TestPlugin.php b/plugin/test/src/TestPlugin.php index b5daebce..d9d305c4 100644 --- a/plugin/test/src/TestPlugin.php +++ b/plugin/test/src/TestPlugin.php @@ -14,6 +14,9 @@ /** * Find tests by the {@see Test} attribute. * + * Also enables {@see Skip}: {@see SkipInterceptor} reports `#[Skip]`-marked tests as skipped + * without running them. + * * @api */ final readonly class TestPlugin implements PluginConfigurator diff --git a/plugin/test/tests/Stub/Skip/SkipChildStub.php b/plugin/test/tests/Stub/Skip/SkipChildStub.php index 2a113cfd..eac6f652 100644 --- a/plugin/test/tests/Stub/Skip/SkipChildStub.php +++ b/plugin/test/tests/Stub/Skip/SkipChildStub.php @@ -6,6 +6,9 @@ use Testo\Test; +/** + * A concrete case without its own `#[Skip]`: the attribute comes from {@see SkipParentStub}. + */ #[Test] final class SkipChildStub extends SkipParentStub { diff --git a/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php b/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php index fde6077b..d02d381b 100644 --- a/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php +++ b/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php @@ -7,6 +7,13 @@ use Testo\Test; use Testo\Test\Skip; +/** + * Stub for verifying which {@see Skip} reason a test is skipped with: a method-level attribute + * wins over the class-level one, and a method without its own attribute inherits the class reason. + * + * The method-level attribute wins as a whole, so {@see self::emptyOwnReason()} is skipped with no + * reason at all instead of falling back to the class one. + */ #[Test] #[Skip('class-wide reason')] final class SkipClassAndMethodStub diff --git a/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php b/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php index c6c90231..cf06c92b 100644 --- a/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php +++ b/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php @@ -7,6 +7,10 @@ use Testo\Test; use Testo\Test\Skip; +/** + * A class-level `#[Skip]`: both tests of the case are skipped with the class reason, proving the + * attribute covers every test and not just the first one. + */ #[Test] #[Skip('the whole case is parked')] final class SkipClassLevelStub diff --git a/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php b/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php index ad93acac..71f4d796 100644 --- a/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php +++ b/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php @@ -9,6 +9,8 @@ /** * Only parked tests and no class-level hooks: the class must never be instantiated. + * + * The flag is a one-way latch — nothing resets it, so feature tests assert it absolutely. */ #[Test] #[Skip('fully parked, must not construct')] diff --git a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php b/plugin/test/tests/Stub/Skip/SkipInFiberStub.php index cc7f3412..11f20007 100644 --- a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php +++ b/plugin/test/tests/Stub/Skip/SkipInFiberStub.php @@ -11,14 +11,16 @@ use Testo\Test\Skip; /** - * A class-level `#[RunInFiber(Schedule::RoundRobin)]` installs a fiber batch runner on the + * A class-level `#[RunInFiber]` ({@see Schedule::RoundRobin}) installs a fiber batch runner on the * case; the skip interceptor must wrap that runner, not replace it. The two enabled tests * suspend once each and write to a shared log: only the case scheduler produces the * round-robin interleaving `first.1, second.1, first.2, second.2` — run sequentially, the * `\Fiber::suspend()` outside a fiber would throw and the log would stop short. * - * The log accumulates across catalog runs — the stubs and the feature test assert the tail - * written by their own run. + * Driven through {@see \Testo\Testing\Helper\TestRunner} by the Feature suite; + * {@see \Tests\Test\Feature\SkipFeatureTest::fiberBatchRunnerSurvivesTheWrap()} asserts the + * interleaving. The log accumulates + * across catalog runs — this stub's tests and the feature test assert the tail written by their own run. */ #[Test] #[RunInFiber(Schedule::RoundRobin)] diff --git a/plugin/test/tests/Stub/Skip/SkipMethodStub.php b/plugin/test/tests/Stub/Skip/SkipMethodStub.php index 2483fb31..68e51944 100644 --- a/plugin/test/tests/Stub/Skip/SkipMethodStub.php +++ b/plugin/test/tests/Stub/Skip/SkipMethodStub.php @@ -8,6 +8,11 @@ use Testo\Test; use Testo\Test\Skip; +/** + * Method-level `#[Skip]`, with and without a reason: only the marked tests of the case are + * deactivated; the unmarked neighbor still runs. Both marked bodies throw, so a marked test that + * reaches the pipeline anyway fails loudly instead of passing quietly. + */ #[Test] final class SkipMethodStub { @@ -25,7 +30,7 @@ public function parkedNoReason(): void public function enabled(): void { - // Control neighbor: stays runnable next to the parked ones. + # Control neighbor: stays runnable next to the parked ones. Assert::true(true); } } diff --git a/plugin/test/tests/Stub/Skip/SkipTraitStub.php b/plugin/test/tests/Stub/Skip/SkipTraitStub.php index a714c4c4..f7547567 100644 --- a/plugin/test/tests/Stub/Skip/SkipTraitStub.php +++ b/plugin/test/tests/Stub/Skip/SkipTraitStub.php @@ -6,6 +6,9 @@ use Testo\Test; +/** + * A case without its own `#[Skip]`: the class-level attribute comes from {@see SkipMarkerTrait}. + */ #[Test] final class SkipTraitStub { diff --git a/plugin/test/tests/Stub/Skip/skip_functions.php b/plugin/test/tests/Stub/Skip/skip_functions.php index 58a726e9..2edb773d 100644 --- a/plugin/test/tests/Stub/Skip/skip_functions.php +++ b/plugin/test/tests/Stub/Skip/skip_functions.php @@ -8,6 +8,8 @@ use Testo\Test; use Testo\Test\Skip; +# Proves #[Skip] reaches a function-based case as well: the test is reported as Skipped and its +# message is built from the function FQN. #[Test] #[Skip('functional test is parked')] function parked_function(): void @@ -15,9 +17,10 @@ function parked_function(): void throw new \LogicException('Must never run: the test is parked.'); } +# Control neighbor of the same case: an enabled function next to a skipped one still runs through +# the batch runner the interceptor installs on the case, and passes. #[Test] function enabled_function(): void { - // Control neighbor for the function-scoped case. Assert::true(true); } diff --git a/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php b/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php index 1029dc73..515b2a6d 100644 --- a/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php +++ b/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php @@ -9,8 +9,8 @@ /** * A catalog for the standalone-fallback run: discovered by naming convention alone (no * `#[Test]` attribute), executed without `TestPlugin` — only the class-level `#[Skip]` - * fallback parks these tests. Lives in its own directory so no regular suite picks up - * the convention-named class. + * fallback parks these tests. Lives in its own directory so the standalone run's + * `FinderConfig` can point at it alone and pick up nothing else. */ #[Skip('standalone catalog is parked')] final class StandaloneParkedTest diff --git a/plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php b/plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php index 9a9afe18..d5209a99 100644 --- a/plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php +++ b/plugin/test/tests/Stub/SkipSummary/Mixed/SummaryMixedStub.php @@ -10,9 +10,10 @@ use Testo\Test\Skip; /** - * One catalog with every outcome kind, so the summary arithmetic - * `total = passed + failed + skipped` can be pinned. Not part of the Stub/Skip catalog: - * the deliberately failing test would turn the feature runs red. + * One directory with a passing, a failing and two skipped tests, so the summary arithmetic + * `total = passed + failed + skipped` can be pinned. Kept out of the shared `Stub/Skip` directory: + * {@see \Tests\Test\Feature\SkipSummaryTest} asserts exact per-status counts, so the set of + * outcomes here has to stay closed. */ #[Test] final class SummaryMixedStub @@ -30,7 +31,7 @@ public function passes(): void public function fails(): void { - // Controlled failure: the parked tests must not hide it from the totals. + # Controlled failure: the parked tests must not hide it from the totals. Assert::true(false); } From 967e2c6bc5c907fb0686afa64848662b80d42cdc Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:14:43 +0500 Subject: [PATCH 21/35] test(test): pin the #[Skip] fallback message and align the stubs with the fixtures test(output): pin that a reason-less skip writes no JUnit message The standalone fallback test asserts the SkipTest failure and its full message instead of the status alone; the feature suite carries a class-level #[Group('async')] because every method replays the fiber stub; SkipWithHooksStub counters take the Calls suffix of their lifecycle twin; the data-provider, repeat and retry stubs mark the class with #[Test] like the rest of the directory. SkipSummaryTest pins the totals as literals, SkipAttributeTest uses Assert::instanceOf and drops the attribute-repeatability test, and the unit fixtures keep empty bodies since nothing ever executes them. JUnitWriterTest gains the negative twin of the skip-reason test. Assisted-By: Claude Fable 5.1 --- .../Feature/SkipFallbackStandaloneTest.php | 18 +++++++- plugin/test/tests/Feature/SkipFeatureTest.php | 32 ++++++++++---- plugin/test/tests/Feature/SkipSummaryTest.php | 42 +++++++++---------- .../Stub/Skip/SkipWithDataProviderStub.php | 5 ++- .../tests/Stub/Skip/SkipWithHooksStub.php | 22 ++++++---- .../tests/Stub/Skip/SkipWithRepeatStub.php | 8 +++- .../tests/Stub/Skip/SkipWithRetryStub.php | 7 +++- .../Unit/Fixture/SkipClassLevelFixture.php | 17 +++----- .../Unit/Fixture/SkipMixedMethodsFixture.php | 16 +++---- plugin/test/tests/Unit/SkipAttributeTest.php | 19 +-------- tests/Output/Unit/JUnit/JUnitWriterTest.php | 22 ++++++++++ 11 files changed, 124 insertions(+), 84 deletions(-) diff --git a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php b/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php index 49b24476..d19ab09c 100644 --- a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php +++ b/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php @@ -13,11 +13,13 @@ use Testo\Codecov\Covers; use Testo\Convention\NamingConventionPlugin; use Testo\Core\Context\TestResult; +use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; use Testo\Test; use Testo\Test\Internal\SkipInterceptor; use Testo\Test\Skip; use Testo\Test\TestPlugin; +use Tests\Test\Stub\SkipStandalone\StandaloneParkedTest; /** * The standalone contract of `#[Skip]`: with `TestPlugin` not registered, the attribute's @@ -52,11 +54,23 @@ public function classLevelSkipFallsBackWithoutTestPlugin(): void } } - # Exactly one result per stub test: the fallback spawn does not duplicate delivery. + # No TestPlugin in this run: the interceptor the attribute spawns through its own + # #[FallbackInterceptor] is what reports both tests of the catalog. Assert::count($tests, 2); + + $messages = []; foreach ($tests as $test) { Assert::same($test->status, Status::Skipped); - Assert::true(\str_contains((string) $test->failure?->getMessage(), ' ==> ')); + Assert::instanceOf($test->failure, SkipTest::class); + $messages[] = $test->failure?->getMessage(); } + + # The order the results are appended in is not a contract; the composed messages are — + # the `is skipped via #[Skip]` marker and the class-level reason. + \sort($messages); + Assert::same($messages, [ + StandaloneParkedTest::class . '::testFirstParked is skipped via #[Skip] ==> standalone catalog is parked', + StandaloneParkedTest::class . '::testSecondParked is skipped via #[Skip] ==> standalone catalog is parked', + ]); } } diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index 25e910da..4f1c7f24 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -9,6 +9,7 @@ use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; use Testo\Data\MultipleResult; +use Testo\Filter\Group; use Testo\Test; use Testo\Test\Internal\SkipInterceptor; use Testo\Test\Skip; @@ -28,7 +29,19 @@ use Tests\Test\Stub\Skip\SkipWithRepeatStub; use Tests\Test\Stub\Skip\SkipWithRetryStub; +/** + * End-to-end checks that {@see SkipInterceptor}, registered by {@see \Testo\Test\TestPlugin}, + * deactivates the `#[Skip]`-marked tests of a case before it runs and delivers them back as + * {@see Status::Skipped} results carrying the composed skip message. + * + * Every test method replays the whole `Stub/Skip` directory through {@see TestRunner} and then inspects + * either the returned result or what the stubs recorded. The stubs' static counters and flags + * survive those runs, so a check either takes a delta over its own run or pins a value that must + * never move at all. The directory holds a `#[RunInFiber]` stub, executed by every one of those + * runs — hence the class-level `#[Group('async')]`. + */ #[Test] +#[Group('async')] #[TestingSuite(path: __DIR__ . '/../Stub/Skip', plugins: [PipelineEntrySpyPlugin::class])] #[Covers(Skip::class)] #[Covers(SkipInterceptor::class)] @@ -120,7 +133,8 @@ public function functionalTestUsesFunctionFqnInMessage(): void /** * The function-based analog of the control neighbor: an enabled function of a partially - * parked file still runs through the wrapped batch runner and passes. + * parked file still runs through the batch runner the interceptor installs on the case, and + * passes. */ public function controlNeighborFunctionNextToParkedFunctionStillRuns(): void { @@ -149,19 +163,19 @@ public function parkedResultCarriesOriginAttribute(): void */ public function classHooksRunButTestHooksDoNot(): void { - $beforeClass = SkipWithHooksStub::$beforeClass; - $afterClass = SkipWithHooksStub::$afterClass; - $beforeTest = SkipWithHooksStub::$beforeTest; - $afterTest = SkipWithHooksStub::$afterTest; + $beforeClass = SkipWithHooksStub::$beforeClassCalls; + $afterClass = SkipWithHooksStub::$afterClassCalls; + $beforeTest = SkipWithHooksStub::$beforeTestCalls; + $afterTest = SkipWithHooksStub::$afterTestCalls; $result = TestRunner::runTest([SkipWithHooksStub::class, 'parked']); Assert::same($result->status, Status::Skipped); - Assert::same(SkipWithHooksStub::$beforeClass - $beforeClass, 1); - Assert::same(SkipWithHooksStub::$afterClass - $afterClass, 1); + Assert::same(SkipWithHooksStub::$beforeClassCalls - $beforeClass, 1); + Assert::same(SkipWithHooksStub::$afterClassCalls - $afterClass, 1); # Only the enabled control test of the case went through the per-test pipeline. - Assert::same(SkipWithHooksStub::$beforeTest - $beforeTest, 1); - Assert::same(SkipWithHooksStub::$afterTest - $afterTest, 1); + Assert::same(SkipWithHooksStub::$beforeTestCalls - $beforeTest, 1); + Assert::same(SkipWithHooksStub::$afterTestCalls - $afterTest, 1); } public function fullyParkedCaseWithoutHooksIsNeverInstantiated(): void diff --git a/plugin/test/tests/Feature/SkipSummaryTest.php b/plugin/test/tests/Feature/SkipSummaryTest.php index 9cbc4f50..c78ece89 100644 --- a/plugin/test/tests/Feature/SkipSummaryTest.php +++ b/plugin/test/tests/Feature/SkipSummaryTest.php @@ -18,8 +18,9 @@ use Testo\Test\Skip; /** - * Session-level arithmetic for parked tests: they are counted, not lost — and they never - * turn a run red on their own. + * Session-level arithmetic for {@see Skip}-marked tests: they are counted in the run's + * {@see \Testo\Core\Value\Summary}, not lost — and {@see Status::Skipped} never turns a run + * red on its own. */ #[Test] #[Covers(Skip::class)] @@ -27,34 +28,31 @@ final class SkipSummaryTest { /** - * The mixed catalog holds one passing, one failing and two parked tests (one of them - * data-driven). The classic off-by-parked bug: totals must satisfy - * `total = passed + failed + skipped` with the data-driven parked test counted exactly - * once — and the failing neighbor still fails the run. + * The mixed directory holds one passing, one failing and two skipped tests (one of them + * data-driven). The classic off-by-one bug lives in that mix: the skipped tests must be + * counted rather than lost, and the failing neighbor must still fail the run. */ public function parkedTestsAddUpAndFailingNeighborStillFailsTheRun(): void { - $result = self::run(__DIR__ . '/../Stub/SkipSummary/Mixed'); + $run = self::run(__DIR__ . '/../Stub/SkipSummary/Mixed'); - $summary = $result->summary; + $summary = $run->summary; Assert::same($summary->count(Status::Passed), 1); Assert::same($summary->count(Status::Failed), 1); Assert::same($summary->count(Status::Skipped), 2); - Assert::same( - $summary->total(), - $summary->passed() + $summary->failed() + $summary->count(Status::Skipped), - ); - Assert::same($result->status, Status::Failed); + # Four tests total: the skipped data-driven one is counted once, not once per data set. + Assert::same($summary->total(), 4); + Assert::same($run->status, Status::Failed); } /** - * A run consisting only of `#[Skip]`-marked tests is a success: Skipped is neither a - * success nor a failure, so nothing fails the run. + * A run consisting only of {@see Skip}-marked tests is a success: {@see Status::Skipped} + * is neither a success nor a failure, so nothing fails the run. * - * The same run pins the dedup invariant: with `TestPlugin` registered, a class-level - * `#[Skip]` also spawns a fallback instance of the interceptor, and the conflict policy - * must collapse the duplicate — each parked test yields exactly one result, not one per - * delivery path. + * The same run pins one result per skipped test. The stub carries a class-level `#[Skip]`, + * so the pipeline spawns a fallback {@see SkipInterceptor} next to the one + * {@see \Testo\Test\TestPlugin} registers; a second delivery would show up here as an + * inflated total and an extra name. */ public function runOfOnlyParkedTestsIsSuccessfulAndDeliveredOnce(): void { @@ -69,7 +67,7 @@ public function runOfOnlyParkedTestsIsSuccessfulAndDeliveredOnce(): void $cases[] = $case; } } - # The catalog holds one class with two parked tests. + # The directory holds one class with two skipped tests. Assert::count($cases, 1); $names = \array_map( static fn(TestResult $result): string => $result->info->name, @@ -79,14 +77,14 @@ public function runOfOnlyParkedTestsIsSuccessfulAndDeliveredOnce(): void Assert::same($names, ['firstParked', 'secondParked']); } - private static function run(string $catalog): RunResult + private static function run(string $path): RunResult { return Application::createFromConfig(new ApplicationConfig( src: [], suites: [ new SuiteConfig( 'SkipSummary', - location: new FinderConfig(include: [$catalog]), + location: new FinderConfig(include: [$path]), ), ], ))->run(); diff --git a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php b/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php index ebcccd9b..d205b24e 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php @@ -9,9 +9,13 @@ use Testo\Test\Skip; /** + * Stub with a data-driven test skipped by {@see Skip}: + * {@see \Testo\Data\Internal\DataProviderInterceptor} must never expand the test's data sets. + * * The provider counts its calls before returning anything, so the counter tells "never * called" apart from "called but not iterated" — a generator body would only run on iteration. */ +#[Test] final class SkipWithDataProviderStub { public static int $providerCalls = 0; @@ -29,7 +33,6 @@ public static function provide(): array ]; } - #[Test] #[Skip('data-driven test is parked as a whole')] #[DataProvider('provide')] public function parked(int $value): void diff --git a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php b/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php index 4390e1ac..01d215dc 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php @@ -13,38 +13,44 @@ use Testo\Test\Skip; /** + * Stub for verifying that a `#[Skip]`-marked test never reaches the per-test pipeline: + * {@see \Testo\Test\Internal\SkipInterceptor} deactivates it before the case's hooks and remaining + * tests run, so the `#[BeforeClass]`/`#[AfterClass]` hooks still fire once per case run while + * `#[BeforeTest]`/`#[AfterTest]` fire for the enabled control test {@see enabled()} alone. + * Driven by {@see \Tests\Test\Feature\SkipFeatureTest::classHooksRunButTestHooksDoNot()}. + * * Static hook counters accumulate across catalog runs — feature tests assert deltas. */ #[Test] final class SkipWithHooksStub { - public static int $beforeClass = 0; - public static int $afterClass = 0; - public static int $beforeTest = 0; - public static int $afterTest = 0; + public static int $beforeClassCalls = 0; + public static int $afterClassCalls = 0; + public static int $beforeTestCalls = 0; + public static int $afterTestCalls = 0; #[BeforeClass] public static function bootCase(): void { - ++self::$beforeClass; + ++self::$beforeClassCalls; } #[AfterClass] public static function shutdownCase(): void { - ++self::$afterClass; + ++self::$afterClassCalls; } #[BeforeTest] public static function bootTest(): void { - ++self::$beforeTest; + ++self::$beforeTestCalls; } #[AfterTest] public static function shutdownTest(): void { - ++self::$afterTest; + ++self::$afterTestCalls; } #[Skip('parked next to hooks')] diff --git a/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php b/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php index 9bd2288a..51be04f2 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php @@ -8,11 +8,17 @@ use Testo\Test; use Testo\Test\Skip; +/** + * `#[Skip]` on a test that also carries `#[Repeat]`: the repeat is resolved in the per-test + * pipeline, which a skipped test never enters, so the body must not run at all. The latch is + * never reset — {@see \Tests\Test\Feature\SkipFeatureTest::repeatDoesNotEngageForParkedTest()} + * asserts it absolutely, not as a delta. + */ +#[Test] final class SkipWithRepeatStub { public static bool $bodyRan = false; - #[Test] #[Skip('parked, repeat must not engage')] #[Repeat(times: 3)] public function parked(): void diff --git a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php b/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php index 8fa404ea..c59c9023 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php @@ -8,11 +8,16 @@ use Testo\Test; use Testo\Test\Skip; +/** + * `#[Skip]` composed with `#[Retry]`: the retry policy is resolved in the per-test pipeline, which + * a skipped test never enters, so the body must not run at all. The counter tells a single stray + * run apart from a full retry cycle. + */ +#[Test] final class SkipWithRetryStub { public static int $attempts = 0; - #[Test] #[Skip('parked, retry must not engage')] #[Retry(maxAttempts: 3)] public function parked(): void diff --git a/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php b/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php index 15135a6c..70eef1be 100644 --- a/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php +++ b/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php @@ -7,6 +7,8 @@ use Testo\Test\Skip; /** + * Fixture with a class-level `#[Skip]` and method-level overrides. + * * Used by {@see \Tests\Test\Unit\Internal\SkipInterceptorTest}: a class-level `#[Skip]` * parks every test; a method-level `#[Skip]` wins over the class-level one, reason included — * also when its own reason is empty. @@ -14,20 +16,11 @@ #[Skip('entire case is parked')] final class SkipClassLevelFixture { - public function first(): void - { - throw new \LogicException('Must never run: the test is parked.'); - } + public function first(): void {} #[Skip('method beats class')] - public function second(): void - { - throw new \LogicException('Must never run: the test is parked.'); - } + public function second(): void {} #[Skip] - public function third(): void - { - throw new \LogicException('Must never run: the test is parked.'); - } + public function third(): void {} } diff --git a/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php b/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php index e682ebc6..34786a04 100644 --- a/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php +++ b/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php @@ -7,8 +7,10 @@ use Testo\Test\Skip; /** - * Used by {@see \Tests\Test\Unit\Internal\SkipInterceptorTest}: a case mixing parked and - * enabled tests. Lives in Fixture (excluded from discovery), so the throwing bodies never run. + * Fixture mixing skipped and enabled tests. + * + * Used by {@see \Tests\Test\Unit\Internal\SkipInterceptorTest}: one test is skipped with a reason, + * one without a reason, and one stays enabled to show what the interceptor leaves alone. */ final class SkipMixedMethodsFixture { @@ -16,16 +18,10 @@ final class SkipMixedMethodsFixture * Checks that order totals include the reworked pricing. */ #[Skip('broken by the pricing rework, see ISSUE-123')] - public function parked(): void - { - throw new \LogicException('Must never run: the test is parked.'); - } + public function parked(): void {} #[Skip] - public function parkedNoReason(): void - { - throw new \LogicException('Must never run: the test is parked.'); - } + public function parkedNoReason(): void {} public function enabled(): void {} } diff --git a/plugin/test/tests/Unit/SkipAttributeTest.php b/plugin/test/tests/Unit/SkipAttributeTest.php index 5c3f4ee8..190ca124 100644 --- a/plugin/test/tests/Unit/SkipAttributeTest.php +++ b/plugin/test/tests/Unit/SkipAttributeTest.php @@ -6,7 +6,6 @@ use Testo\Assert; use Testo\Codecov\Covers; -use Testo\Expect; use Testo\Pipeline\Attribute\FallbackInterceptor; use Testo\Pipeline\Attribute\Interceptable; use Testo\Test; @@ -50,29 +49,13 @@ public function targetsClassMethodAndFunctionOnly(): void ); } - /** - * A skip carries a single reason — a second `#[Skip]` on the same target has nowhere - * to go, so PHP itself rejects the duplicate when the attribute is instantiated. This - * is the diagnostic the skip interceptor surfaces for such a target. - */ - public function duplicateOnOneTargetIsRejected(): never - { - $attributes = (new \ReflectionObject(new #[Skip('first')] #[Skip('second')] class {})) - ->getAttributes(Skip::class); - - Expect::exception(\Error::class) - ->withMessage('Attribute "Testo\Test\Skip" must not be repeated'); - - $attributes[0]->newInstance(); - } - /** * The pipeline collects `Interceptable` attributes; without the marker a class-level * `#[Skip]` would be invisible to the attributes interceptor. */ public function isInterceptable(): void { - Assert::true(\is_a(Skip::class, Interceptable::class, true)); + Assert::instanceOf(new Skip(), Interceptable::class); } /** diff --git a/tests/Output/Unit/JUnit/JUnitWriterTest.php b/tests/Output/Unit/JUnit/JUnitWriterTest.php index c3669dfb..613f387b 100644 --- a/tests/Output/Unit/JUnit/JUnitWriterTest.php +++ b/tests/Output/Unit/JUnit/JUnitWriterTest.php @@ -188,6 +188,28 @@ public function skippedTestCarriesTheReasonFromTheFailureMessage(): void Assert::same((string) $skipped['message'], 'sqlite extension is missing'); } + /** + * No reason — no `message`: an empty attribute would read as an empty reason. + */ + #[Covers(JUnitWriter::class)] + public function skippedTestWithoutAReasonOmitsTheMessage(): void + { + $writer = new JUnitWriter(); + $writer->startSuite('MySuite'); + $writer->addTestResult(self::makeResult( + 'passingTest', + Status::Skipped, + failure: new SkipTest(), + )); + $writer->finishSuite(); + + $xml = self::loadXml($writer->generate('Testo')); + + $skipped = $xml->testsuite->testcase->skipped; + Assert::count($skipped, 1); + Assert::null($skipped['message']); + } + public function cancelledTestCountsAsSkipped(): void { // Arrange From fca9911e658d7164520b0009a5da987ff6a42cc5 Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:14:43 +0500 Subject: [PATCH 22/35] docs(skills): describe #[Skip] deactivation and its package in the skills testo-plugin-author gets a subsection on skipping from a case-level interceptor with the post-#318 mechanic (TestDefinition::$active, getTests() yielding only active tests, synthetic results through CaseInfo::withBatchRunner); testo-write-tests names the testo/test package and the TestPlugin prerequisite, prescribes a reason that points at an issue and says how a skipped test is reported; testo-flaky-tests gains the #[Skip] x #[Retry]/#[Repeat] pitfall and delegates the reporter mechanics to testo-write-tests. Assisted-By: Claude Fable 5.1 --- skills/testo-flaky-tests/SKILL.md | 3 ++- skills/testo-plugin-author/SKILL.md | 24 ++++++++++++++++++++---- skills/testo-write-tests/SKILL.md | 17 +++++++++++------ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/skills/testo-flaky-tests/SKILL.md b/skills/testo-flaky-tests/SKILL.md index a4a4280c..6e12ad94 100644 --- a/skills/testo-flaky-tests/SKILL.md +++ b/skills/testo-flaky-tests/SKILL.md @@ -88,10 +88,11 @@ Don't ship `#[Repeat(times: 50)]` long-term on a fast suite — CI cost adds up. 3. Is the flakiness from shared state inside the suite (ordering)? - Don't reach for either attribute. Fix isolation (lifecycle hooks, fresh fixtures). 4. Parking the test for a longer while (root cause known but not fixable now)? - - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays counted in reports as Skipped; the reason is carried in the result and shown by the JUnit/TeamCity/HTML reporters (full contract in testo-write-tests). `#[Retry]` is for stabilizing, not parking. + - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays counted in reports as Skipped (full contract in the `testo-write-tests` skill). `#[Retry]` is for stabilizing, not parking. ## Pitfalls - A test with `Expect::exception(...)` and `#[Retry]` is almost always wrong — expected exceptions are deterministic by design. - Don't use retries to paper over network calls in unit tests — replace the dependency with a fake instead. - Throwing `SkipTest` / `CancelTest` from the body short-circuits both `#[Retry]` and `#[Repeat]` — the loop stops immediately and the result keeps the `Skipped` / `Cancelled` status. That's intentional (skipping isn't a failure to retry against), but worth knowing when a "flaky" test is actually skipping on some runs. +- `#[Skip]` next to `#[Retry]` / `#[Repeat]` wins even harder than that: the test is deactivated before the case collects its tests, so it never enters the per-test pipeline and the retry/repeat loop is not started at all (the case itself still runs, with its class-level hooks). diff --git a/skills/testo-plugin-author/SKILL.md b/skills/testo-plugin-author/SKILL.md index 2167be24..e51fc672 100644 --- a/skills/testo-plugin-author/SKILL.md +++ b/skills/testo-plugin-author/SKILL.md @@ -172,10 +172,24 @@ if (!$reachable) { } ``` -The canonical shipped example is `Testo\Test\Internal\SkipInterceptor` (`plugin/test`): a -case-level interceptor that filters `#[Skip]`-marked tests out of the case before lifecycle -hooks and returns synthetic Skipped results for them — constructing each `TestResult` by hand -(status, `SkipTest` failure, self-stamped `Summary::forTest(...)`) instead of throwing. +### Skipping from a case interceptor — do call `$next` + +At **case** level the rule inverts: returning a `CaseResult` without `$next` drops the case whole — +its `#[BeforeClass]`/`#[AfterClass]` hooks and every test it still had to run. Skip a subset instead: + +- Deactivate what you skip — pick your tests out of `$info->definition->tests->getTests()` and set + `$definition->active = false` on each of those. Deactivated, not discarded: `getTests()` then + yields only the rest, and those are the tests the core runs. +- Hand back their results yourself, from `CaseInfo::withBatchRunner`: **wrap** the runner already on + the case (testo/fiber may have set one), never replace it, and append one synthetic `TestResult` + per skipped test after the inner runner returns. +- Dispatch `TestPipelineStarting`/`TestPipelineFinished` around each synthetic result, or reporters + never render its line, and stamp `summary: Summary::forTest(Status::Skipped)` on it — a result that + never passes through the test runner is not counted for you. + +The shipped implementation of exactly this shape is `Testo\Test\Internal\SkipInterceptor` in +`plugin/test`, serving the `#[Skip]` attribute (whose contract is in the `testo-write-tests` skill). Read it as +a reference — it is `@internal`, don't import or subclass it. ## Container scopes — provision per-case / per-suite resources @@ -251,6 +265,8 @@ $optedOut = $method->getAttributes(WithoutTransaction::class) !== []; ## Pitfalls - **Skipping**: return a `Status::Skipped` `TestResult`; never `throw SkipTest` from an interceptor. + From a **case** interceptor still call `$next` — deactivate the tests you skip and append their + results through the batch runner. - **Cleanup**: wrap `$next()` in `try/finally`; a later interceptor may throw. - **State**: prefer pipeline attributes / container scope over mutable interceptor fields. - **Listeners** observe; **interceptors** change behaviour. Don't try to alter a run from a listener. diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index eb15c962..a15fc16c 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -136,25 +136,30 @@ Constraints: - Subclasses work: `class MissingExtensionSkip extends SkipTest {}` is still recognized. - Return type stays `void`, or `never` if the throw is unconditional. -## Parking a test: `#[Skip]` +## Parking a test with #[Skip] -To skip a test declaratively — without running any of its code — put `Testo\Test\Skip` on the -test method, the class (skips every test of the case; inherited from parents and traits, a -method-level reason wins), or a free function: +To skip a test declaratively — without running any of its code — put `Testo\Test\Skip` (from the +`testo/test` plugin, the same package as `#[Test]`) on the test method, the class (skips every test +of the case; inherited from parents and traits, a method-level reason wins), or a free function: ```php use Testo\Test\Skip; #[Test] #[Skip('broken by the pricing rework, see ISSUE-123')] -public function calculatesTotal(): void { ... } // reported as Skipped, body never runs +public function calculatesTotal(): void { /* ... */ } // reported as Skipped, body never runs ``` The test is reported as `Status::Skipped` and counted in the totals; its reason travels in the result's failure message `{testId} is skipped via #[Skip] ==> {reason}` (without ` ==> ...` when the reason is empty). The JUnit, TeamCity and HTML reports show that message; the terminal prints the skipped line without it, and the compact `--json` report only counts the test in -`totals.skipped`. `reason` is optional and the attribute is not repeatable. +`totals.skipped`. + +`reason` is optional and the attribute is not repeatable — but **always pass a reason that points +at an issue** (`#[Skip('flaky on CI, see ISSUE-123')]`); a bare `#[Skip]` is how a parked test rots +unreviewed. Its interceptor is registered by `TestPlugin` (on by default); in a suite configured +without that plugin only a class-level `#[Skip]` still works — through the attribute's own fallback. Which skipping tool to reach for: From 153e46fcea9999c99dd7bce50785a1a559cda5fd Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:16:24 +0500 Subject: [PATCH 23/35] test(test): use the typed Assert facade in the #[Skip] tests Assert::array()->hasCount() replaces the is_array()/count() pair on the origin attribute, and the pipeline-entry check reads as a chain of contains()/notContains() instead of an array_intersect() against []. Applied to the Feature and the Unit test together so the two layers keep the same idiom. Assisted-By: Claude Fable 5.1 --- plugin/test/tests/Feature/SkipFeatureTest.php | 24 +++++++++---------- .../Unit/Internal/SkipInterceptorTest.php | 3 +-- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index 4f1c7f24..699ce208 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -152,8 +152,7 @@ public function parkedResultCarriesOriginAttribute(): void $result = TestRunner::runTest([SkipMethodStub::class, 'parked']); $origin = $result->info->getAttribute(Skip::class); - Assert::true(\is_array($origin)); - Assert::count($origin, 1); + Assert::array($origin)->hasCount(1); Assert::instanceOf($origin[0], Skip::class); } @@ -260,17 +259,16 @@ public function parkedTestsNeverEnterThePerTestPipeline(): void TestRunner::runTest([SkipMethodStub::class, 'parked']); $entered = \array_slice(PipelineEntrySpyPlugin::$entered, $offset); - Assert::contains($entered, SkipMethodStub::class . '::enabled'); - Assert::same(\array_intersect($entered, [ - SkipMethodStub::class . '::parked', - SkipMethodStub::class . '::parkedNoReason', - SkipWithHooksStub::class . '::parked', - SkipWithDataProviderStub::class . '::parked', - SkipWithRetryStub::class . '::parked', - SkipWithRepeatStub::class . '::parked', - SkipInFiberStub::class . '::parked', - 'Tests\Test\Stub\Skip\parked_function', - ]), []); + Assert::array($entered) + ->contains(SkipMethodStub::class . '::enabled') + ->notContains(SkipMethodStub::class . '::parked') + ->notContains(SkipMethodStub::class . '::parkedNoReason') + ->notContains(SkipWithHooksStub::class . '::parked') + ->notContains(SkipWithDataProviderStub::class . '::parked') + ->notContains(SkipWithRetryStub::class . '::parked') + ->notContains(SkipWithRepeatStub::class . '::parked') + ->notContains(SkipInFiberStub::class . '::parked') + ->notContains('Tests\Test\Stub\Skip\parked_function'); } /** diff --git a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php index d76d354a..b02a9a67 100644 --- a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php +++ b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php @@ -114,8 +114,7 @@ public function stampsOriginAttributeOnSyntheticInfo(): void $result = $interceptor->runTestCase($info, self::coreNext()); $origin = self::findResult($result, 'parked')->info->getAttribute(Skip::class); - Assert::true(\is_array($origin)); - Assert::count($origin, 1); + Assert::array($origin)->hasCount(1); Assert::instanceOf($origin[0], Skip::class); Assert::same($origin[0]->reason, 'broken by the pricing rework, see ISSUE-123'); Assert::null(self::findResult($result, 'enabled')->info->getAttribute(Skip::class)); From 9324f84a443a7cd6e42d35f292eee3be68d6011e Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:29:28 +0500 Subject: [PATCH 24/35] test(test): pin that an overriding method inherits the #[Skip] of its prototype docs(test): document method-level #[Skip] inheritance SkipInterceptor reads the attribute through Reflection::fetchFunctionAttributes with prototypes included, the way #[Group] is read, so a child method that overrides a #[Skip]-marked one is skipped with the parent's reason. A parent/ child stub pair and a feature test pin it; the Skip docblock and the testo-write-tests skill say so. Assisted-By: Claude Fable 5.1 --- plugin/test/src/Skip.php | 3 +++ plugin/test/tests/Feature/SkipFeatureTest.php | 14 +++++++++++++ .../Skip/SkipOverriddenMethodParentStub.php | 20 ++++++++++++++++++ .../Stub/Skip/SkipOverridingMethodStub.php | 21 +++++++++++++++++++ skills/testo-write-tests/SKILL.md | 5 +++-- 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php create mode 100644 plugin/test/tests/Stub/Skip/SkipOverridingMethodStub.php diff --git a/plugin/test/src/Skip.php b/plugin/test/src/Skip.php index 4602928d..37f4e112 100644 --- a/plugin/test/src/Skip.php +++ b/plugin/test/src/Skip.php @@ -35,6 +35,9 @@ * classes and traits (like `#[Group]`); a method-level `#[Skip]` wins over the class-level * one, reason included. * + * A method-level `#[Skip]` is inherited as well: an overriding method without the attribute is + * skipped with the reason of the method it overrides. + * * The failure message reads `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` * when a reason is given. The JUnit, TeamCity and HTML reporters show that message; the * terminal prints the skipped line without it, and the compact `--json` report counts the diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index 699ce208..5988722d 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -23,6 +23,7 @@ use Tests\Test\Stub\Skip\SkipInFiberStub; use Tests\Test\Stub\Skip\SkipMethodStub; use Tests\Test\Stub\Skip\SkipNonStaticHookStub; +use Tests\Test\Stub\Skip\SkipOverridingMethodStub; use Tests\Test\Stub\Skip\SkipTraitStub; use Tests\Test\Stub\Skip\SkipWithDataProviderStub; use Tests\Test\Stub\Skip\SkipWithHooksStub; @@ -215,6 +216,18 @@ public function classLevelSkipIsInheritedFromTrait(): void Assert::true(\str_ends_with((string) $result->failure?->getMessage(), ' ==> inherited from the trait')); } + /** + * A method-level `#[Skip]` follows the prototype chain like `#[Group]` does: an overriding + * method without the attribute is still skipped, with the parent's reason. + */ + public function methodLevelSkipIsInheritedByOverridingMethod(): void + { + $result = TestRunner::runTest([SkipOverridingMethodStub::class, 'parked']); + + Assert::same($result->status, Status::Skipped); + Assert::true(\str_ends_with((string) $result->failure?->getMessage(), ' ==> inherited from the overridden method')); + } + /** * A data-driven parked test yields a single Skipped node: the provider is never called * (not once across all catalog runs of this class), no `MultipleResult` aggregate is @@ -282,6 +295,7 @@ public function fiberBatchRunnerSurvivesTheWrap(): void $offset = \count(SkipInFiberStub::$log); $parked = TestRunner::runTest([SkipInFiberStub::class, 'parked']); + ->notContains(SkipOverridingMethodStub::class . '::parked') Assert::same($parked->status, Status::Skipped); Assert::same( diff --git a/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php b/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php new file mode 100644 index 00000000..fadd1a34 --- /dev/null +++ b/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php @@ -0,0 +1,20 @@ + Date: Tue, 8 Sep 2026 10:29:29 +0500 Subject: [PATCH 25/35] test(test): add positive controls for the #[Retry]/#[Repeat] skip checks The negative assertions on the skipped tests would also hold if retry or repeat never engaged in the testing suite at all. Each stub gains an enabled neighbor with the same attribute: the retry one fails its first attempt and passes the second (two attempts per run), the repeat one counts its three runs. Assisted-By: Claude Fable 5.1 --- plugin/test/tests/Feature/SkipFeatureTest.php | 16 +++++++++++++++- .../test/tests/Stub/Skip/SkipWithRepeatStub.php | 11 ++++++++++- .../test/tests/Stub/Skip/SkipWithRetryStub.php | 13 ++++++++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index 5988722d..e57d1b2e 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -242,22 +242,36 @@ public function dataProviderIsNotCalledForParkedTest(): void Assert::same(SkipWithDataProviderStub::$providerCalls, 0); } + /** + * The positive control on the enabled neighbor proves that `#[Retry]` does engage in this + * run — its first attempt fails and the second passes — so the zero on the parked test is + * the skip at work, not a retry plugin that never ran. + */ public function retryDoesNotEngageForParkedTest(): void { $attempts = SkipWithRetryStub::$attempts; + $enabledAttempts = SkipWithRetryStub::$enabledAttempts; $result = TestRunner::runTest([SkipWithRetryStub::class, 'parked']); Assert::same($result->status, Status::Skipped); Assert::same(SkipWithRetryStub::$attempts - $attempts, 0); + Assert::same(SkipWithRetryStub::$enabledAttempts - $enabledAttempts, 2); } + /** + * Same shape for `#[Repeat]`: the enabled neighbor runs all three of its repetitions, the + * parked test not even once. + */ public function repeatDoesNotEngageForParkedTest(): void { + $enabledRuns = SkipWithRepeatStub::$enabledRuns; + $result = TestRunner::runTest([SkipWithRepeatStub::class, 'parked']); Assert::same($result->status, Status::Skipped); Assert::false(SkipWithRepeatStub::$bodyRan); + Assert::same(SkipWithRepeatStub::$enabledRuns - $enabledRuns, 3); } /** @@ -281,6 +295,7 @@ public function parkedTestsNeverEnterThePerTestPipeline(): void ->notContains(SkipWithRetryStub::class . '::parked') ->notContains(SkipWithRepeatStub::class . '::parked') ->notContains(SkipInFiberStub::class . '::parked') + ->notContains(SkipOverridingMethodStub::class . '::parked') ->notContains('Tests\Test\Stub\Skip\parked_function'); } @@ -295,7 +310,6 @@ public function fiberBatchRunnerSurvivesTheWrap(): void $offset = \count(SkipInFiberStub::$log); $parked = TestRunner::runTest([SkipInFiberStub::class, 'parked']); - ->notContains(SkipOverridingMethodStub::class . '::parked') Assert::same($parked->status, Status::Skipped); Assert::same( diff --git a/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php b/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php index 51be04f2..a022efcf 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php @@ -12,12 +12,14 @@ * `#[Skip]` on a test that also carries `#[Repeat]`: the repeat is resolved in the per-test * pipeline, which a skipped test never enters, so the body must not run at all. The latch is * never reset — {@see \Tests\Test\Feature\SkipFeatureTest::repeatDoesNotEngageForParkedTest()} - * asserts it absolutely, not as a delta. + * asserts it absolutely, not as a delta. The enabled neighbor carries the same attribute and + * counts its runs: three per run prove the repeat is live in this suite. */ #[Test] final class SkipWithRepeatStub { public static bool $bodyRan = false; + public static int $enabledRuns = 0; #[Skip('parked, repeat must not engage')] #[Repeat(times: 3)] @@ -26,4 +28,11 @@ public function parked(): void self::$bodyRan = true; throw new \LogicException('Must never run: the test is parked.'); } + + #[Repeat(times: 3)] + public function enabled(): void + { + # Control neighbor: repeated three times per run. + ++self::$enabledRuns; + } } diff --git a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php b/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php index c59c9023..384440e9 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php @@ -11,12 +11,15 @@ /** * `#[Skip]` composed with `#[Retry]`: the retry policy is resolved in the per-test pipeline, which * a skipped test never enters, so the body must not run at all. The counter tells a single stray - * run apart from a full retry cycle. + * run apart from a full retry cycle. The enabled neighbor carries the same attribute with the flaky + * mark off and fails its first attempt on purpose: two attempts per run prove the policy is live + * in this suite. */ #[Test] final class SkipWithRetryStub { public static int $attempts = 0; + public static int $enabledAttempts = 0; #[Skip('parked, retry must not engage')] #[Retry(maxAttempts: 3)] @@ -25,4 +28,12 @@ public function parked(): void ++self::$attempts; throw new \LogicException('Must never run: the test is parked.'); } + + #[Retry(maxAttempts: 3, markFlaky: false)] + public function enabled(): void + { + # Control neighbor: the counter is even at the start of every run, so the first attempt + # makes it odd and fails, the second makes it even and passes — two attempts per run. + ++self::$enabledAttempts % 2 === 0 or throw new \RuntimeException('First attempt fails by design.'); + } } From 9d504d6287c612441f29d7402a1ac1c3aedf2ca8 Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:39:07 +0500 Subject: [PATCH 26/35] refactor(test): say "skipped" instead of "parked" across the #[Skip] code and tests The attribute reports a test as Skipped, so the code, the stubs, the tests and the skills now use that word too: findSkipped(), the skipped()/skippedNoReason() stub methods, the skippedFunction/ enabledFunction pair, the FullySkipped and OnlySkipped stub directories, StandaloneSkippedTest, MixedStub, the reason strings and every docblock. Pure rename: no behaviour, structure or assertion changed. "parked" stays where it means a suspended coroutine (testo/fiber, codecov, revolt). Assisted-By: Claude Fable 5.1 --- ...st.php => FullySkippedCaseFeatureTest.php} | 46 ++++---- .../FullySkippedClassStub.php} | 14 +-- .../fully_skipped_functions.php} | 38 +++---- plugin/test/src/Internal/SkipInterceptor.php | 24 ++-- plugin/test/src/Skip.php | 6 +- .../Feature/SkipFallbackStandaloneTest.php | 8 +- plugin/test/tests/Feature/SkipFeatureTest.php | 104 +++++++++--------- plugin/test/tests/Feature/SkipSummaryTest.php | 8 +- plugin/test/tests/Stub/Skip/SkipChildStub.php | 4 +- .../Stub/Skip/SkipClassAndMethodStub.php | 6 +- .../tests/Stub/Skip/SkipClassLevelStub.php | 10 +- .../Stub/Skip/SkipConstructorSpyStub.php | 12 +- .../test/tests/Stub/Skip/SkipInFiberStub.php | 6 +- .../test/tests/Stub/Skip/SkipMethodStub.php | 10 +- .../tests/Stub/Skip/SkipNonStaticHookStub.php | 8 +- .../Skip/SkipOverriddenMethodParentStub.php | 4 +- .../Stub/Skip/SkipOverridingMethodStub.php | 4 +- plugin/test/tests/Stub/Skip/SkipTraitStub.php | 4 +- .../Stub/Skip/SkipWithDataProviderStub.php | 6 +- .../tests/Stub/Skip/SkipWithHooksStub.php | 6 +- .../tests/Stub/Skip/SkipWithRepeatStub.php | 8 +- .../tests/Stub/Skip/SkipWithRetryStub.php | 6 +- .../test/tests/Stub/Skip/skip_functions.php | 8 +- .../SkipStandalone/StandaloneParkedTest.php | 27 ----- .../SkipStandalone/StandaloneSkippedTest.php | 27 +++++ .../{SummaryMixedStub.php => MixedStub.php} | 16 +-- .../SkipSummary/OnlyParked/OnlyParkedStub.php | 26 ----- .../OnlySkipped/OnlySkippedStub.php | 26 +++++ .../Unit/Fixture/SkipClassLevelFixture.php | 4 +- .../Unit/Fixture/SkipMixedMethodsFixture.php | 4 +- .../Unit/Internal/SkipInterceptorTest.php | 76 ++++++------- skills/testo-flaky-tests/SKILL.md | 4 +- skills/testo-write-tests/SKILL.md | 10 +- 33 files changed, 285 insertions(+), 285 deletions(-) rename plugin/lifecycle/tests/Feature/{FullyParkedCaseFeatureTest.php => FullySkippedCaseFeatureTest.php} (58%) rename plugin/lifecycle/tests/Stub/{FullyParked/FullyParkedClassStub.php => FullySkipped/FullySkippedClassStub.php} (65%) rename plugin/lifecycle/tests/Stub/{FullyParked/fully_parked_functions.php => FullySkipped/fully_skipped_functions.php} (52%) delete mode 100644 plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php create mode 100644 plugin/test/tests/Stub/SkipStandalone/StandaloneSkippedTest.php rename plugin/test/tests/Stub/SkipSummary/Mixed/{SummaryMixedStub.php => MixedStub.php} (64%) delete mode 100644 plugin/test/tests/Stub/SkipSummary/OnlyParked/OnlyParkedStub.php create mode 100644 plugin/test/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php diff --git a/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php b/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php similarity index 58% rename from plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php rename to plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php index f6990c59..964b919a 100644 --- a/plugin/lifecycle/tests/Feature/FullyParkedCaseFeatureTest.php +++ b/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php @@ -11,15 +11,15 @@ use Testo\Test; use Testo\Testing\Attribute\TestingSuite; use Testo\Testing\Helper\TestRunner; -use Tests\Lifecycle\Stub\FullyParked\FullyParkedClassStub; -use Tests\Lifecycle\Stub\FullyParked\FullyParkedFunctionState; +use Tests\Lifecycle\Stub\FullySkipped\FullySkippedClassStub; +use Tests\Lifecycle\Stub\FullySkipped\FullySkippedFunctionState; /** * End-to-end regression test for {@see LifecycleInterceptor}: the `#[BeforeClass]`/`#[AfterClass]` hooks * of a case still run when an outer case interceptor — here `#[Skip]` from `testo/test` — leaves * the case without a single active test. * - * The `#[Skip]` case interceptor deactivates the parked tests before the {@see LifecycleInterceptor} + * The `#[Skip]` case interceptor deactivates the skipped tests before the {@see LifecycleInterceptor} * collects the case's hooks, so hook discovery must not depend on the surviving tests. It does not: * the hooks are the case's non-tests. Prefilling defines every member as a non-test, * {@see LifecycleInterceptor} demotes back the ones a finder took for tests (a class-level `#[Test]` @@ -33,50 +33,50 @@ */ #[Test] #[Covers(LifecycleInterceptor::class)] -#[TestingSuite(path: __DIR__ . '/../Stub/FullyParked')] -final class FullyParkedCaseFeatureTest +#[TestingSuite(path: __DIR__ . '/../Stub/FullySkipped')] +final class FullySkippedCaseFeatureTest { public function __construct() { # Functions are not autoloadable: load the stub so TestRunner::runTest() can resolve the # function names below. The pipeline re-includes the same file (include_once) when it runs. - require_once __DIR__ . '/../Stub/FullyParked/fully_parked_functions.php'; + require_once __DIR__ . '/../Stub/FullySkipped/fully_skipped_functions.php'; } /** * The function-based case shape: class-level hooks fire exactly once per catalog run even * though no test of the case stays active; per-test hooks have nothing to wrap and stay silent. */ - public function classHooksRunForFullyParkedFunctionCase(): void + public function classHooksRunForFullySkippedFunctionCase(): void { - $beforeClass = FullyParkedFunctionState::$beforeClassCalls; - $afterClass = FullyParkedFunctionState::$afterClassCalls; - $beforeTest = FullyParkedFunctionState::$beforeTestCalls; - $afterTest = FullyParkedFunctionState::$afterTestCalls; + $beforeClass = FullySkippedFunctionState::$beforeClassCalls; + $afterClass = FullySkippedFunctionState::$afterClassCalls; + $beforeTest = FullySkippedFunctionState::$beforeTestCalls; + $afterTest = FullySkippedFunctionState::$afterTestCalls; - $result = TestRunner::runTest('Tests\Lifecycle\Stub\FullyParked\parkedFnOne'); + $result = TestRunner::runTest('Tests\Lifecycle\Stub\FullySkipped\skippedFnOne'); Assert::same($result->status, Status::Skipped); - Assert::same(FullyParkedFunctionState::$beforeClassCalls - $beforeClass, 1); - Assert::same(FullyParkedFunctionState::$afterClassCalls - $afterClass, 1); + Assert::same(FullySkippedFunctionState::$beforeClassCalls - $beforeClass, 1); + Assert::same(FullySkippedFunctionState::$afterClassCalls - $afterClass, 1); # No test of the case ran, so the per-test hooks never fired. - Assert::same(FullyParkedFunctionState::$beforeTestCalls - $beforeTest, 0); - Assert::same(FullyParkedFunctionState::$afterTestCalls - $afterTest, 0); + Assert::same(FullySkippedFunctionState::$beforeTestCalls - $beforeTest, 0); + Assert::same(FullySkippedFunctionState::$afterTestCalls - $afterTest, 0); } /** * The class-based analog: hooks are the non-tests prefilled from the case's class reflection - * and must keep firing for a fully parked class exactly as before. + * and must keep firing for a fully skipped class exactly as before. */ - public function classHooksRunForFullyParkedClassCase(): void + public function classHooksRunForFullySkippedClassCase(): void { - $beforeClass = FullyParkedClassStub::$beforeClassCalls; - $afterClass = FullyParkedClassStub::$afterClassCalls; + $beforeClass = FullySkippedClassStub::$beforeClassCalls; + $afterClass = FullySkippedClassStub::$afterClassCalls; - $result = TestRunner::runTest([FullyParkedClassStub::class, 'parked']); + $result = TestRunner::runTest([FullySkippedClassStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); - Assert::same(FullyParkedClassStub::$beforeClassCalls - $beforeClass, 1); - Assert::same(FullyParkedClassStub::$afterClassCalls - $afterClass, 1); + Assert::same(FullySkippedClassStub::$beforeClassCalls - $beforeClass, 1); + Assert::same(FullySkippedClassStub::$afterClassCalls - $afterClass, 1); } } diff --git a/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php b/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php similarity index 65% rename from plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php rename to plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php index 05ef4717..cb1c16a7 100644 --- a/plugin/lifecycle/tests/Stub/FullyParked/FullyParkedClassStub.php +++ b/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Lifecycle\Stub\FullyParked; +namespace Tests\Lifecycle\Stub\FullySkipped; use Testo\Lifecycle\AfterClass; use Testo\Lifecycle\BeforeClass; @@ -11,14 +11,14 @@ /** * Class-based analog of the fully skipped function case in the same directory - * ({@see FullyParkedFunctionState}): the hooks are the case's non-tests, so they never + * ({@see FullySkippedFunctionState}): the hooks are the case's non-tests, so they never * depended on the surviving tests — pinned here so both flavors stay in lockstep. * * Static hook counters accumulate across catalog runs — feature tests assert deltas. The hooks - * are static so the fully parked class is never instantiated. + * are static so the fully skipped class is never instantiated. */ #[Test] -final class FullyParkedClassStub +final class FullySkippedClassStub { public static int $beforeClassCalls = 0; public static int $afterClassCalls = 0; @@ -35,9 +35,9 @@ public static function shutdownCase(): void ++self::$afterClassCalls; } - #[Skip('the whole class case is parked')] - public function parked(): void + #[Skip('the whole class case is skipped')] + public function skipped(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } } diff --git a/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php b/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php similarity index 52% rename from plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php rename to plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php index 16a7bbfd..c9d97cef 100644 --- a/plugin/lifecycle/tests/Stub/FullyParked/fully_parked_functions.php +++ b/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Lifecycle\Stub\FullyParked; +namespace Tests\Lifecycle\Stub\FullySkipped; use Testo\Lifecycle\AfterClass; use Testo\Lifecycle\AfterTest; @@ -12,9 +12,9 @@ use Testo\Test\Skip; /** - * A fully parked function-based case: every `#[Test]` function is under `#[Skip]`. Mirrors - * {@see FullyParkedClassStub} for the function-based shape of the same scenario. Its two tests - * spell the attribute both ways — `parkedFnOne` with a reason, `parkedFnTwo` without — so neither + * A fully skipped function-based case: every `#[Test]` function is under `#[Skip]`. Mirrors + * {@see FullySkippedClassStub} for the function-based shape of the same scenario. Its two tests + * spell the attribute both ways — `skippedFnOne` with a reason, `skippedFnTwo` without — so neither * form leaves the case with an active test. * * The `#[Skip]` case interceptor deactivates the skipped tests — they leave the case's active @@ -23,51 +23,51 @@ * for the case (the `#[Skip]` contract), while the per-test hooks have nothing to wrap. * * Static hook counters accumulate across catalog runs — feature tests assert deltas. - * State is shared through {@see FullyParkedFunctionState} because functions have no `$this`. + * State is shared through {@see FullySkippedFunctionState} because functions have no `$this`. */ #[BeforeClass] -function parkedCaseSetUpClass(): void +function skippedCaseSetUpClass(): void { - ++FullyParkedFunctionState::$beforeClassCalls; + ++FullySkippedFunctionState::$beforeClassCalls; } #[AfterClass] -function parkedCaseTearDownClass(): void +function skippedCaseTearDownClass(): void { - ++FullyParkedFunctionState::$afterClassCalls; + ++FullySkippedFunctionState::$afterClassCalls; } #[BeforeTest] -function parkedCaseSetUp(): void +function skippedCaseSetUp(): void { - ++FullyParkedFunctionState::$beforeTestCalls; + ++FullySkippedFunctionState::$beforeTestCalls; } #[AfterTest] -function parkedCaseTearDown(): void +function skippedCaseTearDown(): void { - ++FullyParkedFunctionState::$afterTestCalls; + ++FullySkippedFunctionState::$afterTestCalls; } #[Test] -#[Skip('the whole functional case is parked')] -function parkedFnOne(): void +#[Skip('the whole functional case is skipped')] +function skippedFnOne(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } #[Test] #[Skip] -function parkedFnTwo(): void +function skippedFnTwo(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } /** * Call counters for the lifecycle functions above. Not autoloadable — the feature test * `require_once`s this file before touching the counters. */ -final class FullyParkedFunctionState +final class FullySkippedFunctionState { public static int $beforeClassCalls = 0; public static int $afterClassCalls = 0; diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index 989294cf..a720639b 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -85,7 +85,7 @@ /** * Takes no {@see Skip} parameter on purpose: the container also builds the instance for * the {@see TestPlugin} registration, where no attribute is at hand. The attributes are - * looked up per case in {@see self::findParked()} instead. + * looked up per case in {@see self::findSkipped()} instead. */ public function __construct( private EventDispatcherInterface $eventDispatcher, @@ -94,16 +94,16 @@ public function __construct( #[\Override] public function runTestCase(CaseInfo $info, callable $next): CaseResult { - $parked = $this->findParked($info); + $skipped = $this->findSkipped($info); - if ($parked === []) { + if ($skipped === []) { return $next($info); } # Deactivated, not discarded — the same way filtering narrows a case # (FilterInterceptor::locateTestCases()). The core runs only the active tests # (CaseRunner::run()), so the synthetic results below are their only delivery. - foreach ($parked as [$definition, $_]) { + foreach ($skipped as [$definition, $_]) { $definition->active = false; } @@ -111,12 +111,12 @@ public function runTestCase(CaseInfo $info, callable $next): CaseResult # results are appended by the batch runner inside the case window. $inner = $info->batchRunner; return $next($info->withBatchRunner( - function (array $handlers) use ($inner, $info, $parked): array { + function (array $handlers) use ($inner, $info, $skipped): array { $results = $inner === null ? \array_map(static fn(callable $handler): TestResult => $handler(), $handlers) : $inner($handlers); - foreach ($parked as $name => [$definition, $attribute]) { + foreach ($skipped as $name => [$definition, $attribute]) { $results[] = $this->reportSkipped($info, $name, $definition, $attribute); } @@ -140,12 +140,12 @@ private static function reason(TestInfo $info, Skip $attribute): string } /** - * Collects the parked tests of the case: a method/function-level `#[Skip]` wins over the + * Collects the skipped tests of the case: a method/function-level `#[Skip]` wins over the * class-level one; the class-level attribute is inherited from parents and traits. * * @return array */ - private function findParked(CaseInfo $info): array + private function findSkipped(CaseInfo $info): array { $classAttribute = null; $reflection = $info->definition->reflection; @@ -154,7 +154,7 @@ private function findParked(CaseInfo $info): array $attributes === [] or $classAttribute = $attributes[0]->newInstance(); } - $parked = []; + $skipped = []; # Only the case's active tests: a non-test member (a helper, a lifecycle hook) carries no # skip semantics, and a test already deactivated by a filter is not part of this run — # reporting it as Skipped would resurrect what --filter/--group threw away. @@ -166,14 +166,14 @@ private function findParked(CaseInfo $info): array ); $attribute = $attributes === [] ? $classAttribute : $attributes[0]->newInstance(); - $attribute === null or $parked[$name] = [$definition, $attribute]; + $attribute === null or $skipped[$name] = [$definition, $attribute]; } - return $parked; + return $skipped; } /** - * Builds the synthetic result for a parked test and dispatches its pipeline events, so + * Builds the synthetic result for a skipped test and dispatches its pipeline events, so * reporters that render test lines from those events see the test as any other. */ private function reportSkipped( diff --git a/plugin/test/src/Skip.php b/plugin/test/src/Skip.php index 37f4e112..16410010 100644 --- a/plugin/test/src/Skip.php +++ b/plugin/test/src/Skip.php @@ -14,7 +14,7 @@ * Marks a test as skipped without deleting or hiding it. * * The test is not executed, but stays in the results as {@see Status::Skipped}: it is counted - * in the totals and carries its reason in the result's failure message, so parked tests are + * in the totals and carries its reason in the result's failure message, so skipped tests are * reviewable instead of silently rotting. Contrast with a group filter (`#[Group('x')]` + * `--group=!x`), which drops the test from the results entirely. * @@ -50,7 +50,7 @@ * A data-driven test yields a single Skipped entry (providers are not called). * - `#[BeforeClass]`/`#[AfterClass]` hooks still run — also when every test of the case * is skipped. - * - A skipped test never requires an instance of the case class. A fully parked class is + * - A skipped test never requires an instance of the case class. A fully skipped class is * built only when a non-static class-level hook forces it; next to enabled tests the * class is constructed for them as usual. * - A run consisting only of `#[Skip]`-marked tests is successful (exit code 0): @@ -72,7 +72,7 @@ final readonly class Skip implements Interceptable { /** - * @param string $reason Why the test is parked. Optional, but a reference to an issue + * @param string $reason Why the test is skipped. Optional, but a reference to an issue * (`'flaky on CI, see ISSUE-123'`) keeps the skip reviewable. */ public function __construct( diff --git a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php b/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php index d19ab09c..ef734fd9 100644 --- a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php +++ b/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php @@ -19,11 +19,11 @@ use Testo\Test\Internal\SkipInterceptor; use Testo\Test\Skip; use Testo\Test\TestPlugin; -use Tests\Test\Stub\SkipStandalone\StandaloneParkedTest; +use Tests\Test\Stub\SkipStandalone\StandaloneSkippedTest; /** * The standalone contract of `#[Skip]`: with `TestPlugin` not registered, the attribute's - * {@see \Testo\Pipeline\Attribute\FallbackInterceptor} declaration alone parks a class-level + * {@see \Testo\Pipeline\Attribute\FallbackInterceptor} declaration alone skips a class-level * catalog (tests are discovered by naming convention, so no `#[Test]` attribute is involved). */ #[Test] @@ -69,8 +69,8 @@ public function classLevelSkipFallsBackWithoutTestPlugin(): void # the `is skipped via #[Skip]` marker and the class-level reason. \sort($messages); Assert::same($messages, [ - StandaloneParkedTest::class . '::testFirstParked is skipped via #[Skip] ==> standalone catalog is parked', - StandaloneParkedTest::class . '::testSecondParked is skipped via #[Skip] ==> standalone catalog is parked', + StandaloneSkippedTest::class . '::testFirstSkipped is skipped via #[Skip] ==> standalone catalog is skipped', + StandaloneSkippedTest::class . '::testSecondSkipped is skipped via #[Skip] ==> standalone catalog is skipped', ]); } } diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index e57d1b2e..4e5d0bab 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -58,43 +58,43 @@ public function __construct() public function methodLevelSkipReportsSkippedWithComposedReason(): void { - $result = TestRunner::runTest([SkipMethodStub::class, 'parked']); + $result = TestRunner::runTest([SkipMethodStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::instanceOf($result->failure, SkipTest::class); Assert::same( $result->failure?->getMessage(), - SkipMethodStub::class . '::parked is skipped via #[Skip] ==> broken by the pricing rework, see ISSUE-123', + SkipMethodStub::class . '::skipped is skipped via #[Skip] ==> broken by the pricing rework, see ISSUE-123', ); } public function emptyReasonFallsBackToGeneratedMessage(): void { - $result = TestRunner::runTest([SkipMethodStub::class, 'parkedNoReason']); + $result = TestRunner::runTest([SkipMethodStub::class, 'skippedNoReason']); Assert::same($result->status, Status::Skipped); Assert::same( $result->failure?->getMessage(), - SkipMethodStub::class . '::parkedNoReason is skipped via #[Skip]', + SkipMethodStub::class . '::skippedNoReason is skipped via #[Skip]', ); } - public function controlNeighborNextToParkedTestsStillRuns(): void + public function controlNeighborNextToSkippedTestsStillRuns(): void { $result = TestRunner::runTest([SkipMethodStub::class, 'enabled']); Assert::same($result->status, Status::Passed); } - public function classLevelSkipParksEveryTestWithClassReason(): void + public function classLevelSkipSkipsEveryTestWithClassReason(): void { - $first = TestRunner::runTest([SkipClassLevelStub::class, 'firstParked']); - $second = TestRunner::runTest([SkipClassLevelStub::class, 'secondParked']); + $first = TestRunner::runTest([SkipClassLevelStub::class, 'firstSkipped']); + $second = TestRunner::runTest([SkipClassLevelStub::class, 'secondSkipped']); Assert::same($first->status, Status::Skipped); Assert::same($second->status, Status::Skipped); - Assert::true(\str_ends_with((string) $first->failure?->getMessage(), ' ==> the whole case is parked')); - Assert::true(\str_ends_with((string) $second->failure?->getMessage(), ' ==> the whole case is parked')); + Assert::true(\str_ends_with((string) $first->failure?->getMessage(), ' ==> the whole case is skipped')); + Assert::true(\str_ends_with((string) $second->failure?->getMessage(), ' ==> the whole case is skipped')); } public function methodReasonWinsOverClassReason(): void @@ -123,34 +123,34 @@ public function emptyMethodReasonStillWinsOverClassReason(): void public function functionalTestUsesFunctionFqnInMessage(): void { - $result = TestRunner::runTest('Tests\Test\Stub\Skip\parked_function'); + $result = TestRunner::runTest('Tests\Test\Stub\Skip\skippedFunction'); Assert::same($result->status, Status::Skipped); Assert::same( $result->failure?->getMessage(), - 'Tests\Test\Stub\Skip\parked_function is skipped via #[Skip] ==> functional test is parked', + 'Tests\Test\Stub\Skip\skippedFunction is skipped via #[Skip] ==> functional test is skipped', ); } /** * The function-based analog of the control neighbor: an enabled function of a partially - * parked file still runs through the batch runner the interceptor installs on the case, and + * skipped file still runs through the batch runner the interceptor installs on the case, and * passes. */ - public function controlNeighborFunctionNextToParkedFunctionStillRuns(): void + public function controlNeighborFunctionNextToSkippedFunctionStillRuns(): void { - $result = TestRunner::runTest('Tests\Test\Stub\Skip\enabled_function'); + $result = TestRunner::runTest('Tests\Test\Stub\Skip\enabledFunction'); Assert::same($result->status, Status::Passed); } /** - * The origin contract for downstream consumers: a `#[Skip]`-parked result carries the + * The origin contract for downstream consumers: a result skipped by `#[Skip]` carries the * attribute instances in `$result->info`, unlike a runtime `throw SkipTest` skip. */ - public function parkedResultCarriesOriginAttribute(): void + public function skippedResultCarriesOriginAttribute(): void { - $result = TestRunner::runTest([SkipMethodStub::class, 'parked']); + $result = TestRunner::runTest([SkipMethodStub::class, 'skipped']); $origin = $result->info->getAttribute(Skip::class); Assert::array($origin)->hasCount(1); @@ -158,7 +158,7 @@ public function parkedResultCarriesOriginAttribute(): void } /** - * The parked test is filtered out before the case runs: class-level hooks fire as usual + * The skipped test is filtered out before the case runs: class-level hooks fire as usual * (once per catalog run), per-test hooks fire only for the enabled control test. */ public function classHooksRunButTestHooksDoNot(): void @@ -168,7 +168,7 @@ public function classHooksRunButTestHooksDoNot(): void $beforeTest = SkipWithHooksStub::$beforeTestCalls; $afterTest = SkipWithHooksStub::$afterTestCalls; - $result = TestRunner::runTest([SkipWithHooksStub::class, 'parked']); + $result = TestRunner::runTest([SkipWithHooksStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::same(SkipWithHooksStub::$beforeClassCalls - $beforeClass, 1); @@ -178,9 +178,9 @@ public function classHooksRunButTestHooksDoNot(): void Assert::same(SkipWithHooksStub::$afterTestCalls - $afterTest, 1); } - public function fullyParkedCaseWithoutHooksIsNeverInstantiated(): void + public function fullySkippedCaseWithoutHooksIsNeverInstantiated(): void { - $result = TestRunner::runTest([SkipConstructorSpyStub::class, 'firstParked']); + $result = TestRunner::runTest([SkipConstructorSpyStub::class, 'firstSkipped']); Assert::same($result->status, Status::Skipped); Assert::false(SkipConstructorSpyStub::$constructed); @@ -188,13 +188,13 @@ public function fullyParkedCaseWithoutHooksIsNeverInstantiated(): void /** * Documented caveat: a non-static class-level hook builds the class even when every - * test is parked — pinned so a future change is conscious, not accidental. + * test is skipped — pinned so a future change is conscious, not accidental. */ public function nonStaticClassHookStillBuildsTheClass(): void { $constructions = SkipNonStaticHookStub::$constructions; - $result = TestRunner::runTest([SkipNonStaticHookStub::class, 'parked']); + $result = TestRunner::runTest([SkipNonStaticHookStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::same(SkipNonStaticHookStub::$constructions - $constructions, 1); @@ -202,7 +202,7 @@ public function nonStaticClassHookStillBuildsTheClass(): void public function classLevelSkipIsInheritedFromParent(): void { - $result = TestRunner::runTest([SkipChildStub::class, 'parked']); + $result = TestRunner::runTest([SkipChildStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::true(\str_ends_with((string) $result->failure?->getMessage(), ' ==> inherited from the parent class')); @@ -210,7 +210,7 @@ public function classLevelSkipIsInheritedFromParent(): void public function classLevelSkipIsInheritedFromTrait(): void { - $result = TestRunner::runTest([SkipTraitStub::class, 'parked']); + $result = TestRunner::runTest([SkipTraitStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::true(\str_ends_with((string) $result->failure?->getMessage(), ' ==> inherited from the trait')); @@ -222,20 +222,20 @@ public function classLevelSkipIsInheritedFromTrait(): void */ public function methodLevelSkipIsInheritedByOverridingMethod(): void { - $result = TestRunner::runTest([SkipOverridingMethodStub::class, 'parked']); + $result = TestRunner::runTest([SkipOverridingMethodStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::true(\str_ends_with((string) $result->failure?->getMessage(), ' ==> inherited from the overridden method')); } /** - * A data-driven parked test yields a single Skipped node: the provider is never called + * A data-driven skipped test yields a single Skipped node: the provider is never called * (not once across all catalog runs of this class), no `MultipleResult` aggregate is * attached. */ - public function dataProviderIsNotCalledForParkedTest(): void + public function dataProviderIsNotCalledForSkippedTest(): void { - $result = TestRunner::runTest([SkipWithDataProviderStub::class, 'parked']); + $result = TestRunner::runTest([SkipWithDataProviderStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::null($result->getAttribute(MultipleResult::class)); @@ -244,15 +244,15 @@ public function dataProviderIsNotCalledForParkedTest(): void /** * The positive control on the enabled neighbor proves that `#[Retry]` does engage in this - * run — its first attempt fails and the second passes — so the zero on the parked test is + * run — its first attempt fails and the second passes — so the zero on the skipped test is * the skip at work, not a retry plugin that never ran. */ - public function retryDoesNotEngageForParkedTest(): void + public function retryDoesNotEngageForSkippedTest(): void { $attempts = SkipWithRetryStub::$attempts; $enabledAttempts = SkipWithRetryStub::$enabledAttempts; - $result = TestRunner::runTest([SkipWithRetryStub::class, 'parked']); + $result = TestRunner::runTest([SkipWithRetryStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::same(SkipWithRetryStub::$attempts - $attempts, 0); @@ -261,13 +261,13 @@ public function retryDoesNotEngageForParkedTest(): void /** * Same shape for `#[Repeat]`: the enabled neighbor runs all three of its repetitions, the - * parked test not even once. + * skipped test not even once. */ - public function repeatDoesNotEngageForParkedTest(): void + public function repeatDoesNotEngageForSkippedTest(): void { $enabledRuns = SkipWithRepeatStub::$enabledRuns; - $result = TestRunner::runTest([SkipWithRepeatStub::class, 'parked']); + $result = TestRunner::runTest([SkipWithRepeatStub::class, 'skipped']); Assert::same($result->status, Status::Skipped); Assert::false(SkipWithRepeatStub::$bodyRan); @@ -275,43 +275,43 @@ public function repeatDoesNotEngageForParkedTest(): void } /** - * The common ground of the hook/provider/retry/repeat checks above: a parked test never + * The common ground of the hook/provider/retry/repeat checks above: a skipped test never * enters the per-test pipeline at all. A spy interceptor on that pipeline sees the - * enabled neighbors of the catalog and none of the parked tests. + * enabled neighbors of the catalog and none of the skipped tests. */ - public function parkedTestsNeverEnterThePerTestPipeline(): void + public function skippedTestsNeverEnterThePerTestPipeline(): void { $offset = \count(PipelineEntrySpyPlugin::$entered); - TestRunner::runTest([SkipMethodStub::class, 'parked']); + TestRunner::runTest([SkipMethodStub::class, 'skipped']); $entered = \array_slice(PipelineEntrySpyPlugin::$entered, $offset); Assert::array($entered) ->contains(SkipMethodStub::class . '::enabled') - ->notContains(SkipMethodStub::class . '::parked') - ->notContains(SkipMethodStub::class . '::parkedNoReason') - ->notContains(SkipWithHooksStub::class . '::parked') - ->notContains(SkipWithDataProviderStub::class . '::parked') - ->notContains(SkipWithRetryStub::class . '::parked') - ->notContains(SkipWithRepeatStub::class . '::parked') - ->notContains(SkipInFiberStub::class . '::parked') - ->notContains(SkipOverridingMethodStub::class . '::parked') - ->notContains('Tests\Test\Stub\Skip\parked_function'); + ->notContains(SkipMethodStub::class . '::skipped') + ->notContains(SkipMethodStub::class . '::skippedNoReason') + ->notContains(SkipWithHooksStub::class . '::skipped') + ->notContains(SkipWithDataProviderStub::class . '::skipped') + ->notContains(SkipWithRetryStub::class . '::skipped') + ->notContains(SkipWithRepeatStub::class . '::skipped') + ->notContains(SkipInFiberStub::class . '::skipped') + ->notContains(SkipOverridingMethodStub::class . '::skipped') + ->notContains('Tests\Test\Stub\Skip\skippedFunction'); } /** * Fiber compatibility: the skip interceptor wraps the fiber batch runner instead of * replacing it. The round-robin interleaving of the two enabled tests is produced only by * the case scheduler — run sequentially, their `\Fiber::suspend()` would throw and the - * log would stop short — while the parked test is still skipped. + * log would stop short — while the skipped test is still skipped. */ public function fiberBatchRunnerSurvivesTheWrap(): void { $offset = \count(SkipInFiberStub::$log); - $parked = TestRunner::runTest([SkipInFiberStub::class, 'parked']); + $skipped = TestRunner::runTest([SkipInFiberStub::class, 'skipped']); - Assert::same($parked->status, Status::Skipped); + Assert::same($skipped->status, Status::Skipped); Assert::same( \array_slice(SkipInFiberStub::$log, $offset), ['first.1', 'second.1', 'first.2', 'second.2'], diff --git a/plugin/test/tests/Feature/SkipSummaryTest.php b/plugin/test/tests/Feature/SkipSummaryTest.php index c78ece89..3445a197 100644 --- a/plugin/test/tests/Feature/SkipSummaryTest.php +++ b/plugin/test/tests/Feature/SkipSummaryTest.php @@ -32,7 +32,7 @@ final class SkipSummaryTest * data-driven). The classic off-by-one bug lives in that mix: the skipped tests must be * counted rather than lost, and the failing neighbor must still fail the run. */ - public function parkedTestsAddUpAndFailingNeighborStillFailsTheRun(): void + public function skippedTestsAddUpAndFailingNeighborStillFailsTheRun(): void { $run = self::run(__DIR__ . '/../Stub/SkipSummary/Mixed'); @@ -54,9 +54,9 @@ public function parkedTestsAddUpAndFailingNeighborStillFailsTheRun(): void * {@see \Testo\Test\TestPlugin} registers; a second delivery would show up here as an * inflated total and an extra name. */ - public function runOfOnlyParkedTestsIsSuccessfulAndDeliveredOnce(): void + public function runOfOnlySkippedTestsIsSuccessfulAndDeliveredOnce(): void { - $run = self::run(__DIR__ . '/../Stub/SkipSummary/OnlyParked'); + $run = self::run(__DIR__ . '/../Stub/SkipSummary/OnlySkipped'); Assert::same($run->status, Status::Passed); Assert::same($run->summary->count(Status::Skipped), 2); @@ -74,7 +74,7 @@ public function runOfOnlyParkedTestsIsSuccessfulAndDeliveredOnce(): void \iterator_to_array($cases[0], preserve_keys: false), ); \sort($names); - Assert::same($names, ['firstParked', 'secondParked']); + Assert::same($names, ['firstSkipped', 'secondSkipped']); } private static function run(string $path): RunResult diff --git a/plugin/test/tests/Stub/Skip/SkipChildStub.php b/plugin/test/tests/Stub/Skip/SkipChildStub.php index eac6f652..f4151d12 100644 --- a/plugin/test/tests/Stub/Skip/SkipChildStub.php +++ b/plugin/test/tests/Stub/Skip/SkipChildStub.php @@ -12,8 +12,8 @@ #[Test] final class SkipChildStub extends SkipParentStub { - public function parked(): void + public function skipped(): void { - throw new \LogicException('Must never run: the case is parked via the parent.'); + throw new \LogicException('Must never run: the case is skipped via the parent.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php b/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php index d02d381b..b06f2583 100644 --- a/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php +++ b/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php @@ -21,17 +21,17 @@ final class SkipClassAndMethodStub #[Skip('method-specific reason')] public function ownReason(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } public function classReason(): void { - throw new \LogicException('Must never run: the case is parked.'); + throw new \LogicException('Must never run: the case is skipped.'); } #[Skip] public function emptyOwnReason(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php b/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php index cf06c92b..c2974176 100644 --- a/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php +++ b/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php @@ -12,16 +12,16 @@ * attribute covers every test and not just the first one. */ #[Test] -#[Skip('the whole case is parked')] +#[Skip('the whole case is skipped')] final class SkipClassLevelStub { - public function firstParked(): void + public function firstSkipped(): void { - throw new \LogicException('Must never run: the case is parked.'); + throw new \LogicException('Must never run: the case is skipped.'); } - public function secondParked(): void + public function secondSkipped(): void { - throw new \LogicException('Must never run: the case is parked.'); + throw new \LogicException('Must never run: the case is skipped.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php b/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php index 71f4d796..7b80a5bf 100644 --- a/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php +++ b/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php @@ -8,12 +8,12 @@ use Testo\Test\Skip; /** - * Only parked tests and no class-level hooks: the class must never be instantiated. + * Only skipped tests and no class-level hooks: the class must never be instantiated. * * The flag is a one-way latch — nothing resets it, so feature tests assert it absolutely. */ #[Test] -#[Skip('fully parked, must not construct')] +#[Skip('fully skipped, must not construct')] final class SkipConstructorSpyStub { public static bool $constructed = false; @@ -23,13 +23,13 @@ public function __construct() self::$constructed = true; } - public function firstParked(): void + public function firstSkipped(): void { - throw new \LogicException('Must never run: the case is parked.'); + throw new \LogicException('Must never run: the case is skipped.'); } - public function secondParked(): void + public function secondSkipped(): void { - throw new \LogicException('Must never run: the case is parked.'); + throw new \LogicException('Must never run: the case is skipped.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php b/plugin/test/tests/Stub/Skip/SkipInFiberStub.php index 11f20007..e407d9b0 100644 --- a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php +++ b/plugin/test/tests/Stub/Skip/SkipInFiberStub.php @@ -29,10 +29,10 @@ final class SkipInFiberStub /** @var list */ public static array $log = []; - #[Skip('parked inside a fiber-driven case')] - public function parked(): void + #[Skip('skipped inside a fiber-driven case')] + public function skipped(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } public function first(): void diff --git a/plugin/test/tests/Stub/Skip/SkipMethodStub.php b/plugin/test/tests/Stub/Skip/SkipMethodStub.php index 68e51944..1c90aacd 100644 --- a/plugin/test/tests/Stub/Skip/SkipMethodStub.php +++ b/plugin/test/tests/Stub/Skip/SkipMethodStub.php @@ -17,20 +17,20 @@ final class SkipMethodStub { #[Skip('broken by the pricing rework, see ISSUE-123')] - public function parked(): void + public function skipped(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } #[Skip] - public function parkedNoReason(): void + public function skippedNoReason(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } public function enabled(): void { - # Control neighbor: stays runnable next to the parked ones. + # Control neighbor: stays runnable next to the skipped ones. Assert::true(true); } } diff --git a/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php b/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php index e7968fcc..e16ac5f4 100644 --- a/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php +++ b/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php @@ -10,13 +10,13 @@ /** * Documented caveat: a non-static class-level hook forces construction even when every - * test of the case is parked. The stub pins that behavior so a future change is a + * test of the case is skipped. The stub pins that behavior so a future change is a * conscious one, not an accident. * * The construction counter accumulates across catalog runs — feature tests assert deltas. */ #[Test] -#[Skip('fully parked, but the non-static hook builds the class')] +#[Skip('fully skipped, but the non-static hook builds the class')] final class SkipNonStaticHookStub { public static int $constructions = 0; @@ -29,8 +29,8 @@ public function __construct() #[BeforeClass] public function bootCase(): void {} - public function parked(): void + public function skipped(): void { - throw new \LogicException('Must never run: the case is parked.'); + throw new \LogicException('Must never run: the case is skipped.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php b/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php index fadd1a34..7aa017ad 100644 --- a/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php +++ b/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php @@ -13,8 +13,8 @@ abstract class SkipOverriddenMethodParentStub { #[Skip('inherited from the overridden method')] - public function parked(): void + public function skipped(): void { - throw new \LogicException('Must never run: the parent method is parked.'); + throw new \LogicException('Must never run: the parent method is skipped.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipOverridingMethodStub.php b/plugin/test/tests/Stub/Skip/SkipOverridingMethodStub.php index 8ded601c..1daee184 100644 --- a/plugin/test/tests/Stub/Skip/SkipOverridingMethodStub.php +++ b/plugin/test/tests/Stub/Skip/SkipOverridingMethodStub.php @@ -14,8 +14,8 @@ final class SkipOverridingMethodStub extends SkipOverriddenMethodParentStub { #[\Override] - public function parked(): void + public function skipped(): void { - throw new \LogicException('Must never run: the test is parked via the overridden method.'); + throw new \LogicException('Must never run: the test is skipped via the overridden method.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipTraitStub.php b/plugin/test/tests/Stub/Skip/SkipTraitStub.php index f7547567..ff56a1a1 100644 --- a/plugin/test/tests/Stub/Skip/SkipTraitStub.php +++ b/plugin/test/tests/Stub/Skip/SkipTraitStub.php @@ -14,8 +14,8 @@ final class SkipTraitStub { use SkipMarkerTrait; - public function parked(): void + public function skipped(): void { - throw new \LogicException('Must never run: the case is parked via the trait.'); + throw new \LogicException('Must never run: the case is skipped via the trait.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php b/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php index d205b24e..e08785cd 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php @@ -33,10 +33,10 @@ public static function provide(): array ]; } - #[Skip('data-driven test is parked as a whole')] + #[Skip('data-driven test is skipped as a whole')] #[DataProvider('provide')] - public function parked(int $value): void + public function skipped(int $value): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } } diff --git a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php b/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php index 01d215dc..5d041de1 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php @@ -53,10 +53,10 @@ public static function shutdownTest(): void ++self::$afterTestCalls; } - #[Skip('parked next to hooks')] - public function parked(): void + #[Skip('skipped next to hooks')] + public function skipped(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } public function enabled(): void diff --git a/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php b/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php index a022efcf..d78a7077 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php @@ -11,7 +11,7 @@ /** * `#[Skip]` on a test that also carries `#[Repeat]`: the repeat is resolved in the per-test * pipeline, which a skipped test never enters, so the body must not run at all. The latch is - * never reset — {@see \Tests\Test\Feature\SkipFeatureTest::repeatDoesNotEngageForParkedTest()} + * never reset — {@see \Tests\Test\Feature\SkipFeatureTest::repeatDoesNotEngageForSkippedTest()} * asserts it absolutely, not as a delta. The enabled neighbor carries the same attribute and * counts its runs: three per run prove the repeat is live in this suite. */ @@ -21,12 +21,12 @@ final class SkipWithRepeatStub public static bool $bodyRan = false; public static int $enabledRuns = 0; - #[Skip('parked, repeat must not engage')] + #[Skip('skipped, repeat must not engage')] #[Repeat(times: 3)] - public function parked(): void + public function skipped(): void { self::$bodyRan = true; - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } #[Repeat(times: 3)] diff --git a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php b/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php index 384440e9..6d76379c 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php @@ -21,12 +21,12 @@ final class SkipWithRetryStub public static int $attempts = 0; public static int $enabledAttempts = 0; - #[Skip('parked, retry must not engage')] + #[Skip('skipped, retry must not engage')] #[Retry(maxAttempts: 3)] - public function parked(): void + public function skipped(): void { ++self::$attempts; - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } #[Retry(maxAttempts: 3, markFlaky: false)] diff --git a/plugin/test/tests/Stub/Skip/skip_functions.php b/plugin/test/tests/Stub/Skip/skip_functions.php index 2edb773d..fe87452e 100644 --- a/plugin/test/tests/Stub/Skip/skip_functions.php +++ b/plugin/test/tests/Stub/Skip/skip_functions.php @@ -11,16 +11,16 @@ # Proves #[Skip] reaches a function-based case as well: the test is reported as Skipped and its # message is built from the function FQN. #[Test] -#[Skip('functional test is parked')] -function parked_function(): void +#[Skip('functional test is skipped')] +function skippedFunction(): void { - throw new \LogicException('Must never run: the test is parked.'); + throw new \LogicException('Must never run: the test is skipped.'); } # Control neighbor of the same case: an enabled function next to a skipped one still runs through # the batch runner the interceptor installs on the case, and passes. #[Test] -function enabled_function(): void +function enabledFunction(): void { Assert::true(true); } diff --git a/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php b/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php deleted file mode 100644 index 515b2a6d..00000000 --- a/plugin/test/tests/Stub/SkipStandalone/StandaloneParkedTest.php +++ /dev/null @@ -1,27 +0,0 @@ -runTestCase($info, self::coreNext($seenTests)); @@ -53,20 +53,20 @@ public function filtersParkedTestsBeforeNext(): void } /** - * The parked tests still come back in the case result — as synthetic Skipped results + * The skipped tests still come back in the case result — as synthetic Skipped results * with a SkipTest failure and a self-stamped summary. */ public function returnsSyntheticSkippedResults(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); - $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked', 'enabled'); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'skipped', 'enabled'); $result = $interceptor->runTestCase($info, self::coreNext()); - $parked = self::findResult($result, 'parked'); - Assert::same($parked->status, Status::Skipped); - Assert::instanceOf($parked->failure, SkipTest::class); - Assert::same($parked->summary->count(Status::Skipped), 1); + $skipped = self::findResult($result, 'skipped'); + Assert::same($skipped->status, Status::Skipped); + Assert::instanceOf($skipped->failure, SkipTest::class); + Assert::same($skipped->summary->count(Status::Skipped), 1); Assert::same($result->summary->count(Status::Skipped), 1); Assert::same($result->summary->count(Status::Passed), 1); } @@ -74,14 +74,14 @@ public function returnsSyntheticSkippedResults(): void public function composesReasonAfterGeneratedPart(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); - $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked'); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'skipped'); $result = $interceptor->runTestCase($info, self::coreNext()); Assert::same( - self::findResult($result, 'parked')->failure?->getMessage(), + self::findResult($result, 'skipped')->failure?->getMessage(), SkipMixedMethodsFixture::class - . '::parked is skipped via #[Skip] ==> broken by the pricing rework, see ISSUE-123', + . '::skipped is skipped via #[Skip] ==> broken by the pricing rework, see ISSUE-123', ); } @@ -92,35 +92,35 @@ public function composesReasonAfterGeneratedPart(): void public function fallsBackToGeneratedMessageWithoutReason(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); - $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parkedNoReason'); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'skippedNoReason'); $result = $interceptor->runTestCase($info, self::coreNext()); Assert::same( - self::findResult($result, 'parkedNoReason')->failure?->getMessage(), - SkipMixedMethodsFixture::class . '::parkedNoReason is skipped via #[Skip]', + self::findResult($result, 'skippedNoReason')->failure?->getMessage(), + SkipMixedMethodsFixture::class . '::skippedNoReason is skipped via #[Skip]', ); } /** - * The origin contract: a `#[Skip]`-parked result carries the attribute instances in its + * The origin contract: a result skipped by `#[Skip]` carries the attribute instances in its * info, so downstream consumers can tell a declarative skip from a runtime one. */ public function stampsOriginAttributeOnSyntheticInfo(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); - $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked', 'enabled'); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'skipped', 'enabled'); $result = $interceptor->runTestCase($info, self::coreNext()); - $origin = self::findResult($result, 'parked')->info->getAttribute(Skip::class); + $origin = self::findResult($result, 'skipped')->info->getAttribute(Skip::class); Assert::array($origin)->hasCount(1); Assert::instanceOf($origin[0], Skip::class); Assert::same($origin[0]->reason, 'broken by the pricing rework, see ISSUE-123'); Assert::null(self::findResult($result, 'enabled')->info->getAttribute(Skip::class)); } - public function classLevelSkipParksEveryTest(): void + public function classLevelSkipSkipsEveryTest(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); $info = self::createCaseInfo(SkipClassLevelFixture::class, 'first', 'second'); @@ -146,7 +146,7 @@ public function methodReasonWinsOverClassReason(): void Assert::true(\str_ends_with( (string) self::findResult($result, 'first')->failure?->getMessage(), - ' ==> entire case is parked', + ' ==> entire case is skipped', )); Assert::true(\str_ends_with( (string) self::findResult($result, 'second')->failure?->getMessage(), @@ -159,10 +159,10 @@ public function methodReasonWinsOverClassReason(): void } /** - * A case with no parked tests passes through untouched: same test set, no batch runner + * A case with no skipped tests passes through untouched: same test set, no batch runner * installed. */ - public function passesThroughCaseWithoutParkedTests(): void + public function passesThroughCaseWithoutSkippedTests(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'enabled'); @@ -184,7 +184,7 @@ public function wrapsExistingBatchRunnerInsteadOfReplacing(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); $innerRunnerCalls = 0; - $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked', 'enabled') + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'skipped', 'enabled') ->withBatchRunner(static function (array $handlers) use (&$innerRunnerCalls): array { ++$innerRunnerCalls; return \array_map(static fn(callable $handler): TestResult => $handler(), $handlers); @@ -194,18 +194,18 @@ public function wrapsExistingBatchRunnerInsteadOfReplacing(): void Assert::same($innerRunnerCalls, 1); Assert::same(self::findResult($result, 'enabled')->status, Status::Passed); - Assert::same(self::findResult($result, 'parked')->status, Status::Skipped); + Assert::same(self::findResult($result, 'skipped')->status, Status::Skipped); } /** * Reporters render test lines from the pipeline events: Starting before Finished, both * carrying the same address, so a reporter keyed on the identity closes what it opened. */ - public function dispatchesPipelineEventsForParkedTests(): void + public function dispatchesPipelineEventsForSkippedTests(): void { $dispatcher = self::createDispatcher(); $interceptor = new SkipInterceptor($dispatcher); - $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked'); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'skipped'); $result = $interceptor->runTestCase($info, self::coreNext()); @@ -214,9 +214,9 @@ public function dispatchesPipelineEventsForParkedTests(): void [$starting, $finished] = $events; Assert::instanceOf($starting, TestPipelineStarting::class); Assert::instanceOf($finished, TestPipelineFinished::class); - Assert::same($starting->testInfo->name, 'parked'); + Assert::same($starting->testInfo->name, 'skipped'); Assert::same($finished->testInfo->identity, $starting->testInfo->identity); - Assert::same($finished->testResult, self::findResult($result, 'parked')); + Assert::same($finished->testResult, self::findResult($result, 'skipped')); } /** @@ -226,12 +226,12 @@ public function dispatchesPipelineEventsForParkedTests(): void public function carriesDescriptionInSyntheticResult(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); - $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked'); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'skipped'); $result = $interceptor->runTestCase($info, self::coreNext()); Assert::same( - self::findResult($result, 'parked')->attributes['description'], + self::findResult($result, 'skipped')->attributes['description'], 'Checks that order totals include the reworked pricing.', ); } @@ -243,14 +243,14 @@ public function carriesDescriptionInSyntheticResult(): void public function skippedTestIsDeactivatedNotDiscarded(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); - $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'parked', 'enabled'); + $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'skipped', 'enabled'); $interceptor->runTestCase($info, self::coreNext()); $tests = $info->definition->tests; - Assert::array($tests->getTests())->hasKeys('enabled')->doesNotHaveKeys('parked'); - Assert::array($tests->getTests(active: false))->hasKeys('parked'); - Assert::array($tests->all())->hasKeys('parked', 'enabled'); + Assert::array($tests->getTests())->hasKeys('enabled')->doesNotHaveKeys('skipped'); + Assert::array($tests->getTests(active: false))->hasKeys('skipped'); + Assert::array($tests->all())->hasKeys('skipped', 'enabled'); } /** @@ -262,8 +262,8 @@ public function skipOnANonTestMemberIsInert(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); $info = self::createCaseInfoWith(SkipMixedMethodsFixture::class, [ - 'parked' => new TestDefinition( - new \ReflectionMethod(SkipMixedMethodsFixture::class, 'parked'), + 'skipped' => new TestDefinition( + new \ReflectionMethod(SkipMixedMethodsFixture::class, 'skipped'), isTest: false, ), 'enabled' => new TestDefinition(new \ReflectionMethod(SkipMixedMethodsFixture::class, 'enabled')), @@ -285,8 +285,8 @@ public function alreadyFilteredTestIsNotReportedAsSkipped(): void { $interceptor = new SkipInterceptor(self::createDispatcher()); $info = self::createCaseInfoWith(SkipMixedMethodsFixture::class, [ - 'parked' => new TestDefinition( - new \ReflectionMethod(SkipMixedMethodsFixture::class, 'parked'), + 'skipped' => new TestDefinition( + new \ReflectionMethod(SkipMixedMethodsFixture::class, 'skipped'), active: false, ), 'enabled' => new TestDefinition(new \ReflectionMethod(SkipMixedMethodsFixture::class, 'enabled')), diff --git a/skills/testo-flaky-tests/SKILL.md b/skills/testo-flaky-tests/SKILL.md index 6e12ad94..949b09b5 100644 --- a/skills/testo-flaky-tests/SKILL.md +++ b/skills/testo-flaky-tests/SKILL.md @@ -87,8 +87,8 @@ Don't ship `#[Repeat(times: 50)]` long-term on a fast suite — CI cost adds up. - Use `#[Repeat]`, never `#[Retry]`. 3. Is the flakiness from shared state inside the suite (ordering)? - Don't reach for either attribute. Fix isolation (lifecycle hooks, fresh fixtures). -4. Parking the test for a longer while (root cause known but not fixable now)? - - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays counted in reports as Skipped (full contract in the `testo-write-tests` skill). `#[Retry]` is for stabilizing, not parking. +4. Taking the test out of the run for a longer while (root cause known but not fixable now)? + - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays counted in reports as Skipped (full contract in the `testo-write-tests` skill). `#[Retry]` is for stabilizing, not skipping. ## Pitfalls diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index 661e6134..0e2e47c9 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -136,7 +136,7 @@ Constraints: - Subclasses work: `class MissingExtensionSkip extends SkipTest {}` is still recognized. - Return type stays `void`, or `never` if the throw is unconditional. -## Parking a test with #[Skip] +## Skipping a test with #[Skip] To skip a test declaratively — without running any of its code — put `Testo\Test\Skip` (from the `testo/test` plugin, the same package as `#[Test]`) on the test method (inherited by an overriding @@ -158,7 +158,7 @@ the skipped line without it, and the compact `--json` report only counts the tes `totals.skipped`. `reason` is optional and the attribute is not repeatable — but **always pass a reason that points -at an issue** (`#[Skip('flaky on CI, see ISSUE-123')]`); a bare `#[Skip]` is how a parked test rots +at an issue** (`#[Skip('flaky on CI, see ISSUE-123')]`); a bare `#[Skip]` is how a skipped test rots unreviewed. Its interceptor is registered by `TestPlugin` (on by default); in a suite configured without that plugin only a class-level `#[Skip]` still works — through the attribute's own fallback. @@ -166,15 +166,15 @@ Which skipping tool to reach for: | Tool | Decided by | Visibility | Use when | |---|---|---|---| -| `#[Skip('...')]` | code, ahead of time | always reported; reason in JUnit/TeamCity/HTML | test is parked and must be returned to | +| `#[Skip('...')]` | code, ahead of time | always reported; reason in JUnit/TeamCity/HTML | test is skipped and must be returned to | | `throw SkipTest` | test body, at runtime | reported when the run gets there | test isn't applicable in this environment | | `#[Group]` + `--group=!x` | runner invocation | invisible — filtered out of reports | a category you sometimes don't run | Runtime contract of `#[Skip]`: the test never enters the per-test pipeline, so `#[BeforeTest]`/`#[AfterTest]`, data providers, `#[Retry]`/`#[Repeat]` and coverage never engage, and a data-driven test yields a single Skipped entry (the provider is not called). -`#[BeforeClass]`/`#[AfterClass]` still run (also when every test of the case is parked). A -skipped test never requires an instance of the case class: a fully parked class is built only +`#[BeforeClass]`/`#[AfterClass]` still run (also when every test of the case is skipped). A +skipped test never requires an instance of the case class: a fully skipped class is built only when a non-static class-level hook forces it, while enabled neighbors construct it as usual. A run of only `#[Skip]`-marked tests is a success (exit 0). `#[Skip]` applies to plain tests only: on a `#[Bench]` or `#[TestInline]` target it is inert — the benchmark or inline case runs as usual. From 2f16f363ac63ccc0780bf685347f0a74f6ed9239 Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:39:38 +0500 Subject: [PATCH 27/35] style(test): use a # line comment in SkipWithHooksStub Assisted-By: Claude Fable 5.1 --- plugin/test/tests/Stub/Skip/SkipWithHooksStub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php b/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php index 5d041de1..eba920af 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php @@ -61,7 +61,7 @@ public function skipped(): void public function enabled(): void { - // Control neighbor: proves the per-test hooks and counters do work in this case. + # Control neighbor: proves the per-test hooks and counters do work in this case. Assert::true(true); } } From 0bcca01df943f5bfcc7f7f1d86991f337a38b3f5 Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:56:47 +0500 Subject: [PATCH 28/35] test(test): pin what the #[Skip] docblocks promise The pass-through unit test now asserts the test set it claims stays untouched, the attribute test guards the #[\Attribute] lookup before indexing it, and the pipeline-entry check pins the enabled function neighbor next to the enabled method. The standalone stub's reason says "case" instead of "catalog", together with the test that pins it. Assisted-By: Claude Fable 5.1 --- plugin/test/tests/Feature/SkipFallbackStandaloneTest.php | 8 ++++---- plugin/test/tests/Feature/SkipFeatureTest.php | 7 ++++--- .../tests/Stub/SkipStandalone/StandaloneSkippedTest.php | 4 ++-- plugin/test/tests/Unit/Internal/SkipInterceptorTest.php | 8 ++++++-- plugin/test/tests/Unit/SkipAttributeTest.php | 1 + 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php b/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php index ef734fd9..3d5c4884 100644 --- a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php +++ b/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php @@ -24,7 +24,7 @@ /** * The standalone contract of `#[Skip]`: with `TestPlugin` not registered, the attribute's * {@see \Testo\Pipeline\Attribute\FallbackInterceptor} declaration alone skips a class-level - * catalog (tests are discovered by naming convention, so no `#[Test]` attribute is involved). + * case (tests are discovered by naming convention, so no `#[Test]` attribute is involved). */ #[Test] #[Covers(Skip::class)] @@ -55,7 +55,7 @@ public function classLevelSkipFallsBackWithoutTestPlugin(): void } # No TestPlugin in this run: the interceptor the attribute spawns through its own - # #[FallbackInterceptor] is what reports both tests of the catalog. + # #[FallbackInterceptor] is what reports both tests of the case. Assert::count($tests, 2); $messages = []; @@ -69,8 +69,8 @@ public function classLevelSkipFallsBackWithoutTestPlugin(): void # the `is skipped via #[Skip]` marker and the class-level reason. \sort($messages); Assert::same($messages, [ - StandaloneSkippedTest::class . '::testFirstSkipped is skipped via #[Skip] ==> standalone catalog is skipped', - StandaloneSkippedTest::class . '::testSecondSkipped is skipped via #[Skip] ==> standalone catalog is skipped', + StandaloneSkippedTest::class . '::testFirstSkipped is skipped via #[Skip] ==> standalone case is skipped', + StandaloneSkippedTest::class . '::testSecondSkipped is skipped via #[Skip] ==> standalone case is skipped', ]); } } diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/test/tests/Feature/SkipFeatureTest.php index 4e5d0bab..32abdfed 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/test/tests/Feature/SkipFeatureTest.php @@ -159,7 +159,7 @@ public function skippedResultCarriesOriginAttribute(): void /** * The skipped test is filtered out before the case runs: class-level hooks fire as usual - * (once per catalog run), per-test hooks fire only for the enabled control test. + * (once per directory run), per-test hooks fire only for the enabled control test. */ public function classHooksRunButTestHooksDoNot(): void { @@ -230,7 +230,7 @@ public function methodLevelSkipIsInheritedByOverridingMethod(): void /** * A data-driven skipped test yields a single Skipped node: the provider is never called - * (not once across all catalog runs of this class), no `MultipleResult` aggregate is + * (not once across all directory runs of this class), no `MultipleResult` aggregate is * attached. */ public function dataProviderIsNotCalledForSkippedTest(): void @@ -277,7 +277,7 @@ public function repeatDoesNotEngageForSkippedTest(): void /** * The common ground of the hook/provider/retry/repeat checks above: a skipped test never * enters the per-test pipeline at all. A spy interceptor on that pipeline sees the - * enabled neighbors of the catalog and none of the skipped tests. + * enabled neighbors of the directory and none of the skipped tests. */ public function skippedTestsNeverEnterThePerTestPipeline(): void { @@ -288,6 +288,7 @@ public function skippedTestsNeverEnterThePerTestPipeline(): void $entered = \array_slice(PipelineEntrySpyPlugin::$entered, $offset); Assert::array($entered) ->contains(SkipMethodStub::class . '::enabled') + ->contains('Tests\Test\Stub\Skip\enabledFunction') ->notContains(SkipMethodStub::class . '::skipped') ->notContains(SkipMethodStub::class . '::skippedNoReason') ->notContains(SkipWithHooksStub::class . '::skipped') diff --git a/plugin/test/tests/Stub/SkipStandalone/StandaloneSkippedTest.php b/plugin/test/tests/Stub/SkipStandalone/StandaloneSkippedTest.php index c76823bf..b4a9f654 100644 --- a/plugin/test/tests/Stub/SkipStandalone/StandaloneSkippedTest.php +++ b/plugin/test/tests/Stub/SkipStandalone/StandaloneSkippedTest.php @@ -7,12 +7,12 @@ use Testo\Test\Skip; /** - * A catalog for the standalone-fallback run: discovered by naming convention alone (no + * The case of the standalone-fallback run: discovered by naming convention alone (no * `#[Test]` attribute), executed without `TestPlugin` — only the class-level `#[Skip]` * fallback skips these tests. Lives in its own directory so the standalone run's * `FinderConfig` can point at it alone and pick up nothing else. */ -#[Skip('standalone catalog is skipped')] +#[Skip('standalone case is skipped')] final class StandaloneSkippedTest { public function testFirstSkipped(): void diff --git a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php index e3bfe44a..56429afb 100644 --- a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php +++ b/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php @@ -54,7 +54,8 @@ public function filtersSkippedTestsBeforeNext(): void /** * The skipped tests still come back in the case result — as synthetic Skipped results - * with a SkipTest failure and a self-stamped summary. + * with a SkipTest failure and their own `Summary::forTest(Status::Skipped)`, since no core + * runner produces one for them. */ public function returnsSyntheticSkippedResults(): void { @@ -167,12 +168,15 @@ public function passesThroughCaseWithoutSkippedTests(): void $interceptor = new SkipInterceptor(self::createDispatcher()); $info = self::createCaseInfo(SkipMixedMethodsFixture::class, 'enabled'); $batchRunner = false; + $seenTests = null; - $interceptor->runTestCase($info, static function (CaseInfo $inner) use (&$batchRunner): CaseResult { + $interceptor->runTestCase($info, static function (CaseInfo $inner) use (&$batchRunner, &$seenTests): CaseResult { $batchRunner = $inner->batchRunner; + $seenTests = \array_keys($inner->definition->tests->getTests()); return new CaseResult(results: [], status: Status::Passed); }); + Assert::same($seenTests, ['enabled']); Assert::null($batchRunner); } diff --git a/plugin/test/tests/Unit/SkipAttributeTest.php b/plugin/test/tests/Unit/SkipAttributeTest.php index 190ca124..ab2a5e9f 100644 --- a/plugin/test/tests/Unit/SkipAttributeTest.php +++ b/plugin/test/tests/Unit/SkipAttributeTest.php @@ -40,6 +40,7 @@ public function targetsClassMethodAndFunctionOnly(): void { $attributes = (new \ReflectionClass(Skip::class))->getAttributes(\Attribute::class); + Assert::count($attributes, 1); /** @var \Attribute $attribute */ $attribute = $attributes[0]->newInstance(); From aa048eae64dec0f73e5593150ce8c1582db40cee Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 10:56:47 +0500 Subject: [PATCH 29/35] docs(test): say "directory" and "case" instead of "catalog" in the #[Skip] docs docs(skills): complete the skip-from-a-case-interceptor recipe The last "catalog" calques give way to the glossary words; the stub docblocks name the feature test that reads each counter or latch, the trait marker and the mixed data provider get their one-line docblocks, and SkipInterceptor states why bench and inline cases are out of its reach. testo-plugin-author says what to do when the case carries no batch runner; testo-flaky-tests and testo-write-tests phrase the "when to skip" entries as conditions. Assisted-By: Claude Fable 5.1 --- .../tests/Feature/FullySkippedCaseFeatureTest.php | 8 ++++---- .../tests/Stub/FullySkipped/FullySkippedClassStub.php | 8 ++++---- .../Stub/FullySkipped/fully_skipped_functions.php | 2 +- plugin/test/src/Internal/SkipInterceptor.php | 11 ++++++----- plugin/test/tests/Feature/SkipSummaryTest.php | 4 ++-- plugin/test/tests/Stub/PipelineEntrySpyPlugin.php | 2 +- .../test/tests/Stub/Skip/SkipConstructorSpyStub.php | 4 +++- plugin/test/tests/Stub/Skip/SkipInFiberStub.php | 4 ++-- plugin/test/tests/Stub/Skip/SkipMarkerTrait.php | 3 +++ plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php | 3 ++- plugin/test/tests/Stub/Skip/SkipWithHooksStub.php | 2 +- .../test/tests/Stub/SkipSummary/Mixed/MixedStub.php | 5 +++++ .../Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php | 2 +- .../tests/Unit/Fixture/SkipMixedMethodsFixture.php | 4 +++- skills/testo-flaky-tests/SKILL.md | 2 +- skills/testo-plugin-author/SKILL.md | 9 +++++---- skills/testo-write-tests/SKILL.md | 2 +- 17 files changed, 45 insertions(+), 30 deletions(-) diff --git a/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php b/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php index 964b919a..0ef8fe34 100644 --- a/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php +++ b/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php @@ -22,9 +22,9 @@ * The `#[Skip]` case interceptor deactivates the skipped tests before the {@see LifecycleInterceptor} * collects the case's hooks, so hook discovery must not depend on the surviving tests. It does not: * the hooks are the case's non-tests. Prefilling defines every member as a non-test, - * {@see LifecycleInterceptor} demotes back the ones a finder took for tests (a class-level `#[Test]` - * promotes the hook methods of a class case first), and it then reads them all back with - * `filter(isTest: false)` — non-tests outlive the deactivation of the tests. + * {@see LifecycleInterceptor} demotes back the lifecycle-annotated ones a finder took for tests + * (a class-level `#[Test]` promotes the hook methods of a class case first), and it then reads + * them all back with `filter(isTest: false)` — non-tests outlive the deactivation of the tests. * * Both case shapes are pinned here through the real pipeline. Their members are prefilled by * {@see \Testo\Core\Definition\CaseDefinitions::define()} from the two sources it knows: the @@ -44,7 +44,7 @@ public function __construct() } /** - * The function-based case shape: class-level hooks fire exactly once per catalog run even + * The function-based case shape: class-level hooks fire exactly once per directory run even * though no test of the case stays active; per-test hooks have nothing to wrap and stay silent. */ public function classHooksRunForFullySkippedFunctionCase(): void diff --git a/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php b/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php index cb1c16a7..6764ef3e 100644 --- a/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php +++ b/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php @@ -10,11 +10,11 @@ use Testo\Test\Skip; /** - * Class-based analog of the fully skipped function case in the same directory - * ({@see FullySkippedFunctionState}): the hooks are the case's non-tests, so they never - * depended on the surviving tests — pinned here so both flavors stay in lockstep. + * Class-based analog of the fully skipped function case in `fully_skipped_functions.php` + * ({@see skippedFnOne()}): the hooks are the case's non-tests, so they never depended on the + * surviving tests — pinned here so both flavors stay in lockstep. * - * Static hook counters accumulate across catalog runs — feature tests assert deltas. The hooks + * Static hook counters accumulate across directory runs — feature tests assert deltas. The hooks * are static so the fully skipped class is never instantiated. */ #[Test] diff --git a/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php b/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php index c9d97cef..20b991ac 100644 --- a/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php +++ b/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php @@ -22,7 +22,7 @@ * discovery must not depend on the surviving tests: `#[BeforeClass]`/`#[AfterClass]` still run * for the case (the `#[Skip]` contract), while the per-test hooks have nothing to wrap. * - * Static hook counters accumulate across catalog runs — feature tests assert deltas. + * Static hook counters accumulate across directory runs — feature tests assert deltas. * State is shared through {@see FullySkippedFunctionState} because functions have no `$this`. */ #[BeforeClass] diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/test/src/Internal/SkipInterceptor.php index a720639b..31286c47 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/test/src/Internal/SkipInterceptor.php @@ -49,9 +49,9 @@ * (so filtering happens before `#[BeforeClass]`) and inner to the fiber interceptor (so a * fiber batch runner is already on the case and gets wrapped). * - * `testType: TestType::Test` keeps the interceptor off `#[Bench]` and `#[TestInline]` cases; those - * carry no foreign members to skip anyway, their finders (`BenchFinder`, `InlineFinder`) define - * the case with `prefill: false`. + * `testType: TestType::Test` keeps the interceptor off `#[Bench]` and `#[TestInline]` cases: their + * finders (`BenchFinder`, `InlineFinder`) define the case with `prefill: false`, so it holds nothing + * but their own members — a `#[Skip]` on one of them is inert, see {@see Skip}. * * Two deliberate consequences of delivering results this way: * @@ -65,8 +65,9 @@ * ({@see \Testo\Application\Internal\Runner\CaseRunner::run()}). One `#[Skip]` in a case moves * its remaining tests onto a handler frame. * - * The flag is flipped once on the shared case definition, so a second `runTestCase()` over the - * same {@see \Testo\Core\Definition\CaseDefinition} finds no skipped tests left to report. + * The flag is flipped once on the case's shared {@see \Testo\Core\Definition\TestDefinition}s, so a + * second `runTestCase()` over the same {@see \Testo\Core\Definition\CaseDefinition} finds no skipped + * tests left to report. * * Never throws for a skipped test — a throw from a case interceptor aborts the whole case. * diff --git a/plugin/test/tests/Feature/SkipSummaryTest.php b/plugin/test/tests/Feature/SkipSummaryTest.php index 3445a197..c22dc335 100644 --- a/plugin/test/tests/Feature/SkipSummaryTest.php +++ b/plugin/test/tests/Feature/SkipSummaryTest.php @@ -29,8 +29,8 @@ final class SkipSummaryTest { /** * The mixed directory holds one passing, one failing and two skipped tests (one of them - * data-driven). The classic off-by-one bug lives in that mix: the skipped tests must be - * counted rather than lost, and the failing neighbor must still fail the run. + * data-driven). Skipped tests are where the totals go off by one: they must be counted + * rather than lost, and the failing neighbor must still fail the run. */ public function skippedTestsAddUpAndFailingNeighborStillFailsTheRun(): void { diff --git a/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php b/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php index 870f3675..e75ca16f 100644 --- a/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php +++ b/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php @@ -17,7 +17,7 @@ * Unlike a {@see \Testo\Event\Test\TestPipelineStarting} listener — which also sees the * events the skip interceptor dispatches for its synthetic results — a per-test interceptor * is reached only by tests that actually run through the pipeline. The record accumulates - * across catalog runs — feature tests inspect the slice of their own run. + * across directory runs — feature tests inspect the slice of their own run. */ final class PipelineEntrySpyPlugin implements PluginConfigurator { diff --git a/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php b/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php index 7b80a5bf..7f6cd022 100644 --- a/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php +++ b/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php @@ -10,7 +10,9 @@ /** * Only skipped tests and no class-level hooks: the class must never be instantiated. * - * The flag is a one-way latch — nothing resets it, so feature tests assert it absolutely. + * The flag is a one-way latch — nothing resets it, so + * {@see \Tests\Test\Feature\SkipFeatureTest::fullySkippedCaseWithoutHooksIsNeverInstantiated()} + * asserts it absolutely, not as a delta. */ #[Test] #[Skip('fully skipped, must not construct')] diff --git a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php b/plugin/test/tests/Stub/Skip/SkipInFiberStub.php index e407d9b0..fb2774f9 100644 --- a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php +++ b/plugin/test/tests/Stub/Skip/SkipInFiberStub.php @@ -19,8 +19,8 @@ * * Driven through {@see \Testo\Testing\Helper\TestRunner} by the Feature suite; * {@see \Tests\Test\Feature\SkipFeatureTest::fiberBatchRunnerSurvivesTheWrap()} asserts the - * interleaving. The log accumulates - * across catalog runs — this stub's tests and the feature test assert the tail written by their own run. + * interleaving. The log accumulates across directory runs — this stub's tests and the feature + * test assert the tail written by their own run. */ #[Test] #[RunInFiber(Schedule::RoundRobin)] diff --git a/plugin/test/tests/Stub/Skip/SkipMarkerTrait.php b/plugin/test/tests/Stub/Skip/SkipMarkerTrait.php index 6279a390..1a80ee79 100644 --- a/plugin/test/tests/Stub/Skip/SkipMarkerTrait.php +++ b/plugin/test/tests/Stub/Skip/SkipMarkerTrait.php @@ -6,5 +6,8 @@ use Testo\Test\Skip; +/** + * Carries a class-level `#[Skip]` for {@see SkipTraitStub} to use — nothing else. + */ #[Skip('inherited from the trait')] trait SkipMarkerTrait {} diff --git a/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php b/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php index e16ac5f4..afa723fc 100644 --- a/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php +++ b/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php @@ -13,7 +13,8 @@ * test of the case is skipped. The stub pins that behavior so a future change is a * conscious one, not an accident. * - * The construction counter accumulates across catalog runs — feature tests assert deltas. + * The construction counter accumulates across directory runs — + * {@see \Tests\Test\Feature\SkipFeatureTest::nonStaticClassHookStillBuildsTheClass()} asserts the delta. */ #[Test] #[Skip('fully skipped, but the non-static hook builds the class')] diff --git a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php b/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php index eba920af..0315b4bb 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php @@ -19,7 +19,7 @@ * `#[BeforeTest]`/`#[AfterTest]` fire for the enabled control test {@see enabled()} alone. * Driven by {@see \Tests\Test\Feature\SkipFeatureTest::classHooksRunButTestHooksDoNot()}. * - * Static hook counters accumulate across catalog runs — feature tests assert deltas. + * Static hook counters accumulate across directory runs — feature tests assert deltas. */ #[Test] final class SkipWithHooksStub diff --git a/plugin/test/tests/Stub/SkipSummary/Mixed/MixedStub.php b/plugin/test/tests/Stub/SkipSummary/Mixed/MixedStub.php index 52addffb..d5d53c5f 100644 --- a/plugin/test/tests/Stub/SkipSummary/Mixed/MixedStub.php +++ b/plugin/test/tests/Stub/SkipSummary/Mixed/MixedStub.php @@ -18,6 +18,11 @@ #[Test] final class MixedStub { + /** + * Data sets for {@see self::skippedDataDriven()}; never called, since that test is skipped. + * + * @return iterable + */ public static function provide(): iterable { yield [1]; diff --git a/plugin/test/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php b/plugin/test/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php index d8bd1cdc..fbc8775b 100644 --- a/plugin/test/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php +++ b/plugin/test/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php @@ -8,7 +8,7 @@ use Testo\Test\Skip; /** - * A catalog consisting of skipped tests only: such a run must be a success (exit 0). + * A case consisting of skipped tests only: such a run must be a success (exit 0). */ #[Test] #[Skip('everything here is skipped')] diff --git a/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php b/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php index 63f65083..44cc0dc5 100644 --- a/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php +++ b/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php @@ -10,7 +10,9 @@ * Fixture mixing skipped and enabled tests. * * Used by {@see \Tests\Test\Unit\Internal\SkipInterceptorTest}: one test is skipped with a reason, - * one without a reason, and one stays enabled to show what the interceptor leaves alone. + * one without a reason, and one stays enabled to show what the interceptor leaves alone. The + * PHPDoc summary of the skipped test is the description {@see \Testo\Test\Internal\SkipInterceptor} + * copies into the synthetic result. */ final class SkipMixedMethodsFixture { diff --git a/skills/testo-flaky-tests/SKILL.md b/skills/testo-flaky-tests/SKILL.md index 949b09b5..453d8340 100644 --- a/skills/testo-flaky-tests/SKILL.md +++ b/skills/testo-flaky-tests/SKILL.md @@ -87,7 +87,7 @@ Don't ship `#[Repeat(times: 50)]` long-term on a fast suite — CI cost adds up. - Use `#[Repeat]`, never `#[Retry]`. 3. Is the flakiness from shared state inside the suite (ordering)? - Don't reach for either attribute. Fix isolation (lifecycle hooks, fresh fixtures). -4. Taking the test out of the run for a longer while (root cause known but not fixable now)? +4. Is the root cause known but not fixable now (the test has to leave the run for a while)? - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays counted in reports as Skipped (full contract in the `testo-write-tests` skill). `#[Retry]` is for stabilizing, not skipping. ## Pitfalls diff --git a/skills/testo-plugin-author/SKILL.md b/skills/testo-plugin-author/SKILL.md index e51fc672..95acce94 100644 --- a/skills/testo-plugin-author/SKILL.md +++ b/skills/testo-plugin-author/SKILL.md @@ -181,15 +181,16 @@ its `#[BeforeClass]`/`#[AfterClass]` hooks and every test it still had to run. S `$definition->active = false` on each of those. Deactivated, not discarded: `getTests()` then yields only the rest, and those are the tests the core runs. - Hand back their results yourself, from `CaseInfo::withBatchRunner`: **wrap** the runner already on - the case (testo/fiber may have set one), never replace it, and append one synthetic `TestResult` - per skipped test after the inner runner returns. + the case (testo/fiber may have set one), never replace it — and run the handlers yourself when the + case carries none — then append one synthetic `TestResult` per skipped test after the inner + runner returns. - Dispatch `TestPipelineStarting`/`TestPipelineFinished` around each synthetic result, or reporters never render its line, and stamp `summary: Summary::forTest(Status::Skipped)` on it — a result that never passes through the test runner is not counted for you. The shipped implementation of exactly this shape is `Testo\Test\Internal\SkipInterceptor` in -`plugin/test`, serving the `#[Skip]` attribute (whose contract is in the `testo-write-tests` skill). Read it as -a reference — it is `@internal`, don't import or subclass it. +`plugin/test`, serving the `#[Skip]` attribute (whose contract is in the `testo-write-tests` skill). +Read it as a reference — it is `@internal` (and `final`), don't import it. ## Container scopes — provision per-case / per-suite resources diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index 0e2e47c9..b0014592 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -166,7 +166,7 @@ Which skipping tool to reach for: | Tool | Decided by | Visibility | Use when | |---|---|---|---| -| `#[Skip('...')]` | code, ahead of time | always reported; reason in JUnit/TeamCity/HTML | test is skipped and must be returned to | +| `#[Skip('...')]` | code, ahead of time | always reported; reason in JUnit/TeamCity/HTML | the test is knowingly broken, tracked in an issue, and must be returned to | | `throw SkipTest` | test body, at runtime | reported when the run gets there | test isn't applicable in this environment | | `#[Group]` + `--group=!x` | runner invocation | invisible — filtered out of reports | a category you sometimes don't run | From 4a454e84d7d06a98a9aabc07a3dc42e2e814b700 Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 11:41:31 +0500 Subject: [PATCH 30/35] build: revert the hand-edited testo/test root constraints Reverts e0e2858. Both fields are regenerated by .github/release-please/sync-deps.php from resources/version.json on release: split packages get a caret constraint (the "x - 1" form is reserved for the testo/testo meta-package) and the path-repo alias follows the manifest, which still reads 0.1.7. With bump-patch-for-minor-pre-major the #[Skip] feat releases as 0.1.8, not 0.2.0, so the 0.2.x-dev alias would never match. Assisted-By: Claude Fable 5.1 --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ce80aafa..652996fd 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "testo/lifecycle": "^0.1.6", "testo/repeat": "^0.1.9", "testo/retry": "^0.1.5", - "testo/test": "0.1.7 - 1", + "testo/test": "^0.1.7", "yiisoft/injector": "^1.2" }, "require-dev": { @@ -129,7 +129,7 @@ "testo/lifecycle": "0.1.x-dev", "testo/repeat": "0.1.x-dev", "testo/retry": "0.1.x-dev", - "testo/test": "0.2.x-dev" + "testo/test": "0.1.x-dev" } } }, From 58bd873d3999d3a634ea5995611f96b3fb93537f Mon Sep 17 00:00:00 2001 From: Meacue Date: Tue, 8 Sep 2026 11:42:47 +0500 Subject: [PATCH 31/35] test(test): make the #[Retry] control in SkipWithRetryStub history-proof The enabled neighbor failed its first attempt by the parity of the static attempt counter, which holds only while every run adds exactly two attempts. A run narrowed to that method by --filter, or one aborted between the attempts, would flip the parity for good and break the delta assertion in SkipFeatureTest ever after. Track "first attempt failed" on the case instance instead: it is built anew for every run of the case and shared by the retry attempts within it, so the marker starts fresh each run. The cumulative static stays for the delta assertion. Assisted-By: Claude Fable 5.1 --- .../test/tests/Stub/Skip/SkipWithRetryStub.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php b/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php index 6d76379c..ca88e748 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php +++ b/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php @@ -21,6 +21,14 @@ final class SkipWithRetryStub public static int $attempts = 0; public static int $enabledAttempts = 0; + /** + * Per-run marker for the control neighbor. An instance property, not a static: the case + * instance is built anew for every run of the case and shared by all retry attempts within + * it, so the marker starts fresh each run and never depends on how many attempts earlier + * runs (a `--filter` on one method, an aborted run) left behind. + */ + private bool $firstAttemptFailed = false; + #[Skip('skipped, retry must not engage')] #[Retry(maxAttempts: 3)] public function skipped(): void @@ -32,8 +40,12 @@ public function skipped(): void #[Retry(maxAttempts: 3, markFlaky: false)] public function enabled(): void { - # Control neighbor: the counter is even at the start of every run, so the first attempt - # makes it odd and fails, the second makes it even and passes — two attempts per run. - ++self::$enabledAttempts % 2 === 0 or throw new \RuntimeException('First attempt fails by design.'); + ++self::$enabledAttempts; + + # Control neighbor: the first attempt of every run fails, the second passes — two attempts per run. + if (!$this->firstAttemptFailed) { + $this->firstAttemptFailed = true; + throw new \RuntimeException('First attempt fails by design.'); + } } } From 2d95649badec75e69d91426a38e779d70ac63321 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 15:59:11 +0400 Subject: [PATCH 32/35] feat(core): add `CaseInterceptable` for test-level attributes on the case pipeline The case pipeline is built from class attributes alone, so an attribute that has to act on the whole case from a single test (take it out before the class-level hooks) could only be wired by a plugin. Scanning every `Interceptable` on the tests would make a method-level `#[RunInFiber]` install a fiber batch runner for the entire case, hence an explicit opt-in interface. Test-level attributes are not stamped on `CaseInfo::$attributes`. Assisted-By: Claude Fable 5.1 --- core/Pipeline/Attribute/CaseInterceptable.php | 21 +++++ .../Internal/AttributesInterceptor.php | 33 +++++-- .../Pipeline/AttributesInterceptorTest.php | 91 +++++++++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 core/Pipeline/Attribute/CaseInterceptable.php diff --git a/core/Pipeline/Attribute/CaseInterceptable.php b/core/Pipeline/Attribute/CaseInterceptable.php new file mode 100644 index 00000000..eca73826 --- /dev/null +++ b/core/Pipeline/Attribute/CaseInterceptable.php @@ -0,0 +1,21 @@ +definition->reflection === null + $classAttributes = $info->definition->reflection === null ? [] : Reflection::fetchClassAttributes( class: $info->definition->reflection, @@ -92,22 +96,39 @@ class: $info->definition->reflection, flags: \ReflectionAttribute::IS_INSTANCEOF, ); - if ($attrs === []) { + # Test-level attributes join the case pipeline only when they ask for it explicitly. + $testAttributes = []; + foreach ($info->definition->tests->getTests() as $definition) { + $testAttributes = [...$testAttributes, ...Reflection::fetchFunctionAttributes( + function: $definition->reflection, + attributeClass: CaseInterceptable::class, + flags: \ReflectionAttribute::IS_INSTANCEOF, + )]; + } + + if ($classAttributes === [] && $testAttributes === []) { # No attributes, continue to next interceptor return $next($info); } - $attrs = \array_map( + $instantiate = static fn(array $attrs): array => \array_values(\array_map( static function (\ReflectionAttribute $a): Interceptable { /** @var Interceptable */ return $a->newInstance(); }, $attrs, - ); + )); + $classAttributes = $instantiate($classAttributes); + $testAttributes = $instantiate($testAttributes); # Merge and instantiate attributes - $interceptors = $this->interceptorProvider->fromAttributes(TestCaseRunInterceptor::class, ...$attrs); - $info = $info->withAttributes(self::groupAttributes($attrs)); + $interceptors = $this->interceptorProvider->fromAttributes( + TestCaseRunInterceptor::class, + ...$classAttributes, + ...$testAttributes, + ); + # Only class attributes describe the case itself; test attributes stay on their tests. + $classAttributes === [] or $info = $info->withAttributes(self::groupAttributes($classAttributes)); /** @var callable(CaseInfo): CaseResult $pipeline */ $pipeline = $next instanceof Pipeline diff --git a/tests/Core/Pipeline/AttributesInterceptorTest.php b/tests/Core/Pipeline/AttributesInterceptorTest.php index 1bd61323..0db0eb0f 100644 --- a/tests/Core/Pipeline/AttributesInterceptorTest.php +++ b/tests/Core/Pipeline/AttributesInterceptorTest.php @@ -14,7 +14,9 @@ use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; use Testo\Core\Definition\TestDefinition; +use Testo\Core\Definition\TestDefinitions; use Testo\Core\Value\Status; +use Testo\Pipeline\Attribute\CaseInterceptable; use Testo\Pipeline\Attribute\Interceptable; use Testo\Pipeline\Attribute\FallbackInterceptor; use Testo\Pipeline\Internal\AttributesInterceptor; @@ -317,6 +319,56 @@ public function runTestCasePreparesPipelineAroundClosureNext(): void Assert::same($result->status, Status::Flaky); } + public function runTestCaseRunsCaseInterceptorOfAMethodLevelCaseInterceptableAttribute(): void + { + $caseInfo = $this->makeCaseInfoWithTest(TestWithMethodCaseInterceptableAttribute::class); + + $interceptor = new AttributesInterceptor($this->createInterceptorProvider()); + + $captured = null; + $result = $interceptor->runTestCase($caseInfo, function (CaseInfo $info) use (&$captured): CaseResult { + $captured = $info; + return new CaseResult([], Status::Passed); + }); + + // The case fallback interceptor ran for a method-level attribute: it rewrote the status. + Assert::same($result->status, Status::Flaky); + // A test-level attribute describes its test, not the case: nothing is stamped on the CaseInfo. + Assert::same($captured->attributes, []); + } + + public function runTestCaseIgnoresAMethodLevelInterceptableWithoutCaseOptIn(): void + { + // Same case-interceptor fallback, but the attribute is a plain Interceptable: from a method it + // must stay out of the case pipeline (a per-test attribute must not reconfigure the whole case). + $caseInfo = $this->makeCaseInfoWithTest(TestWithMethodPlainInterceptableCaseAttribute::class); + + $interceptor = new AttributesInterceptor($this->createInterceptorProvider()); + $terminal = new CaseResult([], Status::Passed); + + $captured = null; + $result = $interceptor->runTestCase($caseInfo, function (CaseInfo $info) use (&$captured, $terminal): CaseResult { + $captured = $info; + return $terminal; + }); + + Assert::same($result, $terminal); + Assert::same($captured, $caseInfo); + } + + public function runTestCaseSkipsDeactivatedTestsWhenCollectingCaseInterceptableAttributes(): void + { + $caseInfo = $this->makeCaseInfoWithTest(TestWithMethodCaseInterceptableAttribute::class, active: false); + + $interceptor = new AttributesInterceptor($this->createInterceptorProvider()); + $terminal = new CaseResult([], Status::Passed); + + $result = $interceptor->runTestCase($caseInfo, static fn(CaseInfo $info): CaseResult => $terminal); + + // A filtered-out test is not part of the run, so its attribute must not shape the case. + Assert::same($result, $terminal); + } + public function runTestPreparesPipelineAroundClosureNext(): void { $caseInfo = $this->makeCaseInfo(new \ReflectionClass(TestWithClassInterceptableAttribute::class)); @@ -364,6 +416,25 @@ private function makeTestInfo(string $class, bool $classReflection): TestInfo return new TestInfo('test', $caseInfo, $testDefinition); } + /** + * A case without class attributes whose single test `test` is registered in the definitions. + * + * @param class-string $class + */ + private function makeCaseInfoWithTest(string $class, bool $active = true): CaseInfo + { + $definition = new TestDefinition(new \ReflectionMethod($class, 'test')); + $definition->active = $active; + + return new CaseInfo(new CaseDefinition( + name: 'TestCase', + type: 'unit', + file: Path::create(__FILE__), + reflection: new \ReflectionClass($class), + tests: TestDefinitions::fromArray(test: $definition), + ), new SuiteIdentity('Core/Pipeline')); + } + private function makeCaseInfo(?\ReflectionClass $reflection): CaseInfo { return new CaseInfo(new CaseDefinition( @@ -417,6 +488,18 @@ final class TestWithRepeatedInterceptableAttribute public function test(): void {} } +final class TestWithMethodCaseInterceptableAttribute +{ + #[TestMethodCaseInterceptableAttribute] + public function test(): void {} +} + +final class TestWithMethodPlainInterceptableCaseAttribute +{ + #[TestMethodPlainInterceptableCaseAttribute] + public function test(): void {} +} + #[\Attribute(\Attribute::TARGET_CLASS)] #[FallbackInterceptor(TestTagRunInterceptor::class)] final class TestClassInterceptableAttribute implements Interceptable {} @@ -433,6 +516,14 @@ final class TestCaseInterceptableAttribute implements Interceptable {} #[FallbackInterceptor(TestTagRunInterceptor::class)] final class TestRepeatableInterceptableAttribute implements Interceptable {} +#[\Attribute(\Attribute::TARGET_METHOD)] +#[FallbackInterceptor(TestTagCaseRunInterceptor::class)] +final class TestMethodCaseInterceptableAttribute implements CaseInterceptable {} + +#[\Attribute(\Attribute::TARGET_METHOD)] +#[FallbackInterceptor(TestTagCaseRunInterceptor::class)] +final class TestMethodPlainInterceptableCaseAttribute implements Interceptable {} + /** * Distinguishable effect: tags the result so a real pass-through differs from a no-op. */ From 6421baaddc45d76ab38f1c65eb60cc8ea8007a60 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 16:19:47 +0400 Subject: [PATCH 33/35] feat(skip): move `#[Skip]` into its own `testo/skip` plugin `#[Skip]` is a one-attribute plugin of the same shape as `testo/retry` and `testo/repeat`, and the plugin naming rule puts the top-level class `\Testo\Skip` in a package of the same short name. The manifest starts at 0.0.0 so the first release is 0.1.0. Assisted-By: Claude Fable 5.1 --- .github/.release-please-config.json | 6 +++ .github/workflows/split-publish.yml | 1 + composer.json | 3 ++ .../Feature/FullySkippedCaseFeatureTest.php | 2 +- .../FullySkipped/FullySkippedClassStub.php | 2 +- .../FullySkipped/fully_skipped_functions.php | 2 +- plugin/skip/.github/workflows/close-prs.yml | 14 ++++++ plugin/skip/README.md | 41 +++++++++++++++++ plugin/{test/src => skip}/Skip.php | 4 +- plugin/skip/composer.json | 42 ++++++++++++++++++ .../src/Internal/SkipInterceptor.php | 6 +-- .../Feature/SkipFallbackStandaloneTest.php | 8 ++-- .../tests/Feature/SkipFeatureTest.php | 44 +++++++++---------- .../tests/Feature/SkipSummaryTest.php | 6 +-- .../tests/Stub/PipelineEntrySpyPlugin.php | 2 +- .../tests/Stub/Skip/SkipChildStub.php | 2 +- .../Stub/Skip/SkipClassAndMethodStub.php | 4 +- .../tests/Stub/Skip/SkipClassLevelStub.php | 4 +- .../Stub/Skip/SkipConstructorSpyStub.php | 6 +-- .../tests/Stub/Skip/SkipInFiberStub.php | 6 +-- .../tests/Stub/Skip/SkipMarkerTrait.php | 4 +- .../tests/Stub/Skip/SkipMethodStub.php | 4 +- .../tests/Stub/Skip/SkipNonStaticHookStub.php | 6 +-- .../Skip/SkipOverriddenMethodParentStub.php | 4 +- .../Stub/Skip/SkipOverridingMethodStub.php | 2 +- .../tests/Stub/Skip/SkipParentStub.php | 4 +- .../tests/Stub/Skip/SkipTraitStub.php | 2 +- .../Stub/Skip/SkipWithDataProviderStub.php | 4 +- .../tests/Stub/Skip/SkipWithHooksStub.php | 8 ++-- .../tests/Stub/Skip/SkipWithRepeatStub.php | 6 +-- .../tests/Stub/Skip/SkipWithRetryStub.php | 4 +- .../tests/Stub/Skip/skip_functions.php | 4 +- .../SkipStandalone/StandaloneSkippedTest.php | 4 +- .../Stub/SkipSummary/Mixed/MixedStub.php | 6 +-- .../OnlySkipped/OnlySkippedStub.php | 4 +- .../Unit/Fixture/SkipClassLevelFixture.php | 6 +-- .../Unit/Fixture/SkipMixedMethodsFixture.php | 8 ++-- .../Unit/Internal/SkipInterceptorTest.php | 10 ++--- .../tests/Unit/SkipAttributeTest.php | 6 +-- plugin/skip/tests/suites.php | 22 ++++++++++ plugin/test/README.md | 2 +- plugin/test/src/TestPlugin.php | 8 +--- resources/version.json | 1 + skills/testo-flaky-tests/SKILL.md | 2 +- skills/testo-plugin-author/SKILL.md | 4 +- skills/testo-write-tests/SKILL.md | 6 +-- testo.php | 1 + 47 files changed, 236 insertions(+), 111 deletions(-) create mode 100644 plugin/skip/.github/workflows/close-prs.yml create mode 100644 plugin/skip/README.md rename plugin/{test/src => skip}/Skip.php (98%) create mode 100644 plugin/skip/composer.json rename plugin/{test => skip}/src/Internal/SkipInterceptor.php (99%) rename plugin/{test => skip}/tests/Feature/SkipFallbackStandaloneTest.php (94%) rename plugin/{test => skip}/tests/Feature/SkipFeatureTest.php (92%) rename plugin/{test => skip}/tests/Feature/SkipSummaryTest.php (97%) rename plugin/{test => skip}/tests/Stub/PipelineEntrySpyPlugin.php (98%) rename plugin/{test => skip}/tests/Stub/Skip/SkipChildStub.php (91%) rename plugin/{test => skip}/tests/Stub/Skip/SkipClassAndMethodStub.php (94%) rename plugin/{test => skip}/tests/Stub/Skip/SkipClassLevelStub.php (91%) rename plugin/{test => skip}/tests/Stub/Skip/SkipConstructorSpyStub.php (86%) rename plugin/{test => skip}/tests/Stub/Skip/SkipInFiberStub.php (93%) rename plugin/{test => skip}/tests/Stub/Skip/SkipMarkerTrait.php (78%) rename plugin/{test => skip}/tests/Stub/Skip/SkipMethodStub.php (94%) rename plugin/{test => skip}/tests/Stub/Skip/SkipNonStaticHookStub.php (87%) rename plugin/{test => skip}/tests/Stub/Skip/SkipOverriddenMethodParentStub.php (89%) rename plugin/{test => skip}/tests/Stub/Skip/SkipOverridingMethodStub.php (93%) rename plugin/{test => skip}/tests/Stub/Skip/SkipParentStub.php (81%) rename plugin/{test => skip}/tests/Stub/Skip/SkipTraitStub.php (91%) rename plugin/{test => skip}/tests/Stub/Skip/SkipWithDataProviderStub.php (94%) rename plugin/{test => skip}/tests/Stub/Skip/SkipWithHooksStub.php (89%) rename plugin/{test => skip}/tests/Stub/Skip/SkipWithRepeatStub.php (89%) rename plugin/{test => skip}/tests/Stub/Skip/SkipWithRetryStub.php (96%) rename plugin/{test => skip}/tests/Stub/Skip/skip_functions.php (91%) rename plugin/{test => skip}/tests/Stub/SkipStandalone/StandaloneSkippedTest.php (92%) rename plugin/{test => skip}/tests/Stub/SkipSummary/Mixed/MixedStub.php (90%) rename plugin/{test => skip}/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php (86%) rename plugin/{test => skip}/tests/Unit/Fixture/SkipClassLevelFixture.php (80%) rename plugin/{test => skip}/tests/Unit/Fixture/SkipMixedMethodsFixture.php (80%) rename plugin/{test => skip}/tests/Unit/Internal/SkipInterceptorTest.php (98%) rename plugin/{test => skip}/tests/Unit/SkipAttributeTest.php (95%) create mode 100644 plugin/skip/tests/suites.php diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json index f001082e..2af75de6 100644 --- a/.github/.release-please-config.json +++ b/.github/.release-please-config.json @@ -41,6 +41,12 @@ "include-component-in-tag": true, "changelog-path": "CHANGELOG.md" }, + "plugin/skip": { + "package-name": "testo/skip", + "component": "skip", + "include-component-in-tag": true, + "changelog-path": "CHANGELOG.md" + }, "plugin/test": { "package-name": "testo/test", "component": "test", diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml index d6bdea0d..3adfd878 100644 --- a/.github/workflows/split-publish.yml +++ b/.github/workflows/split-publish.yml @@ -34,6 +34,7 @@ on: # yamllint disable-line rule:truthy - 'lifecycle-[0-9]*' - 'repeat-[0-9]*' - 'retry-[0-9]*' + - 'skip-[0-9]*' - 'test-[0-9]*' name: 📦 Split publish diff --git a/composer.json b/composer.json index 652996fd..88ae398b 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,7 @@ "testo/lifecycle": "^0.1.6", "testo/repeat": "^0.1.9", "testo/retry": "^0.1.5", + "testo/skip": "0.1 - 1", "testo/test": "^0.1.7", "yiisoft/injector": "^1.2" }, @@ -104,6 +105,7 @@ "Tests\\Lifecycle\\": "plugin/lifecycle/tests/", "Tests\\Repeat\\": "plugin/repeat/tests/", "Tests\\Retry\\": "plugin/retry/tests/", + "Tests\\Skip\\": "plugin/skip/tests/", "Tests\\Test\\": "plugin/test/tests/" }, "files": [ @@ -129,6 +131,7 @@ "testo/lifecycle": "0.1.x-dev", "testo/repeat": "0.1.x-dev", "testo/retry": "0.1.x-dev", + "testo/skip": "0.1.x-dev", "testo/test": "0.1.x-dev" } } diff --git a/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php b/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php index 0ef8fe34..a07798ed 100644 --- a/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php +++ b/plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php @@ -16,7 +16,7 @@ /** * End-to-end regression test for {@see LifecycleInterceptor}: the `#[BeforeClass]`/`#[AfterClass]` hooks - * of a case still run when an outer case interceptor — here `#[Skip]` from `testo/test` — leaves + * of a case still run when an outer case interceptor — here `#[Skip]` from `testo/skip` — leaves * the case without a single active test. * * The `#[Skip]` case interceptor deactivates the skipped tests before the {@see LifecycleInterceptor} diff --git a/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php b/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php index 6764ef3e..0839ca2e 100644 --- a/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php +++ b/plugin/lifecycle/tests/Stub/FullySkipped/FullySkippedClassStub.php @@ -7,7 +7,7 @@ use Testo\Lifecycle\AfterClass; use Testo\Lifecycle\BeforeClass; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * Class-based analog of the fully skipped function case in `fully_skipped_functions.php` diff --git a/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php b/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php index 20b991ac..f11d9aaf 100644 --- a/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php +++ b/plugin/lifecycle/tests/Stub/FullySkipped/fully_skipped_functions.php @@ -9,7 +9,7 @@ use Testo\Lifecycle\BeforeClass; use Testo\Lifecycle\BeforeTest; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * A fully skipped function-based case: every `#[Test]` function is under `#[Skip]`. Mirrors diff --git a/plugin/skip/.github/workflows/close-prs.yml b/plugin/skip/.github/workflows/close-prs.yml new file mode 100644 index 00000000..7640d59f --- /dev/null +++ b/plugin/skip/.github/workflows/close-prs.yml @@ -0,0 +1,14 @@ +name: Close PRs + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + close: + uses: php-testo/gh-actions/.github/workflows/close-foreign-prs.yml@v1 + with: + upstream-url: https://github.com/php-testo/testo diff --git a/plugin/skip/README.md b/plugin/skip/README.md new file mode 100644 index 00000000..71413b19 --- /dev/null +++ b/plugin/skip/README.md @@ -0,0 +1,41 @@ +

+ TESTO +

+ +

Skip attribute plugin

+ +
+ +[![Documentation](https://img.shields.io/badge/Documentation-blue?style=for-the-badge&logo=gitbook&logoColor=white)](https://php-testo.github.io) +[![Support on Boosty](https://img.shields.io/static/v1?style=for-the-badge&label=&message=Sponsorship&logo=Boosty&logoColor=white&color=%23F15F2C)](https://boosty.to/roxblnfk) + +
+ +
+ +> [!IMPORTANT] +> ## 🪞 This is a read-only mirror. +> +> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `plugin/skip/`. This repository is **automatically synchronized** from there on every release. +> +> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here. + +## About + +Marks a test, a test class or a test function as skipped without deleting or hiding it. The test is not executed, but stays in every report as Skipped with its reason, so parked tests remain visible until someone returns to them. + +The skip is declared ahead of time, next to the test; skipping at runtime from the test body is covered by the core `SkipTest` exception instead. + +## Install + +```bash +composer require --dev testo/skip +``` + +[![PHP](https://img.shields.io/packagist/php-v/testo/skip.svg?style=flat-square&logo=php)](https://packagist.org/packages/testo/skip) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/testo/skip.svg?style=flat-square&logo=packagist)](https://packagist.org/packages/testo/skip) +[![License](https://img.shields.io/packagist/l/testo/skip.svg?style=flat-square)](https://github.com/php-testo/testo/blob/1.x/LICENSE.md) +[![Total Downloads](https://img.shields.io/packagist/dt/testo/skip.svg?style=flat-square)](https://packagist.org/packages/testo/skip/stats) diff --git a/plugin/test/src/Skip.php b/plugin/skip/Skip.php similarity index 98% rename from plugin/test/src/Skip.php rename to plugin/skip/Skip.php index 16410010..734f7ab0 100644 --- a/plugin/test/src/Skip.php +++ b/plugin/skip/Skip.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Testo\Test; +namespace Testo; use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; use Testo\Pipeline\Attribute\FallbackInterceptor; use Testo\Pipeline\Attribute\Interceptable; -use Testo\Test\Internal\SkipInterceptor; +use Testo\Skip\Internal\SkipInterceptor; /** * Marks a test as skipped without deleting or hiding it. diff --git a/plugin/skip/composer.json b/plugin/skip/composer.json new file mode 100644 index 00000000..0b3ef9c6 --- /dev/null +++ b/plugin/skip/composer.json @@ -0,0 +1,42 @@ +{ + "name": "testo/skip", + "description": "Skip attribute plugin for the Testo testing framework.", + "license": "BSD-3-Clause", + "type": "library", + "keywords": [ + "testo", + "skip", + "test" + ], + "authors": [ + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + } + ], + "funding": [ + { + "type": "boosty", + "url": "https://boosty.to/roxblnfk" + } + ], + "require": { + "php": ">=8.2", + "testo/testo": "0.11 - 1" + }, + "autoload": { + "psr-4": { + "Testo\\Skip\\": "src/" + }, + "files": [ + "Skip.php" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + } +} diff --git a/plugin/test/src/Internal/SkipInterceptor.php b/plugin/skip/src/Internal/SkipInterceptor.php similarity index 99% rename from plugin/test/src/Internal/SkipInterceptor.php rename to plugin/skip/src/Internal/SkipInterceptor.php index 31286c47..31d92094 100644 --- a/plugin/test/src/Internal/SkipInterceptor.php +++ b/plugin/skip/src/Internal/SkipInterceptor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Test\Internal; +namespace Testo\Skip\Internal; use Psr\EventDispatcher\EventDispatcherInterface; use Testo\Common\Reflection; @@ -20,7 +20,7 @@ use Testo\Pipeline\Attribute\InterceptorOptions; use Testo\Pipeline\Middleware\TestCaseRunInterceptor; use Testo\Pipeline\Policy\ConflictPolicy; -use Testo\Test\Skip; +use Testo\Skip; use Testo\Test\TestPlugin; /** @@ -72,7 +72,7 @@ * Never throws for a skipped test — a throw from a case interceptor aborts the whole case. * * @internal - * @psalm-internal Testo\Test + * @psalm-internal Testo\Skip */ #[InterceptorOptions( order: InterceptorOptions::ORDER_DEFAULT, diff --git a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php b/plugin/skip/tests/Feature/SkipFallbackStandaloneTest.php similarity index 94% rename from plugin/test/tests/Feature/SkipFallbackStandaloneTest.php rename to plugin/skip/tests/Feature/SkipFallbackStandaloneTest.php index 3d5c4884..64c0dc6c 100644 --- a/plugin/test/tests/Feature/SkipFallbackStandaloneTest.php +++ b/plugin/skip/tests/Feature/SkipFallbackStandaloneTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Feature; +namespace Tests\Skip\Feature; use Testo\Application\Application; use Testo\Application\Config\ApplicationConfig; @@ -16,10 +16,10 @@ use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; use Testo\Test; -use Testo\Test\Internal\SkipInterceptor; -use Testo\Test\Skip; +use Testo\Skip\Internal\SkipInterceptor; +use Testo\Skip; use Testo\Test\TestPlugin; -use Tests\Test\Stub\SkipStandalone\StandaloneSkippedTest; +use Tests\Skip\Stub\SkipStandalone\StandaloneSkippedTest; /** * The standalone contract of `#[Skip]`: with `TestPlugin` not registered, the attribute's diff --git a/plugin/test/tests/Feature/SkipFeatureTest.php b/plugin/skip/tests/Feature/SkipFeatureTest.php similarity index 92% rename from plugin/test/tests/Feature/SkipFeatureTest.php rename to plugin/skip/tests/Feature/SkipFeatureTest.php index 32abdfed..d6feb6c7 100644 --- a/plugin/test/tests/Feature/SkipFeatureTest.php +++ b/plugin/skip/tests/Feature/SkipFeatureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Feature; +namespace Tests\Skip\Feature; use Testo\Assert; use Testo\Codecov\Covers; @@ -11,24 +11,24 @@ use Testo\Data\MultipleResult; use Testo\Filter\Group; use Testo\Test; -use Testo\Test\Internal\SkipInterceptor; -use Testo\Test\Skip; +use Testo\Skip\Internal\SkipInterceptor; +use Testo\Skip; use Testo\Testing\Attribute\TestingSuite; use Testo\Testing\Helper\TestRunner; -use Tests\Test\Stub\PipelineEntrySpyPlugin; -use Tests\Test\Stub\Skip\SkipChildStub; -use Tests\Test\Stub\Skip\SkipClassAndMethodStub; -use Tests\Test\Stub\Skip\SkipClassLevelStub; -use Tests\Test\Stub\Skip\SkipConstructorSpyStub; -use Tests\Test\Stub\Skip\SkipInFiberStub; -use Tests\Test\Stub\Skip\SkipMethodStub; -use Tests\Test\Stub\Skip\SkipNonStaticHookStub; -use Tests\Test\Stub\Skip\SkipOverridingMethodStub; -use Tests\Test\Stub\Skip\SkipTraitStub; -use Tests\Test\Stub\Skip\SkipWithDataProviderStub; -use Tests\Test\Stub\Skip\SkipWithHooksStub; -use Tests\Test\Stub\Skip\SkipWithRepeatStub; -use Tests\Test\Stub\Skip\SkipWithRetryStub; +use Tests\Skip\Stub\PipelineEntrySpyPlugin; +use Tests\Skip\Stub\Skip\SkipChildStub; +use Tests\Skip\Stub\Skip\SkipClassAndMethodStub; +use Tests\Skip\Stub\Skip\SkipClassLevelStub; +use Tests\Skip\Stub\Skip\SkipConstructorSpyStub; +use Tests\Skip\Stub\Skip\SkipInFiberStub; +use Tests\Skip\Stub\Skip\SkipMethodStub; +use Tests\Skip\Stub\Skip\SkipNonStaticHookStub; +use Tests\Skip\Stub\Skip\SkipOverridingMethodStub; +use Tests\Skip\Stub\Skip\SkipTraitStub; +use Tests\Skip\Stub\Skip\SkipWithDataProviderStub; +use Tests\Skip\Stub\Skip\SkipWithHooksStub; +use Tests\Skip\Stub\Skip\SkipWithRepeatStub; +use Tests\Skip\Stub\Skip\SkipWithRetryStub; /** * End-to-end checks that {@see SkipInterceptor}, registered by {@see \Testo\Test\TestPlugin}, @@ -123,12 +123,12 @@ public function emptyMethodReasonStillWinsOverClassReason(): void public function functionalTestUsesFunctionFqnInMessage(): void { - $result = TestRunner::runTest('Tests\Test\Stub\Skip\skippedFunction'); + $result = TestRunner::runTest('Tests\Skip\Stub\Skip\skippedFunction'); Assert::same($result->status, Status::Skipped); Assert::same( $result->failure?->getMessage(), - 'Tests\Test\Stub\Skip\skippedFunction is skipped via #[Skip] ==> functional test is skipped', + 'Tests\Skip\Stub\Skip\skippedFunction is skipped via #[Skip] ==> functional test is skipped', ); } @@ -139,7 +139,7 @@ public function functionalTestUsesFunctionFqnInMessage(): void */ public function controlNeighborFunctionNextToSkippedFunctionStillRuns(): void { - $result = TestRunner::runTest('Tests\Test\Stub\Skip\enabledFunction'); + $result = TestRunner::runTest('Tests\Skip\Stub\Skip\enabledFunction'); Assert::same($result->status, Status::Passed); } @@ -288,7 +288,7 @@ public function skippedTestsNeverEnterThePerTestPipeline(): void $entered = \array_slice(PipelineEntrySpyPlugin::$entered, $offset); Assert::array($entered) ->contains(SkipMethodStub::class . '::enabled') - ->contains('Tests\Test\Stub\Skip\enabledFunction') + ->contains('Tests\Skip\Stub\Skip\enabledFunction') ->notContains(SkipMethodStub::class . '::skipped') ->notContains(SkipMethodStub::class . '::skippedNoReason') ->notContains(SkipWithHooksStub::class . '::skipped') @@ -297,7 +297,7 @@ public function skippedTestsNeverEnterThePerTestPipeline(): void ->notContains(SkipWithRepeatStub::class . '::skipped') ->notContains(SkipInFiberStub::class . '::skipped') ->notContains(SkipOverridingMethodStub::class . '::skipped') - ->notContains('Tests\Test\Stub\Skip\skippedFunction'); + ->notContains('Tests\Skip\Stub\Skip\skippedFunction'); } /** diff --git a/plugin/test/tests/Feature/SkipSummaryTest.php b/plugin/skip/tests/Feature/SkipSummaryTest.php similarity index 97% rename from plugin/test/tests/Feature/SkipSummaryTest.php rename to plugin/skip/tests/Feature/SkipSummaryTest.php index c22dc335..5d2a6978 100644 --- a/plugin/test/tests/Feature/SkipSummaryTest.php +++ b/plugin/skip/tests/Feature/SkipSummaryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Feature; +namespace Tests\Skip\Feature; use Testo\Application\Application; use Testo\Application\Config\ApplicationConfig; @@ -14,8 +14,8 @@ use Testo\Core\Context\TestResult; use Testo\Core\Value\Status; use Testo\Test; -use Testo\Test\Internal\SkipInterceptor; -use Testo\Test\Skip; +use Testo\Skip\Internal\SkipInterceptor; +use Testo\Skip; /** * Session-level arithmetic for {@see Skip}-marked tests: they are counted in the run's diff --git a/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php b/plugin/skip/tests/Stub/PipelineEntrySpyPlugin.php similarity index 98% rename from plugin/test/tests/Stub/PipelineEntrySpyPlugin.php rename to plugin/skip/tests/Stub/PipelineEntrySpyPlugin.php index e75ca16f..2b493ef5 100644 --- a/plugin/test/tests/Stub/PipelineEntrySpyPlugin.php +++ b/plugin/skip/tests/Stub/PipelineEntrySpyPlugin.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Stub; +namespace Tests\Skip\Stub; use Internal\Container\Container; use Testo\Common\PluginConfigurator; diff --git a/plugin/test/tests/Stub/Skip/SkipChildStub.php b/plugin/skip/tests/Stub/Skip/SkipChildStub.php similarity index 91% rename from plugin/test/tests/Stub/Skip/SkipChildStub.php rename to plugin/skip/tests/Stub/Skip/SkipChildStub.php index f4151d12..7d2de11a 100644 --- a/plugin/test/tests/Stub/Skip/SkipChildStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipChildStub.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Test; diff --git a/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php b/plugin/skip/tests/Stub/Skip/SkipClassAndMethodStub.php similarity index 94% rename from plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php rename to plugin/skip/tests/Stub/Skip/SkipClassAndMethodStub.php index b06f2583..5b2c446c 100644 --- a/plugin/test/tests/Stub/Skip/SkipClassAndMethodStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipClassAndMethodStub.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * Stub for verifying which {@see Skip} reason a test is skipped with: a method-level attribute diff --git a/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php b/plugin/skip/tests/Stub/Skip/SkipClassLevelStub.php similarity index 91% rename from plugin/test/tests/Stub/Skip/SkipClassLevelStub.php rename to plugin/skip/tests/Stub/Skip/SkipClassLevelStub.php index c2974176..8abfa8ee 100644 --- a/plugin/test/tests/Stub/Skip/SkipClassLevelStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipClassLevelStub.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * A class-level `#[Skip]`: both tests of the case are skipped with the class reason, proving the diff --git a/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php b/plugin/skip/tests/Stub/Skip/SkipConstructorSpyStub.php similarity index 86% rename from plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php rename to plugin/skip/tests/Stub/Skip/SkipConstructorSpyStub.php index 7f6cd022..99dfbd4f 100644 --- a/plugin/test/tests/Stub/Skip/SkipConstructorSpyStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipConstructorSpyStub.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * Only skipped tests and no class-level hooks: the class must never be instantiated. * * The flag is a one-way latch — nothing resets it, so - * {@see \Tests\Test\Feature\SkipFeatureTest::fullySkippedCaseWithoutHooksIsNeverInstantiated()} + * {@see \Tests\Skip\Feature\SkipFeatureTest::fullySkippedCaseWithoutHooksIsNeverInstantiated()} * asserts it absolutely, not as a delta. */ #[Test] diff --git a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php b/plugin/skip/tests/Stub/Skip/SkipInFiberStub.php similarity index 93% rename from plugin/test/tests/Stub/Skip/SkipInFiberStub.php rename to plugin/skip/tests/Stub/Skip/SkipInFiberStub.php index fb2774f9..f0b32ed0 100644 --- a/plugin/test/tests/Stub/Skip/SkipInFiberStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipInFiberStub.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Assert; use Testo\Fiber\RunInFiber; use Testo\Fiber\Schedule; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * A class-level `#[RunInFiber]` ({@see Schedule::RoundRobin}) installs a fiber batch runner on the @@ -18,7 +18,7 @@ * `\Fiber::suspend()` outside a fiber would throw and the log would stop short. * * Driven through {@see \Testo\Testing\Helper\TestRunner} by the Feature suite; - * {@see \Tests\Test\Feature\SkipFeatureTest::fiberBatchRunnerSurvivesTheWrap()} asserts the + * {@see \Tests\Skip\Feature\SkipFeatureTest::fiberBatchRunnerSurvivesTheWrap()} asserts the * interleaving. The log accumulates across directory runs — this stub's tests and the feature * test assert the tail written by their own run. */ diff --git a/plugin/test/tests/Stub/Skip/SkipMarkerTrait.php b/plugin/skip/tests/Stub/Skip/SkipMarkerTrait.php similarity index 78% rename from plugin/test/tests/Stub/Skip/SkipMarkerTrait.php rename to plugin/skip/tests/Stub/Skip/SkipMarkerTrait.php index 1a80ee79..a373127c 100644 --- a/plugin/test/tests/Stub/Skip/SkipMarkerTrait.php +++ b/plugin/skip/tests/Stub/Skip/SkipMarkerTrait.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; -use Testo\Test\Skip; +use Testo\Skip; /** * Carries a class-level `#[Skip]` for {@see SkipTraitStub} to use — nothing else. diff --git a/plugin/test/tests/Stub/Skip/SkipMethodStub.php b/plugin/skip/tests/Stub/Skip/SkipMethodStub.php similarity index 94% rename from plugin/test/tests/Stub/Skip/SkipMethodStub.php rename to plugin/skip/tests/Stub/Skip/SkipMethodStub.php index 1c90aacd..3333eaca 100644 --- a/plugin/test/tests/Stub/Skip/SkipMethodStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipMethodStub.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Assert; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * Method-level `#[Skip]`, with and without a reason: only the marked tests of the case are diff --git a/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php b/plugin/skip/tests/Stub/Skip/SkipNonStaticHookStub.php similarity index 87% rename from plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php rename to plugin/skip/tests/Stub/Skip/SkipNonStaticHookStub.php index afa723fc..cba7d29f 100644 --- a/plugin/test/tests/Stub/Skip/SkipNonStaticHookStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipNonStaticHookStub.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Lifecycle\BeforeClass; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * Documented caveat: a non-static class-level hook forces construction even when every @@ -14,7 +14,7 @@ * conscious one, not an accident. * * The construction counter accumulates across directory runs — - * {@see \Tests\Test\Feature\SkipFeatureTest::nonStaticClassHookStillBuildsTheClass()} asserts the delta. + * {@see \Tests\Skip\Feature\SkipFeatureTest::nonStaticClassHookStillBuildsTheClass()} asserts the delta. */ #[Test] #[Skip('fully skipped, but the non-static hook builds the class')] diff --git a/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php b/plugin/skip/tests/Stub/Skip/SkipOverriddenMethodParentStub.php similarity index 89% rename from plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php rename to plugin/skip/tests/Stub/Skip/SkipOverriddenMethodParentStub.php index 7aa017ad..da6a93fe 100644 --- a/plugin/test/tests/Stub/Skip/SkipOverriddenMethodParentStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipOverriddenMethodParentStub.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; -use Testo\Test\Skip; +use Testo\Skip; /** * Abstract, so the locator never discovers it as its own case — the method-level `#[Skip]` diff --git a/plugin/test/tests/Stub/Skip/SkipOverridingMethodStub.php b/plugin/skip/tests/Stub/Skip/SkipOverridingMethodStub.php similarity index 93% rename from plugin/test/tests/Stub/Skip/SkipOverridingMethodStub.php rename to plugin/skip/tests/Stub/Skip/SkipOverridingMethodStub.php index 1daee184..cf41e1fb 100644 --- a/plugin/test/tests/Stub/Skip/SkipOverridingMethodStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipOverridingMethodStub.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Test; diff --git a/plugin/test/tests/Stub/Skip/SkipParentStub.php b/plugin/skip/tests/Stub/Skip/SkipParentStub.php similarity index 81% rename from plugin/test/tests/Stub/Skip/SkipParentStub.php rename to plugin/skip/tests/Stub/Skip/SkipParentStub.php index f4c29ef6..cbd72f91 100644 --- a/plugin/test/tests/Stub/Skip/SkipParentStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipParentStub.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; -use Testo\Test\Skip; +use Testo\Skip; /** * Abstract, so the locator never discovers it as its own case — only the child inherits diff --git a/plugin/test/tests/Stub/Skip/SkipTraitStub.php b/plugin/skip/tests/Stub/Skip/SkipTraitStub.php similarity index 91% rename from plugin/test/tests/Stub/Skip/SkipTraitStub.php rename to plugin/skip/tests/Stub/Skip/SkipTraitStub.php index ff56a1a1..b85db841 100644 --- a/plugin/test/tests/Stub/Skip/SkipTraitStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipTraitStub.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Test; diff --git a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php b/plugin/skip/tests/Stub/Skip/SkipWithDataProviderStub.php similarity index 94% rename from plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php rename to plugin/skip/tests/Stub/Skip/SkipWithDataProviderStub.php index e08785cd..7d64d346 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithDataProviderStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipWithDataProviderStub.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Data\DataProvider; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * Stub with a data-driven test skipped by {@see Skip}: diff --git a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php b/plugin/skip/tests/Stub/Skip/SkipWithHooksStub.php similarity index 89% rename from plugin/test/tests/Stub/Skip/SkipWithHooksStub.php rename to plugin/skip/tests/Stub/Skip/SkipWithHooksStub.php index 0315b4bb..ebda9cb3 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithHooksStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipWithHooksStub.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Assert; use Testo\Lifecycle\AfterClass; @@ -10,14 +10,14 @@ use Testo\Lifecycle\BeforeClass; use Testo\Lifecycle\BeforeTest; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * Stub for verifying that a `#[Skip]`-marked test never reaches the per-test pipeline: - * {@see \Testo\Test\Internal\SkipInterceptor} deactivates it before the case's hooks and remaining + * {@see \Testo\Skip\Internal\SkipInterceptor} deactivates it before the case's hooks and remaining * tests run, so the `#[BeforeClass]`/`#[AfterClass]` hooks still fire once per case run while * `#[BeforeTest]`/`#[AfterTest]` fire for the enabled control test {@see enabled()} alone. - * Driven by {@see \Tests\Test\Feature\SkipFeatureTest::classHooksRunButTestHooksDoNot()}. + * Driven by {@see \Tests\Skip\Feature\SkipFeatureTest::classHooksRunButTestHooksDoNot()}. * * Static hook counters accumulate across directory runs — feature tests assert deltas. */ diff --git a/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php b/plugin/skip/tests/Stub/Skip/SkipWithRepeatStub.php similarity index 89% rename from plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php rename to plugin/skip/tests/Stub/Skip/SkipWithRepeatStub.php index d78a7077..9f6e7e68 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRepeatStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipWithRepeatStub.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Repeat; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * `#[Skip]` on a test that also carries `#[Repeat]`: the repeat is resolved in the per-test * pipeline, which a skipped test never enters, so the body must not run at all. The latch is - * never reset — {@see \Tests\Test\Feature\SkipFeatureTest::repeatDoesNotEngageForSkippedTest()} + * never reset — {@see \Tests\Skip\Feature\SkipFeatureTest::repeatDoesNotEngageForSkippedTest()} * asserts it absolutely, not as a delta. The enabled neighbor carries the same attribute and * counts its runs: three per run prove the repeat is live in this suite. */ diff --git a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php b/plugin/skip/tests/Stub/Skip/SkipWithRetryStub.php similarity index 96% rename from plugin/test/tests/Stub/Skip/SkipWithRetryStub.php rename to plugin/skip/tests/Stub/Skip/SkipWithRetryStub.php index ca88e748..a655a33b 100644 --- a/plugin/test/tests/Stub/Skip/SkipWithRetryStub.php +++ b/plugin/skip/tests/Stub/Skip/SkipWithRetryStub.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Retry; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * `#[Skip]` composed with `#[Retry]`: the retry policy is resolved in the per-test pipeline, which diff --git a/plugin/test/tests/Stub/Skip/skip_functions.php b/plugin/skip/tests/Stub/Skip/skip_functions.php similarity index 91% rename from plugin/test/tests/Stub/Skip/skip_functions.php rename to plugin/skip/tests/Stub/Skip/skip_functions.php index fe87452e..083c59bd 100644 --- a/plugin/test/tests/Stub/Skip/skip_functions.php +++ b/plugin/skip/tests/Stub/Skip/skip_functions.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Tests\Test\Stub\Skip; +namespace Tests\Skip\Stub\Skip; use Testo\Assert; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; # Proves #[Skip] reaches a function-based case as well: the test is reported as Skipped and its # message is built from the function FQN. diff --git a/plugin/test/tests/Stub/SkipStandalone/StandaloneSkippedTest.php b/plugin/skip/tests/Stub/SkipStandalone/StandaloneSkippedTest.php similarity index 92% rename from plugin/test/tests/Stub/SkipStandalone/StandaloneSkippedTest.php rename to plugin/skip/tests/Stub/SkipStandalone/StandaloneSkippedTest.php index b4a9f654..a256b60d 100644 --- a/plugin/test/tests/Stub/SkipStandalone/StandaloneSkippedTest.php +++ b/plugin/skip/tests/Stub/SkipStandalone/StandaloneSkippedTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Test\Stub\SkipStandalone; +namespace Tests\Skip\Stub\SkipStandalone; -use Testo\Test\Skip; +use Testo\Skip; /** * The case of the standalone-fallback run: discovered by naming convention alone (no diff --git a/plugin/test/tests/Stub/SkipSummary/Mixed/MixedStub.php b/plugin/skip/tests/Stub/SkipSummary/Mixed/MixedStub.php similarity index 90% rename from plugin/test/tests/Stub/SkipSummary/Mixed/MixedStub.php rename to plugin/skip/tests/Stub/SkipSummary/Mixed/MixedStub.php index d5d53c5f..e1b62562 100644 --- a/plugin/test/tests/Stub/SkipSummary/Mixed/MixedStub.php +++ b/plugin/skip/tests/Stub/SkipSummary/Mixed/MixedStub.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Tests\Test\Stub\SkipSummary\Mixed; +namespace Tests\Skip\Stub\SkipSummary\Mixed; use Testo\Assert; use Testo\Data\DataProvider; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * One directory with a passing, a failing and two skipped tests, so the summary arithmetic * `total = passed + failed + skipped` can be pinned. Kept out of the shared `Stub/Skip` directory: - * {@see \Tests\Test\Feature\SkipSummaryTest} asserts exact per-status counts, so the set of + * {@see \Tests\Skip\Feature\SkipSummaryTest} asserts exact per-status counts, so the set of * outcomes here has to stay closed. */ #[Test] diff --git a/plugin/test/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php b/plugin/skip/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php similarity index 86% rename from plugin/test/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php rename to plugin/skip/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php index fbc8775b..b979159f 100644 --- a/plugin/test/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php +++ b/plugin/skip/tests/Stub/SkipSummary/OnlySkipped/OnlySkippedStub.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Tests\Test\Stub\SkipSummary\OnlySkipped; +namespace Tests\Skip\Stub\SkipSummary\OnlySkipped; use Testo\Test; -use Testo\Test\Skip; +use Testo\Skip; /** * A case consisting of skipped tests only: such a run must be a success (exit 0). diff --git a/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php b/plugin/skip/tests/Unit/Fixture/SkipClassLevelFixture.php similarity index 80% rename from plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php rename to plugin/skip/tests/Unit/Fixture/SkipClassLevelFixture.php index 65c26eef..972286d3 100644 --- a/plugin/test/tests/Unit/Fixture/SkipClassLevelFixture.php +++ b/plugin/skip/tests/Unit/Fixture/SkipClassLevelFixture.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Tests\Test\Unit\Fixture; +namespace Tests\Skip\Unit\Fixture; -use Testo\Test\Skip; +use Testo\Skip; /** * Fixture with a class-level `#[Skip]` and method-level overrides. * - * Used by {@see \Tests\Test\Unit\Internal\SkipInterceptorTest}: a class-level `#[Skip]` + * Used by {@see \Tests\Skip\Unit\Internal\SkipInterceptorTest}: a class-level `#[Skip]` * skips every test; a method-level `#[Skip]` wins over the class-level one, reason included — * also when its own reason is empty. */ diff --git a/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php b/plugin/skip/tests/Unit/Fixture/SkipMixedMethodsFixture.php similarity index 80% rename from plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php rename to plugin/skip/tests/Unit/Fixture/SkipMixedMethodsFixture.php index 44cc0dc5..5b417154 100644 --- a/plugin/test/tests/Unit/Fixture/SkipMixedMethodsFixture.php +++ b/plugin/skip/tests/Unit/Fixture/SkipMixedMethodsFixture.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Tests\Test\Unit\Fixture; +namespace Tests\Skip\Unit\Fixture; -use Testo\Test\Skip; +use Testo\Skip; /** * Fixture mixing skipped and enabled tests. * - * Used by {@see \Tests\Test\Unit\Internal\SkipInterceptorTest}: one test is skipped with a reason, + * Used by {@see \Tests\Skip\Unit\Internal\SkipInterceptorTest}: one test is skipped with a reason, * one without a reason, and one stays enabled to show what the interceptor leaves alone. The - * PHPDoc summary of the skipped test is the description {@see \Testo\Test\Internal\SkipInterceptor} + * PHPDoc summary of the skipped test is the description {@see \Testo\Skip\Internal\SkipInterceptor} * copies into the synthetic result. */ final class SkipMixedMethodsFixture diff --git a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php b/plugin/skip/tests/Unit/Internal/SkipInterceptorTest.php similarity index 98% rename from plugin/test/tests/Unit/Internal/SkipInterceptorTest.php rename to plugin/skip/tests/Unit/Internal/SkipInterceptorTest.php index 56429afb..6689d4bc 100644 --- a/plugin/test/tests/Unit/Internal/SkipInterceptorTest.php +++ b/plugin/skip/tests/Unit/Internal/SkipInterceptorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Test\Unit\Internal; +namespace Tests\Skip\Unit\Internal; use Internal\Path; use Psr\EventDispatcher\EventDispatcherInterface; @@ -25,10 +25,10 @@ use Testo\Pipeline\Attribute\InterceptorOptions; use Testo\Pipeline\Policy\ConflictPolicy; use Testo\Test; -use Testo\Test\Internal\SkipInterceptor; -use Testo\Test\Skip; -use Tests\Test\Unit\Fixture\SkipClassLevelFixture; -use Tests\Test\Unit\Fixture\SkipMixedMethodsFixture; +use Testo\Skip\Internal\SkipInterceptor; +use Testo\Skip; +use Tests\Skip\Unit\Fixture\SkipClassLevelFixture; +use Tests\Skip\Unit\Fixture\SkipMixedMethodsFixture; /** * @see SkipInterceptor diff --git a/plugin/test/tests/Unit/SkipAttributeTest.php b/plugin/skip/tests/Unit/SkipAttributeTest.php similarity index 95% rename from plugin/test/tests/Unit/SkipAttributeTest.php rename to plugin/skip/tests/Unit/SkipAttributeTest.php index ab2a5e9f..4a281e7d 100644 --- a/plugin/test/tests/Unit/SkipAttributeTest.php +++ b/plugin/skip/tests/Unit/SkipAttributeTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Tests\Test\Unit; +namespace Tests\Skip\Unit; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Pipeline\Attribute\FallbackInterceptor; use Testo\Pipeline\Attribute\Interceptable; use Testo\Test; -use Testo\Test\Internal\SkipInterceptor; -use Testo\Test\Skip; +use Testo\Skip\Internal\SkipInterceptor; +use Testo\Skip; /** * @see Skip diff --git a/plugin/skip/tests/suites.php b/plugin/skip/tests/suites.php new file mode 100644 index 00000000..63b4cded --- /dev/null +++ b/plugin/skip/tests/suites.php @@ -0,0 +1,22 @@ +get(InterceptorCollector::class); - $collector->addInterceptor(new TestoAttributesLocatorInterceptor()); - $collector->addInterceptor(SkipInterceptor::class); + $container->get(InterceptorCollector::class)->addInterceptor(new TestoAttributesLocatorInterceptor()); } } diff --git a/resources/version.json b/resources/version.json index 09806265..90453bc6 100644 --- a/resources/version.json +++ b/resources/version.json @@ -13,6 +13,7 @@ "plugin/codecov": "0.2.1", "plugin/facade": "0.1.1", "plugin/fiber": "0.1.3", + "plugin/skip": "0.0.0", "bridge/symfony-console": "0.1.12", "bridge/infection": "0.1.8", "bridge/mockery": "0.1.2", diff --git a/skills/testo-flaky-tests/SKILL.md b/skills/testo-flaky-tests/SKILL.md index 453d8340..933f4735 100644 --- a/skills/testo-flaky-tests/SKILL.md +++ b/skills/testo-flaky-tests/SKILL.md @@ -88,7 +88,7 @@ Don't ship `#[Repeat(times: 50)]` long-term on a fast suite — CI cost adds up. 3. Is the flakiness from shared state inside the suite (ordering)? - Don't reach for either attribute. Fix isolation (lifecycle hooks, fresh fixtures). 4. Is the root cause known but not fixable now (the test has to leave the run for a while)? - - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Test\Skip`) — the test stops running but stays counted in reports as Skipped (full contract in the `testo-write-tests` skill). `#[Retry]` is for stabilizing, not skipping. + - `#[Skip('flaky on CI, see ISSUE-123')]` (`Testo\Skip`, package `testo/skip`) — the test stops running but stays counted in reports as Skipped (full contract in the `testo-write-tests` skill). `#[Retry]` is for stabilizing, not skipping. ## Pitfalls diff --git a/skills/testo-plugin-author/SKILL.md b/skills/testo-plugin-author/SKILL.md index 95acce94..033e07c3 100644 --- a/skills/testo-plugin-author/SKILL.md +++ b/skills/testo-plugin-author/SKILL.md @@ -188,8 +188,8 @@ its `#[BeforeClass]`/`#[AfterClass]` hooks and every test it still had to run. S never render its line, and stamp `summary: Summary::forTest(Status::Skipped)` on it — a result that never passes through the test runner is not counted for you. -The shipped implementation of exactly this shape is `Testo\Test\Internal\SkipInterceptor` in -`plugin/test`, serving the `#[Skip]` attribute (whose contract is in the `testo-write-tests` skill). +The shipped implementation of exactly this shape is `Testo\Skip\Internal\SkipInterceptor` in +`plugin/skip`, serving the `#[Skip]` attribute (whose contract is in the `testo-write-tests` skill). Read it as a reference — it is `@internal` (and `final`), don't import it. ## Container scopes — provision per-case / per-suite resources diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index b0014592..181ede52 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -138,13 +138,13 @@ Constraints: ## Skipping a test with #[Skip] -To skip a test declaratively — without running any of its code — put `Testo\Test\Skip` (from the -`testo/test` plugin, the same package as `#[Test]`) on the test method (inherited by an overriding +To skip a test declaratively — without running any of its code — put `Testo\Skip` (from the +`testo/skip` plugin) on the test method (inherited by an overriding method that does not repeat it), the class (skips every test of the case; inherited from parents and traits, a method-level reason wins), or a free function: ```php -use Testo\Test\Skip; +use Testo\Skip; #[Test] #[Skip('broken by the pricing rework, see ISSUE-123')] diff --git a/testo.php b/testo.php index 0ad33f9b..989d63cf 100644 --- a/testo.php +++ b/testo.php @@ -67,6 +67,7 @@ require 'plugin/lifecycle/tests/suites.php', require 'plugin/repeat/tests/suites.php', require 'plugin/retry/tests/suites.php', + require 'plugin/skip/tests/suites.php', require 'plugin/test/tests/suites.php', require 'tests/Testo/suites.php', require 'tests/Application/suites.php', From ff59c7321ff1553d02e802c8753c7f6ef634cd33 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 16:19:55 +0400 Subject: [PATCH 34/35] docs(skip): drop the `TestPlugin` wiring and the issue reference from the `#[Skip]` docblocks Assisted-By: Claude Fable 5.1 --- plugin/skip/src/Internal/SkipInterceptor.php | 84 +++++-------------- plugin/skip/tests/Feature/SkipFeatureTest.php | 2 +- plugin/skip/tests/Feature/SkipSummaryTest.php | 7 +- .../Unit/Internal/SkipInterceptorTest.php | 2 +- 4 files changed, 25 insertions(+), 70 deletions(-) diff --git a/plugin/skip/src/Internal/SkipInterceptor.php b/plugin/skip/src/Internal/SkipInterceptor.php index 31d92094..0e69f877 100644 --- a/plugin/skip/src/Internal/SkipInterceptor.php +++ b/plugin/skip/src/Internal/SkipInterceptor.php @@ -21,53 +21,22 @@ use Testo\Pipeline\Middleware\TestCaseRunInterceptor; use Testo\Pipeline\Policy\ConflictPolicy; use Testo\Skip; -use Testo\Test\TestPlugin; /** * Reports {@see Skip}-marked tests as skipped without running them. * - * A case-level interceptor (registered by {@see TestPlugin}, also the attribute's fallback): - * it deactivates every `#[Skip]`-marked test of the case before handing the case on — so lifecycle hooks - * and the per-test pipeline never see them ({@see \Testo\Core\Definition\TestDefinitions::getTests()} - * yields only the active tests) — and appends a synthetic {@see Status::Skipped} result for each - * through the case's batch runner ({@see CaseInfo::withBatchRunner}). A runner already installed - * by an outer interceptor (e.g. testo/fiber's) is wrapped, never replaced. Every synthetic result - * is announced with the {@see TestPipelineStarting}/{@see TestPipelineFinished} pair, so reporters - * render the skipped lines inside the case block; the core aggregates the case as usual. + * Deactivates the marked tests before the case runs, so lifecycle hooks and the per-test pipeline + * never see them, and appends a synthetic {@see Status::Skipped} result for each through the case's + * batch runner. The skipped tests are handled at run time rather than at location, because a case + * left without active tests is dropped by the suite factory together with its class-level hooks. * - * The case level, the cut-off before the hooks and the delivery after the pipeline handler are - * the design settled in issue #313. + * Ordering: outer to the lifecycle interceptor, so the cut-off precedes `#[BeforeClass]`; inner to + * the fiber interceptor, so a fiber batch runner is already on the case and gets wrapped. + * `testType: TestType::Test` keeps `#[Bench]` and `#[TestInline]` cases out — a `#[Skip]` there is inert. * - * Deactivation happens while the case runs, not while it is located, because a case whose active - * test set is empty does not survive location: {@see \Testo\Application\Internal\SuiteFactory::create()} - * drops it and returns `null` for a suite left without cases. A fully skipped case would - * disappear that way, taking its `#[BeforeClass]`/`#[AfterClass]` hooks and the only place to - * deliver its results with it. By run time the case and its {@see CaseInfo} already exist, which - * makes this the one stage where the contract holds. - * - * Ordering: {@see InterceptorOptions::ORDER_DEFAULT} sits outer to the lifecycle interceptor - * (so filtering happens before `#[BeforeClass]`) and inner to the fiber interceptor (so a - * fiber batch runner is already on the case and gets wrapped). - * - * `testType: TestType::Test` keeps the interceptor off `#[Bench]` and `#[TestInline]` cases: their - * finders (`BenchFinder`, `InlineFinder`) define the case with `prefill: false`, so it holds nothing - * but their own members — a `#[Skip]` on one of them is inert, see {@see Skip}. - * - * Two deliberate consequences of delivering results this way: - * - * - The {@see \Testo\Event\Test\TestStarting}/{@see \Testo\Event\Test\TestFinished} pair is not - * emitted. Those announce a test body that begins and ends, and a skipped test has none; the - * TeamCity reporter covers the gap itself, emitting `testStarted` from - * {@see \Testo\Output\Teamcity\TeamcityPlugin::onTestPipelineFinished()} when the body never - * ran. - * - Installing a batch runner takes the case off the core's inline path, which runs each test - * "without a runner/handler call frame so the stack stays shallow for deeply-recursive tests" - * ({@see \Testo\Application\Internal\Runner\CaseRunner::run()}). One `#[Skip]` in a case moves - * its remaining tests onto a handler frame. - * - * The flag is flipped once on the case's shared {@see \Testo\Core\Definition\TestDefinition}s, so a - * second `runTestCase()` over the same {@see \Testo\Core\Definition\CaseDefinition} finds no skipped - * tests left to report. + * Only the {@see TestPipelineStarting}/{@see TestPipelineFinished} pair is dispatched for a skipped + * test: `TestStarting`/`TestFinished` announce a test body, and there is none. Installing a batch + * runner also takes the case off the core's shallow-stack inline path. * * Never throws for a skipped test — a throw from a case interceptor aborts the whole case. * @@ -76,17 +45,15 @@ */ #[InterceptorOptions( order: InterceptorOptions::ORDER_DEFAULT, - # A class-level #[Skip] spawns a second instance through the fallback alias, next to the - # one registered by TestPlugin; First collapses the duplicate onto the registered one. + # One instance is spawned per #[Skip] occurrence in the case; a single pass handles them all. onConflict: ConflictPolicy::First, testType: TestType::Test, )] final readonly class SkipInterceptor implements TestCaseRunInterceptor { /** - * Takes no {@see Skip} parameter on purpose: the container also builds the instance for - * the {@see TestPlugin} registration, where no attribute is at hand. The attributes are - * looked up per case in {@see self::findSkipped()} instead. + * Takes no {@see Skip} parameter: the instance is spawned by one of the case's `#[Skip]` + * occurrences, yet has to handle every one of them. */ public function __construct( private EventDispatcherInterface $eventDispatcher, @@ -101,15 +68,12 @@ public function runTestCase(CaseInfo $info, callable $next): CaseResult return $next($info); } - # Deactivated, not discarded — the same way filtering narrows a case - # (FilterInterceptor::locateTestCases()). The core runs only the active tests - # (CaseRunner::run()), so the synthetic results below are their only delivery. + # Deactivated, not discarded: the definitions are shared, and the synthetic results below + # are the only delivery of these tests. foreach ($skipped as [$definition, $_]) { $definition->active = false; } - # The case still runs (class-level hooks, events, the remaining tests): the skipped - # results are appended by the batch runner inside the case window. $inner = $info->batchRunner; return $next($info->withBatchRunner( function (array $handlers) use ($inner, $info, $skipped): array { @@ -128,10 +92,7 @@ function (array $handlers) use ($inner, $info, $skipped): array { /** * `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` when a reason is given. - * The generated part is always present, so reporters that render skip failure messages can - * show that the skip came from `#[Skip]`. The test id is the test's address - * ({@see \Testo\Core\Context\Identity\TestIdentity::fqn()}) — the exact string `--filter` - * takes back. + * The test id is the string `--filter` takes back. */ private static function reason(TestInfo $info, Skip $attribute): string { @@ -141,8 +102,8 @@ private static function reason(TestInfo $info, Skip $attribute): string } /** - * Collects the skipped tests of the case: a method/function-level `#[Skip]` wins over the - * class-level one; the class-level attribute is inherited from parents and traits. + * A method/function-level `#[Skip]` wins over the class-level one; the class-level attribute + * is inherited from parents and traits. * * @return array */ @@ -156,9 +117,8 @@ private function findSkipped(CaseInfo $info): array } $skipped = []; - # Only the case's active tests: a non-test member (a helper, a lifecycle hook) carries no - # skip semantics, and a test already deactivated by a filter is not part of this run — - # reporting it as Skipped would resurrect what --filter/--group threw away. + # Active tests only: a test deactivated by --filter/--group is not part of this run and + # must not resurface as Skipped. foreach ($info->definition->tests->getTests() as $name => $definition) { $attributes = Reflection::fetchFunctionAttributes( $definition->reflection, @@ -173,10 +133,6 @@ private function findSkipped(CaseInfo $info): array return $skipped; } - /** - * Builds the synthetic result for a skipped test and dispatches its pipeline events, so - * reporters that render test lines from those events see the test as any other. - */ private function reportSkipped( CaseInfo $case, string $name, diff --git a/plugin/skip/tests/Feature/SkipFeatureTest.php b/plugin/skip/tests/Feature/SkipFeatureTest.php index d6feb6c7..be53a868 100644 --- a/plugin/skip/tests/Feature/SkipFeatureTest.php +++ b/plugin/skip/tests/Feature/SkipFeatureTest.php @@ -31,7 +31,7 @@ use Tests\Skip\Stub\Skip\SkipWithRetryStub; /** - * End-to-end checks that {@see SkipInterceptor}, registered by {@see \Testo\Test\TestPlugin}, + * End-to-end checks that {@see SkipInterceptor}, wired by the attribute's fallback declaration, * deactivates the `#[Skip]`-marked tests of a case before it runs and delivers them back as * {@see Status::Skipped} results carrying the composed skip message. * diff --git a/plugin/skip/tests/Feature/SkipSummaryTest.php b/plugin/skip/tests/Feature/SkipSummaryTest.php index 5d2a6978..9c193f42 100644 --- a/plugin/skip/tests/Feature/SkipSummaryTest.php +++ b/plugin/skip/tests/Feature/SkipSummaryTest.php @@ -49,10 +49,9 @@ public function skippedTestsAddUpAndFailingNeighborStillFailsTheRun(): void * A run consisting only of {@see Skip}-marked tests is a success: {@see Status::Skipped} * is neither a success nor a failure, so nothing fails the run. * - * The same run pins one result per skipped test. The stub carries a class-level `#[Skip]`, - * so the pipeline spawns a fallback {@see SkipInterceptor} next to the one - * {@see \Testo\Test\TestPlugin} registers; a second delivery would show up here as an - * inflated total and an extra name. + * The same run pins one result per skipped test. Every `#[Skip]` occurrence of the case + * spawns its own {@see SkipInterceptor} through the fallback alias; a second delivery would + * show up here as an inflated total and an extra name. */ public function runOfOnlySkippedTestsIsSuccessfulAndDeliveredOnce(): void { diff --git a/plugin/skip/tests/Unit/Internal/SkipInterceptorTest.php b/plugin/skip/tests/Unit/Internal/SkipInterceptorTest.php index 6689d4bc..3b149f03 100644 --- a/plugin/skip/tests/Unit/Internal/SkipInterceptorTest.php +++ b/plugin/skip/tests/Unit/Internal/SkipInterceptorTest.php @@ -319,7 +319,7 @@ public function declaresTestTypeScopingSkipToPlainTests(): void /** * The rest of the placement contract: `ORDER_DEFAULT` is the slot the class docblock claims * (outer to the lifecycle interceptor, inner to the fiber one), and `ConflictPolicy::First` - * is what collapses the duplicate instance the class-level fallback alias spawns. + * is what collapses the instances the fallback alias spawns per `#[Skip]` occurrence. */ public function declaresOrderAndConflictPolicy(): void { From b9fd5d6160a4f68f6c1247086751174e2ba05244 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 8 Sep 2026 16:19:56 +0400 Subject: [PATCH 35/35] feat(skip): wire `#[Skip]` through `CaseInterceptable` instead of `TestPlugin` docs(skills): point `#[Skip]` guidance at the self-wiring attribute and document `CaseInterceptable` The attribute is wired by its `#[FallbackInterceptor]` alone: as a `CaseInterceptable` it reaches the case pipeline from a method or function too, so no plugin registers anything and the attribute works in any suite. `ConflictPolicy::First` now collapses the instances spawned per `#[Skip]` occurrence. Assisted-By: Claude Fable 5.1 --- plugin/skip/Skip.php | 58 +++++++------------ .../Feature/SkipFallbackStandaloneTest.php | 42 ++++++-------- .../SkipStandalone/StandaloneSkippedTest.php | 23 +++++--- plugin/skip/tests/Unit/SkipAttributeTest.php | 10 ++++ skills/testo-plugin-author/SKILL.md | 10 ++++ skills/testo-write-tests/SKILL.md | 4 +- 6 files changed, 76 insertions(+), 71 deletions(-) diff --git a/plugin/skip/Skip.php b/plugin/skip/Skip.php index 734f7ab0..4af6bde4 100644 --- a/plugin/skip/Skip.php +++ b/plugin/skip/Skip.php @@ -6,19 +6,16 @@ use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; +use Testo\Pipeline\Attribute\CaseInterceptable; use Testo\Pipeline\Attribute\FallbackInterceptor; -use Testo\Pipeline\Attribute\Interceptable; use Testo\Skip\Internal\SkipInterceptor; /** * Marks a test as skipped without deleting or hiding it. * - * The test is not executed, but stays in the results as {@see Status::Skipped}: it is counted - * in the totals and carries its reason in the result's failure message, so skipped tests are - * reviewable instead of silently rotting. Contrast with a group filter (`#[Group('x')]` + - * `--group=!x`), which drops the test from the results entirely. - * - * On a method or function — only that test is skipped: + * The test is not executed, but stays in the results as {@see Status::Skipped}: it is counted in + * the totals and carries its reason in the result's failure message. Contrast with a group filter + * (`#[Group('x')]` + `--group=!x`), which drops the test from the results entirely. * * ``` * #[Test] @@ -31,48 +28,37 @@ * } * ``` * - * On a class — every test of the case is skipped. The attribute is inherited from parent - * classes and traits (like `#[Group]`); a method-level `#[Skip]` wins over the class-level - * one, reason included. - * - * A method-level `#[Skip]` is inherited as well: an overriding method without the attribute is - * skipped with the reason of the method it overrides. + * On a class every test of the case is skipped. The attribute is inherited from parent classes, + * traits and overridden methods; a method-level `#[Skip]` wins over the class-level one, reason + * included. * - * The failure message reads `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` - * when a reason is given. The JUnit, TeamCity and HTML reporters show that message; the - * terminal prints the skipped line without it, and the compact `--json` report counts the - * test in its totals. + * The failure message reads `{testId} is skipped via #[Skip]`, extended with ` ==> {reason}` when + * a reason is given. The JUnit, TeamCity and HTML reporters show it; the terminal does not. * * Runtime contract: * - * - The skipped test never enters the per-test pipeline: `#[BeforeTest]`/`#[AfterTest]` - * hooks, data providers, `#[Retry]`/`#[Repeat]`, fibers and coverage never engage. - * A data-driven test yields a single Skipped entry (providers are not called). - * - `#[BeforeClass]`/`#[AfterClass]` hooks still run — also when every test of the case - * is skipped. - * - A skipped test never requires an instance of the case class. A fully skipped class is - * built only when a non-static class-level hook forces it; next to enabled tests the - * class is constructed for them as usual. - * - A run consisting only of `#[Skip]`-marked tests is successful (exit code 0): - * Skipped is neither a success nor a failure. - * - On a non-test method the attribute is inert (like `#[Group]` on a helper). So is it on - * a `#[Bench]` or `#[TestInline]` target: only plain test cases are handled. + * - The skipped test never enters the per-test pipeline: `#[BeforeTest]`/`#[AfterTest]`, + * data providers, `#[Retry]`/`#[Repeat]`, fibers and coverage never engage. A data-driven + * test yields a single Skipped entry. + * - `#[BeforeClass]`/`#[AfterClass]` still run, also when every test of the case is skipped. + * - The case class is not constructed for a skipped test. + * - A run consisting only of skipped tests is successful (exit code 0). + * - The attribute is inert on a non-test method and on `#[Bench]`/`#[TestInline]` targets. * - * Prerequisite: {@see TestPlugin}, which registers the handler {@see SkipInterceptor}. Without - * the plugin only a class-level `#[Skip]` keeps working — through the {@see FallbackInterceptor} - * declared below; a method- or function-level `#[Skip]` is then inert. + * No registration is needed: the attribute wires {@see SkipInterceptor} itself, from a class, a + * method or a function alike. * - * For skipping at runtime — from the test body, based on the environment — throw - * {@see SkipTest} instead; the `is skipped via #[Skip]` marker tells the two apart in reports. + * For skipping at runtime — from the test body, based on the environment — throw {@see SkipTest} + * instead; the `is skipped via #[Skip]` marker tells the two apart in reports. * * @api */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] #[FallbackInterceptor(SkipInterceptor::class)] -final readonly class Skip implements Interceptable +final readonly class Skip implements CaseInterceptable { /** - * @param string $reason Why the test is skipped. Optional, but a reference to an issue + * @param string $reason Why the test is skipped. A reference to an issue * (`'flaky on CI, see ISSUE-123'`) keeps the skip reviewable. */ public function __construct( diff --git a/plugin/skip/tests/Feature/SkipFallbackStandaloneTest.php b/plugin/skip/tests/Feature/SkipFallbackStandaloneTest.php index 64c0dc6c..f2d1846d 100644 --- a/plugin/skip/tests/Feature/SkipFallbackStandaloneTest.php +++ b/plugin/skip/tests/Feature/SkipFallbackStandaloneTest.php @@ -15,23 +15,24 @@ use Testo\Core\Context\TestResult; use Testo\Core\Exception\SkipTest; use Testo\Core\Value\Status; -use Testo\Test; -use Testo\Skip\Internal\SkipInterceptor; use Testo\Skip; +use Testo\Skip\Internal\SkipInterceptor; +use Testo\Test; use Testo\Test\TestPlugin; use Tests\Skip\Stub\SkipStandalone\StandaloneSkippedTest; /** - * The standalone contract of `#[Skip]`: with `TestPlugin` not registered, the attribute's - * {@see \Testo\Pipeline\Attribute\FallbackInterceptor} declaration alone skips a class-level - * case (tests are discovered by naming convention, so no `#[Test]` attribute is involved). + * The standalone contract of `#[Skip]`: no plugin registers {@see SkipInterceptor}, so with + * `TestPlugin` out of the run and the tests discovered by naming convention, the attribute's own + * {@see \Testo\Pipeline\Attribute\FallbackInterceptor} declaration is all that skips a + * method-level case member. */ #[Test] #[Covers(Skip::class)] #[Covers(SkipInterceptor::class)] final class SkipFallbackStandaloneTest { - public function classLevelSkipFallsBackWithoutTestPlugin(): void + public function methodLevelSkipFallsBackWithoutAnyPlugin(): void { $run = Application::createFromConfig(new ApplicationConfig( src: [], @@ -44,33 +45,26 @@ public function classLevelSkipFallsBackWithoutTestPlugin(): void ], ))->run(); - /** @var list $tests */ + /** @var array $tests */ $tests = []; foreach ($run as $suite) { foreach ($suite as $case) { foreach ($case as $test) { - $tests[] = $test; + $tests[$test->info->name] = $test; } } } - # No TestPlugin in this run: the interceptor the attribute spawns through its own - # #[FallbackInterceptor] is what reports both tests of the case. Assert::count($tests, 2); + Assert::true(StandaloneSkippedTest::$enabledRan); + Assert::same($tests['testEnabled']->status, Status::Passed); - $messages = []; - foreach ($tests as $test) { - Assert::same($test->status, Status::Skipped); - Assert::instanceOf($test->failure, SkipTest::class); - $messages[] = $test->failure?->getMessage(); - } - - # The order the results are appended in is not a contract; the composed messages are — - # the `is skipped via #[Skip]` marker and the class-level reason. - \sort($messages); - Assert::same($messages, [ - StandaloneSkippedTest::class . '::testFirstSkipped is skipped via #[Skip] ==> standalone case is skipped', - StandaloneSkippedTest::class . '::testSecondSkipped is skipped via #[Skip] ==> standalone case is skipped', - ]); + $skipped = $tests['testSkipped']; + Assert::same($skipped->status, Status::Skipped); + Assert::instanceOf($skipped->failure, SkipTest::class); + Assert::same( + $skipped->failure->getMessage(), + StandaloneSkippedTest::class . '::testSkipped is skipped via #[Skip] ==> standalone method is skipped', + ); } } diff --git a/plugin/skip/tests/Stub/SkipStandalone/StandaloneSkippedTest.php b/plugin/skip/tests/Stub/SkipStandalone/StandaloneSkippedTest.php index a256b60d..d38d9c0b 100644 --- a/plugin/skip/tests/Stub/SkipStandalone/StandaloneSkippedTest.php +++ b/plugin/skip/tests/Stub/SkipStandalone/StandaloneSkippedTest.php @@ -4,24 +4,29 @@ namespace Tests\Skip\Stub\SkipStandalone; +use Testo\Assert; use Testo\Skip; /** - * The case of the standalone-fallback run: discovered by naming convention alone (no - * `#[Test]` attribute), executed without `TestPlugin` — only the class-level `#[Skip]` - * fallback skips these tests. Lives in its own directory so the standalone run's - * `FinderConfig` can point at it alone and pick up nothing else. + * The case of the standalone run: discovered by naming convention alone (no `#[Test]` + * attribute, no `TestPlugin`), so nothing but the attribute's own fallback declaration + * can wire the skip. The method-level `#[Skip]` is the one that reaches the case pipeline + * only through {@see \Testo\Pipeline\Attribute\CaseInterceptable}. Lives in its own + * directory so the standalone run's `FinderConfig` can point at it alone. */ -#[Skip('standalone case is skipped')] final class StandaloneSkippedTest { - public function testFirstSkipped(): void + public static bool $enabledRan = false; + + #[Skip('standalone method is skipped')] + public function testSkipped(): void { - throw new \LogicException('Must never run: the case is skipped via the fallback.'); + throw new \LogicException('Must never run: the test is skipped via the fallback.'); } - public function testSecondSkipped(): void + public function testEnabled(): void { - throw new \LogicException('Must never run: the case is skipped via the fallback.'); + self::$enabledRan = true; + Assert::true(true); } } diff --git a/plugin/skip/tests/Unit/SkipAttributeTest.php b/plugin/skip/tests/Unit/SkipAttributeTest.php index 4a281e7d..3be2c744 100644 --- a/plugin/skip/tests/Unit/SkipAttributeTest.php +++ b/plugin/skip/tests/Unit/SkipAttributeTest.php @@ -6,6 +6,7 @@ use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Pipeline\Attribute\CaseInterceptable; use Testo\Pipeline\Attribute\FallbackInterceptor; use Testo\Pipeline\Attribute\Interceptable; use Testo\Test; @@ -59,6 +60,15 @@ public function isInterceptable(): void Assert::instanceOf(new Skip(), Interceptable::class); } + /** + * A method- or function-level `#[Skip]` has to reach the case pipeline, which is built from + * class attributes alone unless the attribute opts in. + */ + public function isCaseInterceptable(): void + { + Assert::instanceOf(new Skip(), CaseInterceptable::class); + } + /** * An `Interceptable` attribute must resolve to an interceptor, or the attributes * interceptor throws at pipeline build time; the fallback names the handler. diff --git a/skills/testo-plugin-author/SKILL.md b/skills/testo-plugin-author/SKILL.md index 033e07c3..8a1d3a98 100644 --- a/skills/testo-plugin-author/SKILL.md +++ b/skills/testo-plugin-author/SKILL.md @@ -263,6 +263,16 @@ $method = $info->testDefinition->reflection; $optedOut = $method->getAttributes(WithoutTransaction::class) !== []; ``` +An attribute can also bring its own interceptor, so users need no plugin registration at all — +`#[Retry]`, `#[Repeat]` and `#[Skip]` ship this way. Implement `Testo\Pipeline\Attribute\Interceptable` +and name the handler with `#[FallbackInterceptor(MyInterceptor::class)]` (repeatable — one per +pipeline position); the core instantiates the interceptor with the attribute instance as a constructor +argument when the attribute is found on a class (case and test pipelines) or on a test (test pipeline +only). If a test-level attribute has to act on the **case** pipeline — take that test out before the +class-level hooks, say — implement `Testo\Pipeline\Attribute\CaseInterceptable` instead: one interceptor +instance is spawned per attribute occurrence, so declare `ConflictPolicy::First` in +`#[InterceptorOptions]` to keep a single one. + ## Pitfalls - **Skipping**: return a `Status::Skipped` `TestResult`; never `throw SkipTest` from an interceptor. diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index 181ede52..b8773386 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -159,8 +159,8 @@ the skipped line without it, and the compact `--json` report only counts the tes `reason` is optional and the attribute is not repeatable — but **always pass a reason that points at an issue** (`#[Skip('flaky on CI, see ISSUE-123')]`); a bare `#[Skip]` is how a skipped test rots -unreviewed. Its interceptor is registered by `TestPlugin` (on by default); in a suite configured -without that plugin only a class-level `#[Skip]` still works — through the attribute's own fallback. +unreviewed. The attribute needs no plugin registration: it wires its own interceptor, from a class, +a method or a function alike. Which skipping tool to reach for: