Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion bridge/rector/FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ Conversion coverage across the three directions supported by `testo/bridge-recto
| **Exception expectation (bare)** | βœ… *bare `\Testo\Expect::exception($c)` β†’ `$this->expectException($c)` (`ExpectExceptionToPhpUnitRector`); the attribute form `#[\Testo\Assert\ExpectException($c)]` β†’ prepended `$this->expectException($c)` (`ExpectExceptionAttributeToPhpUnitRector`)* | 🟑 | βœ… *`TestCallToFunctionRector` folds `->throws(X::class)` into a prepended `\Testo\Expect::exception(X)` + `never` return type* |
| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessage`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessagePattern`β†’`expectExceptionMessageMatches`); substring `withMessageContaining` aborts the chain (no faithful PCRE target)* | βœ… *`ExpectExceptionToTestoRector` folds an uninterrupted run of sibling `expectExceptionMessage/Code` after `expectException` into the `->withMessage()/->withCode()` chain (StmtsAware); a non-foldable call ends the run* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessage('msg')`; Pest has no exception-code modifier to map* |
| **Exception message by regex** (`expectExceptionMessageMatches`) | βž– | β›” *Testo's `withMessageContaining` is substring, not regex* | βž– |
| **Skip** (`throw SkipTest` ↔ `markTestSkipped`) | βœ… | βœ… | 🟑 *`->skip('reason')` β†’ prepended `throw new \Testo\Core\Exception\SkipTest('reason')`; a conditional `->skip(fn () => …)` is left untouched* |
| **Skip at runtime** (`throw SkipTest` ↔ `markTestSkipped`) | βœ… | βœ… *`MarkTestSkippedToTestoRector` takes what the attribute form below leaves: a guarded call, a call deeper in the body, a non-literal message* | βž– *every Pest `->skip()` is unconditional β€” see the row below* |
| **Skip declaratively** (`#[Skip]`) | βœ… *`SkipAttributeToPhpUnitRector`: PHPUnit has no skip attribute, so `#[\Testo\Skip('reason')]` becomes a leading `$this->markTestSkipped('reason')` and the attribute is dropped; a class-level attribute is fanned out onto each test method (a method's own reason wins). **Residual:** the skip turns into a runtime one β€” PHPUnit runs `setUp()` and the data provider before aborting, where Testo keeps the test out of the pipeline entirely* | βœ… *`MarkTestSkippedToSkipAttributeRector`: an unconditional `markTestSkipped('literal')` opening a test method β†’ `#[\Testo\Skip('literal')]`, dropping the call. A guarded/non-literal one stays a throw (row above); a call opening `setUp()` stays a throw too, rather than becoming a class-level `#[Skip]`* | βœ… *`->skip()` / `->skip('reason')` β†’ `#[\Testo\Skip('reason')]`; a conditional `->skip(fn () => …)` / `->skip($bool, 'reason')` leaves the whole statement untouched* |
| **Incomplete** (`markTestIncomplete`) | βž– | 🟑 *`MarkTestIncompleteRector`: Testo has no Incomplete status, so it maps to the nearest one β€” a `throw new \Testo\Core\Exception\SkipTest(...)` (Skipped). Lossy: the Incomplete-vs-Skipped nuance survives only as an `Incomplete: ` prefix on the reason (literal folds to `'Incomplete: <msg>'`, non-literal to `'Incomplete: ' . $expr`, bare call to `'Incomplete'`)* | βž– |
| **Cancel** (`CancelTest`) | β›” *no PHPUnit equivalent* | βž– | βž– |
| **Coverage attribute** (`#[Covers]` ↔ `#[CoversClass]`) | βœ… *`CoversToCoversClassRector` maps by target kind: a class/enum β†’ `#[CoversClass]`, a trait β†’ `#[CoversTrait]`, an interface is dropped (PHPUnit rejects a non-class coverage target); kind resolved by autoload, an unresolvable name defaults to `#[CoversClass]`* | βœ… | βœ… *`->covers(X::class)` β†’ `#[\Testo\Codecov\Covers(X::class)]`* |
Expand Down Expand Up @@ -115,3 +116,12 @@ Retry/Repeat moved off this list: PHPUnit 13.3 added `#[Repeat]`/`#[Retry]`, so
convert as a documented 🟑 (`RepeatRetryRector` / `RepeatRetryToTestoRector`).
PHPUnit's `markTestIncomplete` moved off this list β€” it now converts to a Skipped throw with an
`Incomplete: ` reason prefix (`MarkTestIncompleteRector`), a documented lossy 🟑 rather than a β›”.

Also done: **declarative skip** β€” Testo's `#[Skip]` attribute (`testo/skip`) splits the skip rows in
two. A skip that states something about the test rather than about a path through it now converts as
an attribute in every direction: `SkipAttributeToPhpUnitRector` unrolls it into the leading
`markTestSkipped()` call PHPUnit needs (fanning a class-level attribute onto each test method), and
both reverse directions produce it β€” a `markTestSkipped('literal')` opening a test method
(`MarkTestSkippedToSkipAttributeRector`) and Pest's `->skip('reason')` modifier. What stays a
`SkipTest` throw is exactly what cannot be declared: a guarded call, one deeper in the body, or a
message no attribute argument can hold.
4 changes: 4 additions & 0 deletions bridge/rector/config/phpunit-to-testo.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Testo\Bridge\Rector\PhpunitToTesto\GroupToTestoRector;
use Testo\Bridge\Rector\PhpunitToTesto\LifecycleMethodToTestoRector;
use Testo\Bridge\Rector\PhpunitToTesto\MarkTestIncompleteRector;
use Testo\Bridge\Rector\PhpunitToTesto\MarkTestSkippedToSkipAttributeRector;
use Testo\Bridge\Rector\PhpunitToTesto\MarkTestSkippedToTestoRector;
use Testo\Bridge\Rector\PhpunitToTesto\MergeAssertChainRector;
use Testo\Bridge\Rector\PhpunitToTesto\RepeatRetryToTestoRector;
Expand All @@ -35,6 +36,9 @@
# emptiness) rather than a flat facade call β€” see TypedAssertCallToTestoRector.
$rectorConfig->rule(TypedAssertCallToTestoRector::class);

# A skip that opens a test method is a property of the test: it becomes `#[Skip]`, and only what
# is left β€” a guarded or non-literal skip β€” falls through to the throw form below.
$rectorConfig->rule(MarkTestSkippedToSkipAttributeRector::class);
$rectorConfig->rule(MarkTestSkippedToTestoRector::class);

# Incomplete has no exact Testo status; mapped to a Skipped throw with an "Incomplete:" reason
Expand Down
2 changes: 2 additions & 0 deletions bridge/rector/config/testo-to-phpunit.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Testo\Bridge\Rector\TestoToPhpunit\GroupToPhpUnitRector;
use Testo\Bridge\Rector\TestoToPhpunit\LifecycleAttributesToPhpUnitRector;
use Testo\Bridge\Rector\TestoToPhpunit\RepeatRetryRector;
use Testo\Bridge\Rector\TestoToPhpunit\SkipAttributeToPhpUnitRector;
use Testo\Bridge\Rector\TestoToPhpunit\TestClassToTestCaseRector;
use Testo\Bridge\Rector\TestoToPhpunit\ThrowSkipTestToPhpUnitRector;
use Testo\Bridge\Rector\TestoToPhpunit\TypedAssertChainRector;
Expand All @@ -28,6 +29,7 @@
return static function (RectorConfig $rectorConfig): void {
$rectorConfig->rule(AssertCallToPhpUnitRector::class);
$rectorConfig->rule(ThrowSkipTestToPhpUnitRector::class);
$rectorConfig->rule(SkipAttributeToPhpUnitRector::class);
$rectorConfig->rule(CoversToCoversClassRector::class);
$rectorConfig->rule(LifecycleAttributesToPhpUnitRector::class);
$rectorConfig->rule(ExpectExceptionToPhpUnitRector::class);
Expand Down
3 changes: 2 additions & 1 deletion bridge/rector/src/PestToTesto/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ synthesize a class, we synthesize functions.
- `->group('a','b')` β†’ `#[\Testo\Filter\Group('a','b')]`
- `->covers(X::class)` β†’ `#[\Testo\Codecov\Covers(X::class)]`
- `->throws(X::class[, 'msg'])` β†’ prepended `\Testo\Expect::exception(X)[->withMessage('msg')]`, return type `never`
- `->skip(['reason'])` β†’ prepended `throw new \Testo\Core\Exception\SkipTest('reason')`
- `->skip(['reason'])` β†’ `#[\Testo\Skip('reason')]` (the modifier is unconditional, so the
declarative attribute is the faithful form)
- `->with([ <rows> ])` β†’ one `#[\Testo\Data\DataSet([...])]` per row (inline array literal only)
- **`ExpectToAssertRector`** β€” runs after the structural rule and maps each
`expect($value)->toX(...)` expectation inside the generated bodies to the matching actual-first
Expand Down
18 changes: 10 additions & 8 deletions bridge/rector/src/PestToTesto/TestCallToFunctionRector.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\Throw_;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
Expand Down Expand Up @@ -59,7 +57,7 @@
* - `->group('a','b')` => `#[\Testo\Filter\Group('a','b')]`
* - `->covers(X::class)` => `#[\Testo\Codecov\Covers(X::class)]` (repeatable)
* - `->throws(X::class[, 'msg'])` => prepended `\Testo\Expect::exception(X::class)[->withMessage('msg')]`, return type `never`
* - `->skip(['reason'])` => prepended `throw new \Testo\Core\Exception\SkipTest('reason')`
* - `->skip(['reason'])` => `#[\Testo\Skip('reason')]`
* - `->with([ <rows> ])` => one `#[\Testo\Data\DataSet([...])]` per row (array literal only)
*
* Operates at the statements level ({@see StmtsAwareInterface}) so the file/namespace body is
Expand Down Expand Up @@ -358,8 +356,10 @@ private function throwsModifier(array $args): ?array
}

/**
* `->skip()` / `->skip('reason')` => prepended `throw new \Testo\Core\Exception\SkipTest(['reason'])`.
* A conditional skip (`->skip(fn () => …)` / `->skip($bool, 'reason')`) is unsupported.
* `->skip()` / `->skip('reason')` => `#[\Testo\Skip(['reason'])]`. Both are unconditional
* declarations about the test, which is what the attribute expresses β€” the test never enters
* the pipeline. A conditional skip (`->skip(fn () => …)` / `->skip($bool, 'reason')`) is a
* runtime decision and is unsupported.
*
* @param array<int, Arg|Node\VariadicPlaceholder> $args
* @return array{attributes: list<AttributeGroup>, prepend: list<Node\Stmt>, returnType: ?non-empty-string}|null
Expand All @@ -375,9 +375,11 @@ private function skipModifier(array $args): ?array
$skipArgs = [$args[0]];
}

$throw = new Throw_(new New_(new FullyQualified('Testo\\Core\\Exception\\SkipTest'), $skipArgs));

return ['attributes' => [], 'prepend' => [new Expression($throw)], 'returnType' => null];
return [
'attributes' => [$this->attribute('Testo\\Skip', $skipArgs)],
'prepend' => [],
'returnType' => null,
];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ it('is work in progress', function () {

/** is work in progress */
#[\Testo\Test]
#[\Testo\Skip('not ready')]
function it_is_work_in_progress(): void
{
throw new \Testo\Core\Exception\SkipTest('not ready');
doThing();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<?php

declare(strict_types=1);

namespace Testo\Bridge\Rector\PhpunitToTesto;

use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Attribute;
use PhpParser\Node\AttributeGroup;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Expression;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
use Testo\Bridge\Rector\Testing\TestRectorFixtures;

/**
* Turns an unconditional `markTestSkipped()` that opens a test method into Testo's declarative
* `#[\Testo\Skip]` attribute, dropping the call.
*
* A skip on the first line is a property of the test, not of a code path through it, and that is
* what the attribute expresses: Testo keeps such a test out of the per-test pipeline entirely β€”
* `#[BeforeTest]`, the data provider and `#[Retry]`/`#[Repeat]` never engage, and the case class is
* never constructed. A literal message becomes the reason.
*
* Everything else stays a throw, converted by {@see MarkTestSkippedToTestoRector}: a call deeper in
* the body (guarded by an `if`, inside a loop) is a runtime decision, and a non-literal message
* (`$this->markTestSkipped($reason)`, a concatenation with a variable) cannot live in an attribute
* argument. `self::`/`static::` calls convert the same way as `$this->`.
*
* **Residual:** a `markTestSkipped()` opening `setUp()` skips every test of the class in PHPUnit;
* that stays a throw rather than becoming a class-level `#[Skip]`, so the hook keeps whatever else
* it does.
*/
#[TestRectorFixtures('MarkTestSkippedToSkipAttributeRector')]
final class MarkTestSkippedToSkipAttributeRector extends AbstractRector
{
private const SKIP_TESTO = 'Testo\\Skip';

/** @var list<string> */
private const TEST_ATTRIBUTES = ['PHPUnit\\Framework\\Attributes\\Test', 'Testo\\Test'];

/** @var list<string> */
private const LIFECYCLE_NAMES = ['setup', 'teardown', 'setupbeforeclass', 'teardownafterclass'];

public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
'Convert a leading unconditional markTestSkipped() into the Testo #[Skip] attribute',
[
new CodeSample(
<<<'PHP'
public function testSomething(): void
{
$this->markTestSkipped('not ready');
$this->doSomething();
}
PHP,
<<<'PHP'
#[\Testo\Skip('not ready')]
public function testSomething(): void
{
$this->doSomething();
}
PHP,
),
],
);
}

#[\Override]
public function getNodeTypes(): array
{
return [ClassMethod::class];
}

/**
* @param ClassMethod $node
*/
#[\Override]
public function refactor(Node $node): ?Node
{
if ($node->stmts === null || !$this->isTestMethod($node)) {
return null;
}

$first = $node->stmts[0] ?? null;
if (!$first instanceof Expression) {
return null;
}

$args = $this->skipCallArgs($first->expr);
if ($args === null) {
return null;
}

\array_shift($node->stmts);
$node->attrGroups[] = new AttributeGroup([
new Attribute(new FullyQualified(self::SKIP_TESTO), $args),
]);

return $node;
}

/**
* The `#[Skip]` arguments for a `markTestSkipped()` call expression: none for a message-less
* call, the literal message otherwise. Null when the expression is not such a call at all, or
* carries a message an attribute cannot hold β€” a non-literal one stays a throw.
*
* @return list<Arg>|null
*/
private function skipCallArgs(Node\Expr $expr): ?array
{
if ($expr instanceof MethodCall) {
if (!$this->isName($expr->var, 'this')) {
return null;
}
} elseif ($expr instanceof StaticCall) {
if (!$this->isName($expr->class, 'self') && !$this->isName($expr->class, 'static')) {
return null;
}
} else {
return null;
}

if (!$this->isName($expr->name, 'markTestSkipped')) {
return null;
}

$message = $expr->args[0] ?? null;
if ($message === null) {
return [];
}

return $message instanceof Arg && $message->value instanceof String_
? [new Arg(new String_($message->value->value))]
: null;
}

/**
* Whether the method is a PHPUnit test: public, non-static, not a lifecycle hook, and either
* `test`-prefixed or carrying a `#[Test]` attribute (PHPUnit's or the converted Testo one).
*/
private function isTestMethod(ClassMethod $method): bool
{
if (!$method->isPublic() || $method->isStatic()) {
return false;
}

$name = \strtolower((string) $this->getName($method));
if (\in_array($name, self::LIFECYCLE_NAMES, true)) {
return false;
}

return \str_starts_with($name, 'test') || $this->hasTestAttribute($method);
}

private function hasTestAttribute(ClassMethod $method): bool
{
foreach ($method->attrGroups as $attrGroup) {
foreach ($attrGroup->attrs as $attr) {
foreach (self::TEST_ATTRIBUTES as $name) {
if ($this->isName($attr->name, $name)) {
return true;
}
}
}
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
public function testSomething(): void
{
if (!\extension_loaded('pcntl')) {
$this->markTestSkipped('needs pcntl');
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
public function testSomething(): void
{
$this->markTestSkipped('not ready');
$this->doSomething();
}
}
-----
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
#[\Testo\Skip('not ready')]
public function testSomething(): void
{
$this->doSomething();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
public function testSomething(): void
{
$this->markTestSkipped();
}
}
-----
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
#[\Testo\Skip]
public function testSomething(): void
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
public function testSomething(): void
{
$this->markTestSkipped($this->reason());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
#[\PHPUnit\Framework\Attributes\Test]
public function something(): void
{
self::markTestSkipped('needs the new API');
}
}
-----
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
#[\PHPUnit\Framework\Attributes\Test]
#[\Testo\Skip('needs the new API')]
public function something(): void
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

class SomeTest extends \PHPUnit\Framework\TestCase
{
protected function setUp(): void
{
$this->markTestSkipped('the whole case is on hold');
}
}
Loading
Loading