Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 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
0dbef9a
Merge remote-tracking branch 'origin/1.x' into fork/Meacue/feat/skip-…
roxblnfk Sep 15, 2026
e9d1043
build(skip): align the `testo/skip` version constraints with the plug…
roxblnfk Sep 16, 2026
30b6692
fix(skip): require the core version the monorepo actually resolves
roxblnfk Sep 16, 2026
44159a6
docs(core): say what the test-attribute scan constrains instead of re…
roxblnfk Sep 16, 2026
58753e4
feat(core): flag a test as skipped on its definition
roxblnfk Sep 17, 2026
3a9f178
feat(lifecycle): run no hook for a skipped test or a case without a t…
roxblnfk Sep 17, 2026
b897d6b
feat(skip): flag `#[Skip]` tests at location and report them from the…
roxblnfk Sep 17, 2026
22c1c2b
Merge branch '1.x' into feat/skip-attribute
roxblnfk Sep 18, 2026
3db685e
docs(skip): say what the `#[Skip]` interceptors constrain instead of …
roxblnfk Sep 18, 2026
3535db2
fix(skip): resolve the skip reason by reflection instead of by the su…
roxblnfk Sep 18, 2026
caf9e90
docs(lifecycle): say what hook discovery ignores instead of promising…
roxblnfk Sep 18, 2026
c967a3d
test(skip): pin the empty-reason message for a function
roxblnfk Sep 18, 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 @@ -35,6 +35,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",
"testo/test": "^0.1.8",
"yiisoft/injector": "^1.2"
},
Expand Down Expand Up @@ -105,6 +106,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 @@ -131,6 +133,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
2 changes: 2 additions & 0 deletions core/Application/Config/Plugin/SuitePlugins.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Testo\Facade\FacadePlugin;
use Testo\Inline\InlineTestPlugin;
use Testo\Lifecycle\LifecyclePlugin;
use Testo\Skip\SkipPlugin;
use Testo\Test\TestPlugin;

$_ = [];
Expand All @@ -18,6 +19,7 @@
\class_exists(FacadePlugin::class) and $_[] = new FacadePlugin();
\class_exists(InlineTestPlugin::class) and $_[] = new InlineTestPlugin();
\class_exists(LifecyclePlugin::class) and $_[] = new LifecyclePlugin();
\class_exists(SkipPlugin::class) and $_[] = new SkipPlugin();
\class_exists(TestPlugin::class) and $_[] = new TestPlugin();

\define([__NAMESPACE__ . '\DEFAULT_SUITE_PLUGINS'][0], $_);
Expand Down
18 changes: 18 additions & 0 deletions core/Application/Internal/Runner/TestRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
* {@see Status::Error} result, and a failure of the interceptor pipeline itself is captured as
* {@see Status::Aborted}.
*
* A test marked {@see \Testo\Core\Definition\TestDefinition::$skipped} still goes down the
* pipeline, so the interceptor that knows the reason may report it; if none does, the runner
* reports it as {@see Status::Skipped} itself instead of running the body.
*
* @internal
* @psalm-internal Testo\Application
*/
Expand All @@ -61,6 +65,20 @@ public function runTest(TestInfo $info): TestResult
...$interceptors,
)->with(
function (TestInfo $info) use ($description): TestResult {
# Nothing on the way down reported this skipped test, so no reason is known.
# `TestStarting` announces a body, and there is none: return ahead of it.
if ($info->testDefinition->skipped) {
return new TestResult(
info: $info,
status: Status::Skipped,
failure: new SkipTest("{$info->identity->fqn()} is skipped"),
attributes: [
'duration' => 0,
'description' => $description,
],
);
}

$this->eventDispatcher->dispatch(new TestStarting($info));

$startTime = \microtime(true);
Expand Down
11 changes: 10 additions & 1 deletion core/Core/Definition/TestDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

/**
* A runnable member of a case: a test, or a non-test such as a lifecycle hook. Interceptors refine
* its role through the mutable {@see self::$isTest} and {@see self::$active} flags.
* its role through the mutable {@see self::$isTest}, {@see self::$active} and {@see self::$skipped}
* flags.
*
* @api
*/
Expand All @@ -27,6 +28,14 @@ public function __construct(
* Whether this member is active. Filtering deactivates a test instead of discarding it.
*/
public bool $active = true,

/**
* Whether this test is skipped ahead of time. A skipped test stays active: it is reported as
* {@see \Testo\Core\Value\Status::Skipped} without its body being run, unlike a deactivated
* test, which leaves the results entirely. Nothing that prepares a test body has to engage
* for it β€” a reader of this flag decides that for itself.
*/
public bool $skipped = false,
) {}

public function getDescription(): ?string
Expand Down
15 changes: 8 additions & 7 deletions core/Core/Definition/TestDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,25 +63,26 @@ public function all(): array
*
* @return array<non-empty-string, TestDefinition>
*/
public function filter(?bool $isTest = null, ?bool $active = null): array
public function filter(?bool $isTest = null, ?bool $active = null, ?bool $skipped = null): array
{
return \array_filter(
$this->definitions,
static fn(TestDefinition $d): bool => ($isTest === null || $d->isTest === $isTest)
&& ($active === null || $d->active === $active),
&& ($active === null || $d->active === $active)
&& ($skipped === null || $d->skipped === $skipped),
);
}

/**
* The case's tests β€” by default only the active ones, i.e. the definitions to run. Pass
* `$active = false` for the deactivated tests alone, or `null` for every test regardless of
* state.
* The case's tests β€” by default only the active ones, i.e. the definitions to run or to report
* as skipped. Pass `$active = false` for the deactivated tests alone, or `null` for every test
* regardless of state; `$skipped = false` narrows down to the tests whose body will run.
*
* @return array<non-empty-string, TestDefinition>
*/
public function getTests(?bool $active = true): array
public function getTests(?bool $active = true, ?bool $skipped = null): array
{
return $this->filter(isTest: true, active: $active);
return $this->filter(isTest: true, active: $active, skipped: $skipped);
}

/**
Expand Down
1 change: 0 additions & 1 deletion core/Pipeline/Internal/AttributesInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ static function (\ReflectionAttribute $a): Interceptable {
$attrs,
));

# Merge and instantiate attributes
$interceptors = $this->interceptorProvider->fromAttributes(TestRunInterceptor::class, ...$attrs);
$info = $info->withAttributes(self::groupAttributes($attrs));

Expand Down
13 changes: 10 additions & 3 deletions plugin/lifecycle/src/Internal/LifecycleInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,17 @@ public function locateTestCases(FileDefinitions $file, callable $next): CaseDefi

/**
* Collect all the lifecycle hooks and cache them for execution during test runs.
*
* A case without a test to run (every active test is skipped) has nothing to set up: its hooks
* stay silent, class-level and per-test alike.
*/
#[\Override]
public function runTestCase(CaseInfo $info, callable $next): CaseResult
{
if ($info->definition->tests->getTests(skipped: false) === []) {
Comment thread
roxblnfk marked this conversation as resolved.
return $next($info);
}
Comment thread
roxblnfk marked this conversation as resolved.

$result = self::group(self::collectHooks($info->definition));

# Execute BeforeClass hooks
Expand All @@ -99,7 +106,7 @@ public function runTest(TestInfo $info, callable $next): TestResult
{
/** @var array<class-string<LifecycleAttribute>, non-empty-list<\ReflectionFunctionAbstract>> $hooks */
$hooks = $info->caseInfo->getAttribute(self::class, []);
if ($hooks === []) {
if ($hooks === [] || $info->testDefinition->skipped) {
return $next($info);
}

Expand Down Expand Up @@ -155,8 +162,8 @@ private static function group(iterable $hooks): array
/**
* The lifecycle-annotated non-test members of the case. Lifecycle-annotated members a finder
* took for tests were demoted in {@see self::locateTestCases()}, so every hook is a non-test.
* Non-tests outlive test filtering: the `#[BeforeClass]`/`#[AfterClass]` hooks run even for a
* case whose tests were all filtered out.
* Discovery therefore does not depend on which tests survived: deactivating or skipping one
* leaves the case's hooks where they are.
*
* @return list<\ReflectionFunctionAbstract>
*/
Expand Down
73 changes: 73 additions & 0 deletions plugin/lifecycle/tests/Feature/FullySkippedCaseFeatureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?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}: a case whose every test is flagged
* skipped ahead of the run β€” here by `#[Skip]` from `testo/skip` β€” has nothing to set up, so none
* of its hooks fire, `#[BeforeClass]`/`#[AfterClass]` included.
*
* The skipped tests stay active (the case is still located, run and reported), which is why the
* interceptor has to look at the skipped flag and not only at the active test set. Both case
* shapes are pinned here through the real pipeline: the methods of a class-based case and the
* free functions of 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: no hook of any kind fires, and the skipped tests are still
* reported.
*/
public function noHookRunsForAFullySkippedFunctionCase(): 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, 0);
Assert::same(FullySkippedFunctionState::$afterClassCalls - $afterClass, 0);
Assert::same(FullySkippedFunctionState::$beforeTestCalls - $beforeTest, 0);
Assert::same(FullySkippedFunctionState::$afterTestCalls - $afterTest, 0);
}

/**
* The class-based analog: the class-level hooks stay silent for a fully skipped class.
*/
public function noHookRunsForAFullySkippedClassCase(): void
{
$beforeClass = FullySkippedClassStub::$beforeClassCalls;
$afterClass = FullySkippedClassStub::$afterClassCalls;

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

Assert::same($result->status, Status::Skipped);
Assert::same(FullySkippedClassStub::$beforeClassCalls - $beforeClass, 0);
Assert::same(FullySkippedClassStub::$afterClassCalls - $afterClass, 0);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?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()}): a case with no test to run gets no class-level hooks β€” pinned here so
* both flavors stay in lockstep.
*
* Static hook counters accumulate across directory runs β€” feature tests assert deltas.
*/
#[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,75 @@
<?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 skipped tests are flagged ahead of the run and stay active, so the
* {@see \Testo\Lifecycle\Internal\LifecycleInterceptor} sees a case with tests but without a single
* one to run: none of its hooks fire (the `#[Skip]` contract).
*
* 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;
}
Loading
Loading