Skip to content
178 changes: 98 additions & 80 deletions bin/Rector/SkipUnconvertibleTestMethodRector.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
Expand All @@ -26,8 +27,12 @@
*
* A test method is skipped when:
* - its class lives in a runtime-bound namespace ({@see self::RUNTIME_NAMESPACES}) — the Facade
* tests need an active container;
* tests need an active container — or is an Acceptance test that drives an external process by a
* path absent from the mirror;
* - it carries a composite data source ({@see self::COMPOSITE_DATA}) with no PHPUnit equivalent;
* - it declares an `Expect::exception()` expectation with a modifier that has no PHPUnit form
* (the substring `withMessageContaining`, `fromMethod`, …) — the chain stays as a `Testo\Expect::`
* call the runtime cannot satisfy under PHPUnit;
* - it is individually listed as unconvertible ({@see self::SKIP_METHODS}) — e.g. a Tokenizer test
* whose stub reprint fully-qualifies an unqualified external call.
*
Expand All @@ -38,11 +43,15 @@
* skipped: PHPUnit — not the engine — discovers and runs them, so a mutation cannot break discovery
* to fake a pass, and these tests are exactly what gives the pipeline its mutation coverage.
*
* Skipping rewrites the body to a single `$this->markTestSkipped(...)`, drops the parameters and
* removes any data-provider attributes — otherwise PHPUnit would invoke the method with the wrong
* argument count before the skip runs. Whole-file fatals (a custom constructor, a name colliding
* with a `final` TestCase method) are handled earlier by bin/build-phpunit.php, which cannot be
* fixed per method.
* Skipping PREPENDS a `$this->markTestSkipped(...)` and keeps the original body and attributes, so
* the unconvertible test stays fully visible and simply reports as skipped instead of being blanked
* out — the skip throws before the kept body runs. Parameters are dropped only when no data source
* will feed them (so PHPUnit can still call the method: a Testo composite it ignores would otherwise
* leave required parameters unfed and error before the skip); a method whose data source PHPUnit
* honors keeps its parameters so the dataset arity still matches. Lifecycle hooks are left untouched
* — they run their real body around a skipped test, which is harmless. Whole-file fatals (a custom
* constructor, a name colliding with a `final` TestCase method) are handled earlier by
* bin/build-phpunit.php, which cannot be fixed per method.
*/
final class SkipUnconvertibleTestMethodRector extends AbstractRector
{
Expand Down Expand Up @@ -73,18 +82,6 @@ final class SkipUnconvertibleTestMethodRector extends AbstractRector
'Testo\\Data\\DataUnion',
];

/** Argument-feeding attributes to strip from a skipped method (else PHPUnit miscounts args). */
private const DATA_ATTRIBUTES = [
'PHPUnit\\Framework\\Attributes\\DataProvider',
'PHPUnit\\Framework\\Attributes\\TestWith',
'PHPUnit\\Framework\\Attributes\\TestWithJson',
'Testo\\Data\\DataProvider',
'Testo\\Data\\DataSet',
'Testo\\Data\\DataCross',
'Testo\\Data\\DataZip',
'Testo\\Data\\DataUnion',
];

public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
Expand All @@ -93,17 +90,19 @@ public function getRuleDefinition(): RuleDefinition
new CodeSample(
<<<'PHP'
#[\PHPUnit\Framework\Attributes\Test]
public function risky(): void
public function rejects(): never
{
$result = \Testo\Testing\Helper\TestRunner::runTest([Common::class, 'risky']);
$this->assertSame($result->status, Status::Risky);
\Testo\Expect::exception(X::class)->withMessageContaining('x');
$this->act();
}
PHP,
<<<'PHP'
#[\PHPUnit\Framework\Attributes\Test]
public function risky(): void
public function rejects(): never
{
$this->markTestSkipped('risky() exercises the Testo runtime (Testo\Testing\) and has no PHPUnit equivalent');
$this->markTestSkipped('rejects() calls Testo\Expect with no PHPUnit form');
\Testo\Expect::exception(X::class)->withMessageContaining('x');
$this->act();
}
PHP,
),
Expand Down Expand Up @@ -143,39 +142,9 @@ public function refactor(Node $node): ?Node
$changed = true;
}

// When the whole case is skipped, its lifecycle hooks still run before/after each (skipped)
// test. Empty them: their setup is meaningless once every test is skipped, and it may
// reference Testo runtime helpers or stub files absent from the mirror (e.g. a former
// constructor turned into a #[Before] hook that `require`s a stub).
if ($classReason !== null) {
foreach ($node->getMethods() as $method) {
// Leave a hook already turned into a skipped test (it carried #[Test]) — emptying it
// would drop the markTestSkipped and PHPUnit would report it risky, not skipped.
if ($this->isLifecycleHook($method) && !$this->isAlreadySkipped($method) && ($method->stmts ?? []) !== []) {
$method->stmts = [];
$changed = true;
}
}
}

return $changed ? $node : null;
}

/** A lifecycle hook by reserved name or by PHPUnit lifecycle attribute. */
private function isLifecycleHook(ClassMethod $method): bool
{
if (\in_array(\strtolower((string) $this->getName($method)), ['setup', 'teardown', 'setupbeforeclass', 'teardownafterclass'], true)) {
return true;
}

return $this->hasAttribute($method, [
'PHPUnit\\Framework\\Attributes\\Before',
'PHPUnit\\Framework\\Attributes\\After',
'PHPUnit\\Framework\\Attributes\\BeforeClass',
'PHPUnit\\Framework\\Attributes\\AfterClass',
]);
}

private function namespaceReason(?string $fqcn): ?string
{
if ($fqcn === null) {
Expand Down Expand Up @@ -213,52 +182,101 @@ private function methodReason(ClassMethod $method, ?string $fqcn): ?string
return 'uses a composite data source (Testo\\Data\\DataCross/DataZip/DataUnion) with no PHPUnit equivalent';
}

// An `Expect::exception()` chain carrying a modifier that ExpectExceptionToPhpUnitRector cannot
// translate (only withMessage/withCode/withMessagePattern are mapped — the substring
// withMessageContaining, fromMethod, withPrevious, … are not) is left as a `Testo\Expect::`
// call, which needs the Testo runtime state absent under PHPUnit and would fatal with
// StateNotFound. Skip such a method. (A chain with only mapped modifiers converts fine and is
// left to run — detecting it by modifier name, not by "leftover Expect", is order-independent:
// the skip rule may run before or after that conversion.)
if ($this->hasUnconvertibleExpect($method)) {
return 'declares an Expect::exception() expectation with a modifier that has no PHPUnit form (e.g. withMessageContaining)';
}

$shortClass = $fqcn === null ? '' : \substr($fqcn, (int) \strrpos($fqcn, '\\') + 1);

return self::SKIP_METHODS[$shortClass . '::' . $this->getName($method)] ?? null;
}

/** Modifiers ExpectExceptionToPhpUnitRector can translate; any other on the chain aborts it. */
private const MAPPED_EXPECT_MODIFIERS = ['withMessage', 'withCode', 'withMessagePattern'];

/**
* Whether the body holds a `Testo\Expect::exception(...)` chain with at least one modifier that has
* no PHPUnit counterpart — the case ExpectExceptionToPhpUnitRector leaves unconverted. Each
* modifier is a `MethodCall` whose `->var` chain bottoms out at the `exception()` head.
*/
private function hasUnconvertibleExpect(ClassMethod $method): bool
{
foreach ((new \PhpParser\NodeFinder())->findInstanceOf($method->stmts ?? [], MethodCall::class) as $call) {
$head = $call->var;
while ($head instanceof MethodCall) {
$head = $head->var;
}
if (!$head instanceof StaticCall || !$this->isName($head->class, 'Testo\\Expect') || !$this->isName($head->name, 'exception')) {
continue;
}

$modifier = $this->getName($call->name);
if ($modifier === null || !\in_array($modifier, self::MAPPED_EXPECT_MODIFIERS, true)) {
return true;
}
}

return false;
}

private function skip(ClassMethod $method, string $reason): void
{
$name = $this->getName($method) ?? 'test';

$method->params = [];
$method->stmts = [
new Expression(new MethodCall(
new Variable('this'),
new Identifier('markTestSkipped'),
[new Arg(new String_("{$name}() {$reason}"))],
)),
];

// Drop data-feeding attributes (and any group left empty), keep the rest (#[Test], #[Group]).
$groups = [];
foreach ($method->attrGroups as $group) {
$group->attrs = \array_filter(
$group->attrs,
fn($attr): bool => !$this->isAnyName($attr->name, self::DATA_ATTRIBUTES),
);

if ($group->attrs !== []) {
$group->attrs = \array_values($group->attrs);
$groups[] = $group;
}
$skip = new Expression(new MethodCall(
new Variable('this'),
new Identifier('markTestSkipped'),
[new Arg(new String_("{$name}() {$reason}"))],
));

// Prepend the skip, keeping the original body and attributes; the skip throws before the kept
// body would run. Parameters are kept when a PHPUnit data source will feed them (so the
// dataset arity still matches), and dropped otherwise so PHPUnit can still call the method — a
// Testo composite source it ignores, or no source at all, would leave required parameters
// unfed and error before the skip.
$method->stmts = [$skip, ...($method->stmts ?? [])];
if (!$this->hasHonoredDataSource($method)) {
$method->params = [];
}
}

$method->attrGroups = $groups;
/**
* Whether a data source that PHPUnit will feed to the method's parameters is present — either the
* PHPUnit attribute directly, or the Testo attribute the conversion turns into one (this rule may
* run before or after that conversion). A Testo *composite* source (DataCross/Zip/Union) is NOT
* honored — PHPUnit ignores it, so its method's parameters must still be dropped.
*/
private function hasHonoredDataSource(ClassMethod $method): bool
{
return $this->hasAttribute($method, [
'PHPUnit\\Framework\\Attributes\\DataProvider',
'PHPUnit\\Framework\\Attributes\\DataProviderExternal',
'PHPUnit\\Framework\\Attributes\\TestWith',
'PHPUnit\\Framework\\Attributes\\TestWithJson',
'Testo\\Data\\DataProvider',
'Testo\\Data\\DataSet',
]);
}

private function isAlreadySkipped(ClassMethod $method): bool
{
if (\count($method->stmts ?? []) !== 1) {
$first = ($method->stmts ?? [])[0] ?? null;
if (!$first instanceof Expression) {
return false;
}

$stmt = $method->stmts[0];
# `$this->markTestSkipped()` on a test method, `self::markTestSkipped()` on a (possibly static) hook.
$call = $first->expr;

return $stmt instanceof Expression
&& $stmt->expr instanceof MethodCall
&& $this->isName($stmt->expr->name, 'markTestSkipped');
return ($call instanceof MethodCall || $call instanceof StaticCall)
&& $this->isName($call->name, 'markTestSkipped');
}

/**
Expand Down
94 changes: 74 additions & 20 deletions bin/build-phpunit.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,47 +39,57 @@
foreach ($roots as $srcRoot) {
$srcRoot = \str_replace('\\', '/', $srcRoot);

// The mirror base this whole root maps to: $dest for the core `tests/` root (its layout already
// mirrors `Tests\PhpUnit\`), or the base derived from a sample test file's namespace for a
// plugin/bridge root (e.g. bridge/vcr/tests -> $dest/Bridge/VCR). Support data is mirrored
// relative to it so a fixture read by `__DIR__/../fixtures` still resolves.
$rootBase = mirrorBaseDir($srcRoot, $coreTestsRoot, $dest);

/** @var \SplFileInfo $file */
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($srcRoot, \FilesystemIterator::SKIP_DOTS),
) as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
if (!$file->isFile()) {
continue;
}

$path = \str_replace('\\', '/', $file->getPathname());

// Never re-scan our own output.
if (\str_starts_with($path, $dest . '/')) {
continue;
}
// Skip Self-tests: framework fixtures driven by meta-tests, not standalone unit tests.
if (\str_contains($path, '/Self/')) {
++$ignored;
continue;
}
// Never re-scan our own output.
if (\str_starts_with($path, $dest . '/')) {
continue;
}

$code = \file_get_contents($path);

// Support data (Stub/ or Fixture/ directory) in the core `tests/` root: the tests that use it
// tokenize it — and often `require` it — by RELATIVE PATH, so it must be mirrored verbatim at
// the same relative location under $dest, with its original filename. The namespace-based
// placement below would misplace it: its namespace may be irregular (braced, repeated,
// sub-namespaced or global) or its filename may differ from its sole class, and soleTypeName()
// would rename it out from under the `require`/read. Copied as-is; the rename Rector pass still
// relocates any `Tests\` namespace it carries. Restricted to the core `tests/` root, whose
// layout already mirrors the `Tests\PhpUnit\` PSR-4 path (so autoloading a well-behaved stub
// still resolves); plugin/bridge roots, where source layout and namespace diverge, keep the
// namespace-based placement below.
if ($srcRoot === $coreTestsRoot && \preg_match('#/(?:Stub|Fixture)s?/#', $path) === 1) {
// Support data (a Stub/Fixture/fixtures directory): the tests that use it tokenize it — and
// often `require` it — by RELATIVE PATH, so it must be mirrored verbatim next to the relocated
// tests, under its original filename. This covers every root for NON-PHP support files (a
// fixture, a VCR cassette, a `*.php.inc`, and a `.gitkeep` keeping an intentionally EMPTY stub
// dir — e.g. an empty suite location — alive), plus PHP stubs in the core `tests/` root, which
// are read/`require`d by path so must keep their name (the namespace-based placement below
// would rename them via soleTypeName()). A PHP stub CLASS in a plugin/bridge root instead
// falls through to the namespace-based placement, which its PSR-4 autoload relies on.
$isSupport = \preg_match('#/(?:stub|fixture)s?/#i', $path) === 1;
if ($isSupport && ($file->getExtension() !== 'php' || $srcRoot === $coreTestsRoot)) {
$relative = \ltrim(\substr($path, \strlen($srcRoot)), '/');
$targetFile = $dest . '/' . $relative;
$targetFile = $rootBase . '/' . $relative;
@\mkdir(\dirname($targetFile), 0777, true);
\file_put_contents($targetFile, $code);
\copy($path, $targetFile);
++$copied;
continue;
}

// Beyond support data, only namespaced PHP test/helper files are placed.
if ($file->getExtension() !== 'php') {
continue;
}

$code = \file_get_contents($path);

$namespace = extractNamespace($code);

// Only namespaced PSR-4 classes are placeable; bare helper files (functions.php) are loaded
Expand Down Expand Up @@ -116,6 +126,50 @@
echo "Copied: {$copied}, ignored (Self/helpers): {$ignored}\n";
echo "Next: rector + composer dump-autoload (run by the composer script).\n";

/**
* The mirror base directory a whole source root maps to. The core `tests/` root maps straight to
* $dest; a plugin/bridge root's base is read off a sample namespaced test file — its `Tests\…`
* namespace maps to a `$dest/…` path, and stripping the file's own subdirectory within the root
* leaves the base the root maps to (e.g. bridge/vcr/tests -> $dest/Bridge/VCR). Falls back to $dest
* when the root holds no namespaced test file.
*/
function mirrorBaseDir(string $srcRoot, string $coreTestsRoot, string $dest): string
{
if ($srcRoot === $coreTestsRoot) {
return $dest;
}

/** @var \SplFileInfo $file */
foreach (new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($srcRoot, \FilesystemIterator::SKIP_DOTS),
) as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
continue;
}

$namespace = extractNamespace((string) \file_get_contents($file->getPathname()));
if ($namespace === null) {
continue;
}

$nsPath = \trim(\substr($namespace, \strlen('Tests')), '\\');
$nsPath = $nsPath === '' ? '' : \str_replace('\\', '/', $nsPath);

$filePath = \str_replace('\\', '/', $file->getPathname());
$fileRelDir = \trim(\substr(\dirname($filePath), \strlen($srcRoot)), '/');

// The namespace path ends with the file's own subdirectory when the layout is regular; strip
// it to leave what the root itself maps to.
if ($fileRelDir !== '' && \str_ends_with($nsPath, $fileRelDir)) {
$nsPath = \rtrim(\substr($nsPath, 0, -\strlen($fileRelDir)), '/');
}

return $nsPath === '' ? $dest : $dest . '/' . $nsPath;
}

return $dest;
}

/** Extract the file's namespace (expected to start with `Tests`), or null when there is none. */
function extractNamespace(string $code): ?string
{
Expand Down
Loading
Loading