Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
be1fb8d
feat(test): add #[Skip] attribute to skip tests declaratively
Meacue Sep 2, 2026
79109b6
test(test): cover the #[Skip] v1 semantics with unit and feature tests
Meacue Sep 2, 2026
dd86e2c
docs(skills): document #[Skip] in write-tests, plugin-author and flak…
Meacue Sep 2, 2026
e0e2858
build: prepare root constraints for the testo/test 0.2.0 release
Meacue Sep 2, 2026
708787c
fix(test): mark #[Skip] as Interceptable with SkipInterceptor as fall…
Meacue Sep 2, 2026
62a12da
refactor(test): declare SkipInterceptor conflict policy explicitly
Meacue Sep 2, 2026
e766061
docs(test): correct the fallback coverage claim in the Skip docblock
Meacue Sep 2, 2026
29eb900
docs(test): explain why SkipInterceptor takes no Skip constructor par…
Meacue Sep 2, 2026
93f8580
docs(test): document that #[Skip] is inert on bench and inline cases
Meacue Sep 2, 2026
9f44b4a
fix(test): carry the test description into the synthetic Skipped result
Meacue Sep 2, 2026
9cc7603
test(test): cover class-level #[Skip] standalone fallback without Tes…
Meacue Sep 2, 2026
83a8237
test(test): pin single delivery of class-level #[Skip] with both path…
Meacue Sep 2, 2026
ce84744
test(lifecycle): pin class-level hooks for fully parked cases end-to-end
Meacue Sep 4, 2026
9c98bab
test(test): drop a cannot-fail assertion and pin the enabled function…
Meacue Sep 4, 2026
806ab59
fix(test): load the function stub in SkipFeatureTest regardless of ru…
Meacue Sep 4, 2026
9f4f75c
docs(test): fix #[Skip] docs and strengthen its tests
Meacue Sep 5, 2026
26dd926
docs(test): clarify skip reporter visibility
Meacue Sep 5, 2026
7764b46
fix(test): deactivate #[Skip] tests instead of undefining them
Meacue Sep 8, 2026
19b6580
style(test): order static methods first in the #[Skip] sources
Meacue Sep 8, 2026
6182310
docs(test): say what each #[Skip] stub proves and tighten the attribu…
Meacue Sep 8, 2026
967e2c6
test(test): pin the #[Skip] fallback message and align the stubs with…
Meacue Sep 8, 2026
fca9911
docs(skills): describe #[Skip] deactivation and its package in the sk…
Meacue Sep 8, 2026
153e46f
test(test): use the typed Assert facade in the #[Skip] tests
Meacue Sep 8, 2026
9324f84
test(test): pin that an overriding method inherits the #[Skip] of its…
Meacue Sep 8, 2026
d755af1
test(test): add positive controls for the #[Retry]/#[Repeat] skip checks
Meacue Sep 8, 2026
9d504d6
refactor(test): say "skipped" instead of "parked" across the #[Skip] …
Meacue Sep 8, 2026
2f16f36
style(test): use a # line comment in SkipWithHooksStub
Meacue Sep 8, 2026
0bcca01
test(test): pin what the #[Skip] docblocks promise
Meacue Sep 8, 2026
aa048ea
docs(test): say "directory" and "case" instead of "catalog" in the #[…
Meacue Sep 8, 2026
4a454e8
build: revert the hand-edited testo/test root constraints
Meacue Sep 8, 2026
58bd873
test(test): make the #[Retry] control in SkipWithRetryStub history-proof
Meacue Sep 8, 2026
2c16e71
Merge branch '1.x' into feat/skip-attribute
roxblnfk Sep 8, 2026
2d95649
feat(core): add `CaseInterceptable` for test-level attributes on the …
roxblnfk Sep 8, 2026
6421baa
feat(skip): move `#[Skip]` into its own `testo/skip` plugin
roxblnfk Sep 8, 2026
ff59c73
docs(skip): drop the `TestPlugin` wiring and the issue reference from…
roxblnfk Sep 8, 2026
b9fd5d6
feat(skip): wire `#[Skip]` through `CaseInterceptable` instead of `Te…
roxblnfk Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/.release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/split-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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": [
Expand All @@ -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"
}
}
Expand Down
21 changes: 21 additions & 0 deletions core/Pipeline/Attribute/CaseInterceptable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Testo\Pipeline\Attribute;

/**
* An {@see Interceptable} attribute whose case-level interceptors are wired even when the attribute
* is placed on a test method or function, not only on the case class.
*
* By default a test-level attribute reaches only the per-test pipeline
* ({@see \Testo\Pipeline\Middleware\TestRunInterceptor}); the case pipeline
* ({@see \Testo\Pipeline\Middleware\TestCaseRunInterceptor}) is built from class attributes alone.
* Implement this interface when the attribute has to act on the whole case from a single test —
* e.g. to take that test out of the case before any lifecycle hook runs. The interceptor is
* instantiated once per attribute occurrence; use {@see InterceptorOptions::$onConflict} to
* collapse the duplicates when several tests carry the attribute.
*
* @api
*/
interface CaseInterceptable extends Interceptable {}
33 changes: 27 additions & 6 deletions core/Pipeline/Internal/AttributesInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Testo\Core\Context\CaseResult;
use Testo\Core\Context\TestInfo;
use Testo\Core\Context\TestResult;
use Testo\Pipeline\Attribute\CaseInterceptable;
use Testo\Pipeline\Attribute\Interceptable;
use Testo\Pipeline\Attribute\InterceptorOptions;
use Testo\Pipeline\InterceptorProvider;
Expand All @@ -21,6 +22,9 @@
* Reads {@see Interceptable} attributes and integrates them into the pipeline.
* Also maps the found attributes into the info DTO attributes.
*
* At the case level the class attributes are read; a test-level attribute joins the case pipeline
* only when it is a {@see CaseInterceptable}.
*
* @internal
* @psalm-internal Testo\Pipeline
*/
Expand Down Expand Up @@ -84,30 +88,47 @@ static function (\ReflectionAttribute $a): Interceptable {
#[\Override]
public function runTestCase(CaseInfo $info, callable $next): CaseResult
{
$attrs = $info->definition->reflection === null
$classAttributes = $info->definition->reflection === null
? []
: Reflection::fetchClassAttributes(
class: $info->definition->reflection,
attributeClass: Interceptable::class,
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
Expand Down
82 changes: 82 additions & 0 deletions plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace Tests\Lifecycle\Feature;

use Testo\Assert;
use Testo\Codecov\Covers;
use Testo\Core\Value\Status;
use Testo\Lifecycle\Internal\LifecycleInterceptor;
use Testo\Test;
use Testo\Testing\Attribute\TestingSuite;
use Testo\Testing\Helper\TestRunner;
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/skip` — leaves
* the case without a single active test.
*
* 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 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
* 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)]
#[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/FullySkipped/fully_skipped_functions.php';
}

/**
* 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
{
$beforeClass = FullySkippedFunctionState::$beforeClassCalls;
$afterClass = FullySkippedFunctionState::$afterClassCalls;
$beforeTest = FullySkippedFunctionState::$beforeTestCalls;
$afterTest = FullySkippedFunctionState::$afterTestCalls;

$result = TestRunner::runTest('Tests\Lifecycle\Stub\FullySkipped\skippedFnOne');

Assert::same($result->status, Status::Skipped);
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(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 skipped class exactly as before.
*/
public function classHooksRunForFullySkippedClassCase(): void
{
$beforeClass = FullySkippedClassStub::$beforeClassCalls;
$afterClass = FullySkippedClassStub::$afterClassCalls;

$result = TestRunner::runTest([FullySkippedClassStub::class, 'skipped']);

Assert::same($result->status, Status::Skipped);
Assert::same(FullySkippedClassStub::$beforeClassCalls - $beforeClass, 1);
Assert::same(FullySkippedClassStub::$afterClassCalls - $afterClass, 1);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Tests\Lifecycle\Stub\FullySkipped;

use Testo\Lifecycle\AfterClass;
use Testo\Lifecycle\BeforeClass;
use Testo\Test;
use Testo\Skip;

/**
* 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 directory runs — feature tests assert deltas. The hooks
* are static so the fully skipped class is never instantiated.
*/
#[Test]
final class FullySkippedClassStub
{
public static int $beforeClassCalls = 0;
public static int $afterClassCalls = 0;

#[BeforeClass]
public static function bootCase(): void
{
++self::$beforeClassCalls;
}

#[AfterClass]
public static function shutdownCase(): void
{
++self::$afterClassCalls;
}

#[Skip('the whole class case is skipped')]
public function skipped(): void
{
throw new \LogicException('Must never run: the test is skipped.');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace Tests\Lifecycle\Stub\FullySkipped;

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

/**
* 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
* 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 directory runs — feature tests assert deltas.
* State is shared through {@see FullySkippedFunctionState} because functions have no `$this`.
*/
#[BeforeClass]
function skippedCaseSetUpClass(): void
{
++FullySkippedFunctionState::$beforeClassCalls;
}

#[AfterClass]
function skippedCaseTearDownClass(): void
{
++FullySkippedFunctionState::$afterClassCalls;
}

#[BeforeTest]
function skippedCaseSetUp(): void
{
++FullySkippedFunctionState::$beforeTestCalls;
}

#[AfterTest]
function skippedCaseTearDown(): void
{
++FullySkippedFunctionState::$afterTestCalls;
}

#[Test]
#[Skip('the whole functional case is skipped')]
function skippedFnOne(): void
{
throw new \LogicException('Must never run: the test is skipped.');
}

#[Test]
#[Skip]
function skippedFnTwo(): void
{
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 FullySkippedFunctionState
{
public static int $beforeClassCalls = 0;
public static int $afterClassCalls = 0;
public static int $beforeTestCalls = 0;
public static int $afterTestCalls = 0;
}
14 changes: 14 additions & 0 deletions plugin/skip/.github/workflows/close-prs.yml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions plugin/skip/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<p align="center">
<a href="https://github.com/php-testo/testo"><img alt="TESTO"
src="https://github.com/php-testo/.github/blob/1.x/resources/logo-full.svg?raw=true"
style="width: 2in; display: block"
/></a>
</p>

<p align="center">Skip attribute plugin</p>

<div align="center">

[![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)

</div>

<br />

> [!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)
Loading