Summary
A declarative attribute that marks a test method, test class, or test function as disabled: the test is not executed, but stays visible in every report as Status::Skipped, carrying an optional reason. The analog of JUnit 5's @Disabled and Rust's #[ignore].
#[Disabled]
#[Test]
public function brokenScenario(): void { /* … */ }
#[Disabled('Integration service is under maintenance')]
final class BillingIntegrationTest { /* … */ }
Motivation
Today Testo has two ways to keep a test from running, and neither fits the "park this test, come back later" case:
throw new SkipTest('…') is imperative and per-method: the body must start executing, #[BeforeTest] hooks have already run, and it can't target a class.
- With
#[Group('x')] + --group=!x the test vanishes entirely: no result, no count, no report line, the flag must be passed on every invocation, and the reason is recorded nowhere.
Typical moments you reach for it:
- Parking a broken or flaky test. Instead of deleting the test or hiding it behind a group filter, mark it
#[Disabled('flaky on CI, see #103')]; every run keeps reminding the team that the debt exists.
- An executable bug report. A contributor submits a reproducing test for a found bug, disabled so the maintainer's build stays green.
- Tests ahead of implementation. Scaffolded or requirement tests get committed before the code exists, without breaking CI. Pest acknowledges the same need with
->todo().
In the attribute-based half of the PHP ecosystem a declarative disable is simply missing: PHPUnit 10-12 has no unconditional skip attribute (only the imperative markTestSkipped() and conditional #[RequiresPhp*]). Pest ships ->skip(), but as a modifier of its closure DSL it has no counterpart in attribute-configured tests. The addition of the #[Disabled] attribute would address this gap, enabling Testo to offer a declarative disable.
Keeping parked tests visible is the point of the attribute. A test hidden by a group filter or a removed #[Test] attribute leaves no trace in reports, and such tests tend to be forgotten: a mining study of 15 OSS Java projects found that 41% of disabled tests are never re-enabled (ESEC/FSE 2021). A #[Disabled] test shows up in every run as Skipped with its reason, so the debt stays on the radar until someone returns to it.
Side benefit for testo/bridge-rector: Pest's unconditional ->skip('reason') currently converts by mutating the test body (prepending throw new SkipTest); with #[Disabled('reason')] it becomes a clean, non-mutating attribute mapping.
Proposed semantics
- Targets:
TARGET_CLASS | TARGET_METHOD | TARGET_FUNCTION (free-function tests too).
reason is optional; an empty reason falls back to a generated "Tests\Foo::bar is disabled" so no reporter ever shows an empty skip message.
- The test is discovered and reported as
Status::Skipped with the reason: JUnit XML <skipped message>, TeamCity testIgnored, JSON totals, HTML statusReason all already handle this status; zero reporter changes.
- Lifecycle, method-level (matches documented JUnit behavior):
#[BeforeTest] / #[AfterTest] do not run; #[BeforeClass] / #[AfterClass] do run; the test class is never constructed (instantiation is lazy; a non-static class-level hook would still force construction).
#[Retry] / #[Repeat] never engage; data providers are not expanded: a disabled data-driven test yields one Skipped entry.
- Not repeatable; a duplicate declaration is a diagnostic, not a silent no-op.
- Exit code is unaffected (Skipped is neither a success nor a failure), same as JUnit; stated here as intended behavior.
Implementation sketch
Two files, zero registration, following the #[Retry] / #[Repeat] pattern:
- the attribute implements
Interceptable and carries #[FallbackInterceptor(DisabledInterceptor::class)];
DisabledInterceptor implements TestRunInterceptor and short-circuits by returning TestResult(info: $info, status: Status::Skipped, failure: new SkipTest($reason)) without calling $next, exactly the "return, don't throw" contract already documented in the SkipTest docblock and the plugin-author skill;
- interceptor order: outside
ORDER_DATA_PROVIDER, inside ORDER_FILTER;
- class-level placement works for free via the existing class+method attribute merge in
AttributesInterceptor;
- no new
Status case, no changes to Summary or any reporter.
Open questions: maintainer input wanted before I start
- Host package. A top-level
\Testo\Disabled requires a brand-new testo/disabled package (the plugin-creation naming rule: top-level class must equal the package short name). The cheap alternative is a sub-namespace of an already-bundled package, e.g. Testo\Test\Disabled (precedents: Testo\Assert\ExpectException, Testo\Lifecycle\BeforeTest). Preference?
- Class-level depth. v1 short-circuits per test only: every test in the class reports Skipped,
#[BeforeClass]/#[AfterClass] still run (the class itself is never constructed unless a non-static class-level hook forces it). Full case-level suppression is possible but requires synthesizing per-test results and events by hand. OK to ship v1 with the cheap, documented semantics?
- Terminal output. Today the terminal renderer drops skip reasons entirely (
FormattedItem has no field for it), so a reason is visible only in JUnit XML / TeamCity / HTML. Should a follow-up PR add reason rendering? (It would also start printing reasons for existing SkipTest skips, arguably an improvement, but an output change.)
- Inheritance. Should class-level
#[Disabled] on a parent class / trait affect subclasses? Testo's #[Group] inherits; JUnit's @Disabled deliberately does not.
Scope: incremental plan
Each item is an independent PR with its own scope, so per-package changelogs stay honest:
If this approach (or an amended version of it) is confirmed, I'd like to take the implementation, starting with PR 1.
Summary
A declarative attribute that marks a test method, test class, or test function as disabled: the test is not executed, but stays visible in every report as
Status::Skipped, carrying an optional reason. The analog of JUnit 5's@Disabledand Rust's#[ignore].Motivation
Today Testo has two ways to keep a test from running, and neither fits the "park this test, come back later" case:
throw new SkipTest('…')is imperative and per-method: the body must start executing,#[BeforeTest]hooks have already run, and it can't target a class.#[Group('x')]+--group=!xthe test vanishes entirely: no result, no count, no report line, the flag must be passed on every invocation, and the reason is recorded nowhere.Typical moments you reach for it:
#[Disabled('flaky on CI, see #103')]; every run keeps reminding the team that the debt exists.->todo().In the attribute-based half of the PHP ecosystem a declarative disable is simply missing: PHPUnit 10-12 has no unconditional skip attribute (only the imperative
markTestSkipped()and conditional#[RequiresPhp*]). Pest ships->skip(), but as a modifier of its closure DSL it has no counterpart in attribute-configured tests. The addition of the#[Disabled]attribute would address this gap, enabling Testo to offer a declarative disable.Keeping parked tests visible is the point of the attribute. A test hidden by a group filter or a removed
#[Test]attribute leaves no trace in reports, and such tests tend to be forgotten: a mining study of 15 OSS Java projects found that 41% of disabled tests are never re-enabled (ESEC/FSE 2021). A#[Disabled]test shows up in every run as Skipped with its reason, so the debt stays on the radar until someone returns to it.Side benefit for
testo/bridge-rector: Pest's unconditional->skip('reason')currently converts by mutating the test body (prependingthrow new SkipTest); with#[Disabled('reason')]it becomes a clean, non-mutating attribute mapping.Proposed semantics
TARGET_CLASS | TARGET_METHOD | TARGET_FUNCTION(free-function tests too).reasonis optional; an empty reason falls back to a generated"Tests\Foo::bar is disabled"so no reporter ever shows an empty skip message.Status::Skippedwith the reason: JUnit XML<skipped message>, TeamCitytestIgnored, JSON totals, HTMLstatusReasonall already handle this status; zero reporter changes.#[BeforeTest]/#[AfterTest]do not run;#[BeforeClass]/#[AfterClass]do run; the test class is never constructed (instantiation is lazy; a non-static class-level hook would still force construction).#[Retry]/#[Repeat]never engage; data providers are not expanded: a disabled data-driven test yields one Skipped entry.Implementation sketch
Two files, zero registration, following the
#[Retry]/#[Repeat]pattern:Interceptableand carries#[FallbackInterceptor(DisabledInterceptor::class)];DisabledInterceptor implements TestRunInterceptorand short-circuits by returningTestResult(info: $info, status: Status::Skipped, failure: new SkipTest($reason))without calling$next, exactly the "return, don't throw" contract already documented in theSkipTestdocblock and the plugin-author skill;ORDER_DATA_PROVIDER, insideORDER_FILTER;AttributesInterceptor;Statuscase, no changes toSummaryor any reporter.Open questions: maintainer input wanted before I start
\Testo\Disabledrequires a brand-newtesto/disabledpackage (the plugin-creation naming rule: top-level class must equal the package short name). The cheap alternative is a sub-namespace of an already-bundled package, e.g.Testo\Test\Disabled(precedents:Testo\Assert\ExpectException,Testo\Lifecycle\BeforeTest). Preference?#[BeforeClass]/#[AfterClass]still run (the class itself is never constructed unless a non-static class-level hook forces it). Full case-level suppression is possible but requires synthesizing per-test results and events by hand. OK to ship v1 with the cheap, documented semantics?FormattedItemhas no field for it), so a reason is visible only in JUnit XML / TeamCity / HTML. Should a follow-up PR add reason rendering? (It would also start printing reasons for existingSkipTestskips, arguably an improvement, but an output change.)#[Disabled]on a parent class / trait affect subclasses? Testo's#[Group]inherits; JUnit's@Disableddeliberately does not.Scope: incremental plan
Each item is an independent PR with its own scope, so per-package changelogs stay honest:
feat(rector)Testo→PHPUnit rule (attribute → prependedmarkTestSkipped(), class-level fan-out), retarget Pest->skip('…'), FEATURE_PARITY row--run-disabled/--include-disabledto run parked tests and see if they still fail (thecargo test -- --ignoredmodel)llms.txt/llms-full.txtentry (separate repo)If this approach (or an amended version of it) is confirmed, I'd like to take the implementation, starting with PR 1.