Skip to content

Feature request: #[Disabled] attribute to declaratively skip a test or test class, with a reason #313

Description

@Meacue

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

  1. 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?
  2. 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?
  3. 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.)
  4. 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:

  • PR 1: attribute + interceptor + unit/feature tests + skills updates (per AGENTS.md, skills move in the same change)
  • PR 2: feat(rector) Testo→PHPUnit rule (attribute → prepended markTestSkipped(), class-level fan-out), retarget Pest ->skip('…'), FEATURE_PARITY row
  • PR 3: terminal rendering of skip reasons (pending open question 3)
  • PR 4: CLI flags --run-disabled / --include-disabled to run parked tests and see if they still fail (the cargo test -- --ignored model)
  • PR 5: JSON report listing disabled tests (id, reason, location) so a project can lint/inventory its parked tests
  • docs-site follow-up: llms.txt / llms-full.txt entry (separate repo)

If this approach (or an amended version of it) is confirmed, I'd like to take the implementation, starting with PR 1.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions