diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1a424d5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = false +max_line_length = 120 +tab_width = 4 diff --git a/.github/workflows/symfony.yml b/.github/workflows/symfony.yml new file mode 100644 index 0000000..a8c710f --- /dev/null +++ b/.github/workflows/symfony.yml @@ -0,0 +1,51 @@ +name: Transunit + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + symfony-tests: + runs-on: ubuntu-latest + steps: + # To automatically get bug fixes and new Php versions for shivammathur/setup-php, + # change this to (see https://github.com/shivammathur/setup-php#bookmark-versioning): + # uses: shivammathur/setup-php@v2 + - uses: shivammathur/setup-php@2cb9b829437ee246e9b3cac53555a39208ca6d28 + with: + php-version: '8.3' + + - uses: actions/checkout@v3 + + - name: Copy .env.test.local + run: php -r "file_exists('.env.test.local') || copy('.env.test', '.env.test.local');" + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install Dependencies + run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Execute canon unit tests via PhpSpec + run: bin/phpspec run --format=pretty --verbose --no-interaction + +# - name: Dogfooding via converting unit tests from PhpSpec to PHPUnit +# run: | +# php transunit.php spec +# +# - name: Execute converted unit tests via PHPUnit +# run: bin/phpunit --testdox + + - name: 'Candidate conversion: Convert sylius/addressing unit tests from PhpSpec to PHPUnit, then run them' + run: php transunit.php vendor/sylius/addressing/spec && phpunit var/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..631570d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/var/ +composer.lock diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ed26a80 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "require-dev": { + "phpspec/phpspec": "*", + "phpunit/phpunit": "*", + "sylius/addressing": "*", + "phpspec/prophecy-phpunit": "^2.2" + }, + "require": { + "nikic/php-parser": "*", + "symfony/finder": "*", + "symfony/filesystem": "^7.0" + }, + "autoload": { + "psr-4": { + "Transunit\\": "lib/Transunit" + } + } +} diff --git a/lib/Transunit/Pass.php b/lib/Transunit/Pass.php new file mode 100644 index 0000000..c697718 --- /dev/null +++ b/lib/Transunit/Pass.php @@ -0,0 +1,13 @@ +find($ast, function (Node $node) { + if ($node instanceof Node\Stmt\Expression) { + return $node; + } + + return null; + }); + } + + public function rewrite(Node $node): void + { + if (!$node->expr instanceof Node\Expr\MethodCall) { + return; + } + + $assertion = $node->expr->name->toString(); + $mappedAssertions = [ + 'shouldBe' => 'assertSame', + 'shouldReturn' => 'assertSame', + 'shouldBeLike' => 'assertEquals', + 'shouldHaveCount' => 'assertCount', + 'shouldHaveType' => 'assertInstanceOf', + 'shouldImplement' => 'assertInstanceOf', + ]; + + if (!isset($mappedAssertions[$assertion])) { + return; + } + + $expectation = $node->expr->args[0]->value; + $call = $node->expr->var; + + if ( + $expectation instanceof Node\Expr\ConstFetch + && $expectation->name->toString() === 'null' + ) { + // static::assertNull($call); + $rewrittenAssertion = new Node\Expr\StaticCall( + new Node\Name('static'), + 'assertNull', + [ + new Node\Arg($call) + ] + ); + + } else { + // static::assertSame($expectation, $call); + $rewrittenAssertion = new Node\Expr\StaticCall( + new Node\Name('static'), + $mappedAssertions[$assertion], + [ + new Node\Arg($expectation), + new Node\Arg($call) + ] + ); + } + + $node->expr = $rewrittenAssertion; + } +} diff --git a/lib/Transunit/Pass/CallTestSubjectPass.php b/lib/Transunit/Pass/CallTestSubjectPass.php new file mode 100644 index 0000000..1fcf503 --- /dev/null +++ b/lib/Transunit/Pass/CallTestSubjectPass.php @@ -0,0 +1,71 @@ +findInstanceOf($ast, Node\Stmt\ClassMethod::class); + } + + public function rewrite(Node $node): void + { + if (!$node instanceof Node\Stmt\ClassMethod) { + return; + } + + if (in_array($node->name->toString(), ['setUp', 'let'])) { + return; + } + + foreach ($node->stmts as $stmt) { + if (!$stmt instanceof Node\Stmt\Expression) { + continue; + } + + $resolvedCall = $stmt->expr; + + if ($resolvedCall instanceof Node\Expr\Assign) { + $resolvedCall = $resolvedCall->expr; + } + + if (!$resolvedCall instanceof Node\Expr\MethodCall) { + continue; + } + + if ($resolvedCall->var instanceof Node\Expr\MethodCall) { + $resolvedCall = $resolvedCall->var; + } + + $this->callMethodOnTestSubject($resolvedCall); + } + } + + /** + * $this->_testSubject->doSomething(); + */ + private function callMethodOnTestSubject(Node\Expr\MethodCall $stmt): void + { + if (!$stmt->var instanceof Node\Expr\Variable) { + return; + } + + if ('this' !== $stmt->var->name) { + return; + } + + if ($stmt->name->toString() === 'prophesize') { + return; + } + + $stmt->var = new Node\Expr\PropertyFetch( + new Node\Expr\Variable('this'), + '_testSubject' + ); + } +} diff --git a/lib/Transunit/Pass/ClassnamePass.php b/lib/Transunit/Pass/ClassnamePass.php new file mode 100644 index 0000000..e4d4a94 --- /dev/null +++ b/lib/Transunit/Pass/ClassnamePass.php @@ -0,0 +1,55 @@ +findFirstInstanceOf($ast, Class_::class)]; + } + + public function rewrite(Node $node): void + { + if (!$node instanceof Class_) { + return; + } + + $this->renameClass($node); + $this->changeExtendedClass($node); + $this->useProphecyTrait($node); + } + + private function renameClass(Class_ $node): void + { + $sourceClassname = $node->name->toString(); + + if (substr($sourceClassname, -4) !== 'Spec') { + return; + } + + $targetClassname = substr_replace($sourceClassname, '', -4).'Test'; + + $node->name = new Node\Identifier($targetClassname); + } + + private function changeExtendedClass(Class_ $node): void + { + if ($node->extends->toString() === 'ObjectBehavior') { + $node->extends = new Node\Name('TestCase'); + } + } + + private function useProphecyTrait(Class_ $node): void + { + $node->stmts = array_merge( + [new Node\Stmt\TraitUse([new Node\Name('ProphecyTrait')])], + $node->stmts + ); + } +} diff --git a/lib/Transunit/Pass/GlobalCollaboratorPass.php b/lib/Transunit/Pass/GlobalCollaboratorPass.php new file mode 100644 index 0000000..1ce720c --- /dev/null +++ b/lib/Transunit/Pass/GlobalCollaboratorPass.php @@ -0,0 +1,149 @@ +findFirstInstanceOf($ast, Node\Stmt\Class_::class)]; + } + + public function rewrite(Node $node): void + { + if (!$node instanceof Node\Stmt\Class_) { + return; + } + + $this->declareGlobalCollaborators($node); + } + + /** + * get the args of a class method named 'let' or 'setUp' and declare them as class properties + */ + private function declareGlobalCollaborators(Node\Stmt\Class_ $node): void + { + $classMethodNodes = $node->getMethods(); + $globalCollaborators = []; + + $useProphecyTrait = array_shift($node->stmts); + + foreach ($classMethodNodes as $classMethodNode) { + if (!in_array($classMethodNode->name->toString(), ['let', 'setUp'], true)) { + continue; + } + + foreach ($classMethodNode->params as $param) { + $globalCollaborators[$param->var->name] = $param->type; + + array_unshift( + $node->stmts, + new Node\Stmt\Property( + Modifiers::PRIVATE, + [ + new Node\Stmt\PropertyProperty($param->var->name), + ], + [], + // from PHP 8.0 + new Node\UnionType( + [ + new Node\Identifier('ObjectProphecy'), + new Node\Identifier($param->type), + ] + ) + ) + ); + + $context['collaborators'][$param->var->name] = $param->type; + } + } + + foreach ($classMethodNodes as $classMethodNode) { + if (in_array($classMethodNode->name->toString(), ['let', 'setUp'], true)) { + continue; + } + + $currentParams = $classMethodNode->getParams(); + $newParams = []; + + foreach ($currentParams as $param) { + if (!array_key_exists($param->var->name, $globalCollaborators)) { + $newParams[] = $param; + continue; + } + + // iterate through each expression and find any variables that match + // $param->var->name and replace them with $this->{param->var->name} + $stmts = $classMethodNode->getStmts(); + + $newStmts = []; + + foreach ($stmts as $i => $stmt) { + $newStmts[] = $stmt; + + if (!$stmt instanceof Node\Stmt\Expression) { + continue; + } + + if (!$stmt->expr instanceof Node\Expr\MethodCall) { + continue; + } + + // ->willReturn() + // ->shouldBeCalled() + + if (!$stmt->expr->var instanceof Node\Expr\MethodCall) { + continue; + } + + // ->stubbedMethod()->willReturn() + // ->mockedCall()->shouldBeCalled() + + if (!$stmt->expr->var->var instanceof Node\Expr\Variable) { + continue; + } + + // $collaborator->stubbedMethod()->willReturn() + // $collaborator->mockedCall()->shouldBeCalled() + + if ($stmt->expr->var->var->name !== $param->var->name) { + continue; + } + + // $this->collaborator->stubbedMethod()->willReturn() + + array_pop($newStmts); + + $newStmts[] = new Node\Stmt\Expression( + // ->willReturn() + new Node\Expr\MethodCall( + // ->stubbedMethod() + new Node\Expr\MethodCall( + // ->collaborator + new Node\Expr\PropertyFetch( + new Node\Expr\Variable('this'), + $param->var->name + ), + $stmt->expr->var->name, + $stmt->expr->var->args + ), + $stmt->expr->name, + $stmt->expr->args + ) + ); + } + + $classMethodNode->stmts = $newStmts; + } + + $classMethodNode->params = $newParams; + } + + array_unshift($node->stmts, $useProphecyTrait); + } +} diff --git a/lib/Transunit/Pass/GlobalTestSubjectInstancePass.php b/lib/Transunit/Pass/GlobalTestSubjectInstancePass.php new file mode 100644 index 0000000..e2e74a9 --- /dev/null +++ b/lib/Transunit/Pass/GlobalTestSubjectInstancePass.php @@ -0,0 +1,129 @@ +findFirstInstanceOf($ast, Node\Stmt\Class_::class)]; + } + + public function rewrite(Node $node): void + { + if (!$node instanceof Node\Stmt\Class_) { + return; + } + + $testClassname = $node->name->toString(); + $subjectClassname = substr($testClassname, 0, -4); + + $setupMethod = null; + foreach ($node->stmts as $stmt) { + if ( + $stmt instanceof Node\Stmt\ClassMethod + && in_array($stmt->name->toString(), ['setUp', 'let'], true) + ) { + $setupMethod = $stmt; + break; + } + } + + $useProphecyTrait = array_shift($node->stmts); + + if (!$setupMethod) { + // Add setUp method + $setupMethod = new Node\Stmt\ClassMethod('setUp', [ + 'type' => Modifiers::PROTECTED, + 'stmts' => [ + $this->writeInstantiation($subjectClassname), + ], + 'returnType' => new Node\Name('void') + ]); + + array_unshift($node->stmts, $setupMethod); + } + + $this->instantiateTestSubject($setupMethod, $subjectClassname); + $this->declareTestSubjectAsClassProperty($node, $subjectClassname); + + array_unshift($node->stmts, $useProphecyTrait); + } + + private function declareTestSubjectAsClassProperty(Node\Stmt\Class_ $node, string $subjectClassname): void + { + $testSubjectProperty = new Node\Stmt\Property( + Modifiers::PRIVATE, + [new Node\Stmt\PropertyProperty('_testSubject')], + [], + new Node\Name($subjectClassname) + ); + + array_unshift($node->stmts, $testSubjectProperty); + } + + private function instantiateTestSubject(Node\Stmt\ClassMethod $node, string $subjectClassname): void + { + $collaboratorNames = []; + + foreach ($node->stmts as $stmt) { + // Check if expression is a property assignment + if ($stmt instanceof Node\Stmt\Expression + && $stmt->expr instanceof Node\Expr\Assign + && $stmt->expr->var instanceof Node\Expr\PropertyFetch + && $stmt->expr->var->var instanceof Node\Expr\Variable + && $stmt->expr->var->var->name === 'this' + ) { + $collaboratorNames[$stmt->expr->var->name->name] = true; + } + } + + $globalCollaborators = []; + + foreach ($collaboratorNames as $collaborator => $v) { + // call $this->{$collaborator}->reveal() + $globalCollaborators[] = new Node\Arg(new Node\Expr\MethodCall( + new Node\Expr\PropertyFetch( + new Node\Expr\Variable('this'), + $collaborator + ), + 'reveal' + )); + } + + $node->stmts = array_map(function ($stmt) use ($subjectClassname, $globalCollaborators) { + if ($stmt instanceof Node\Stmt\Expression + && $stmt->expr instanceof Node\Expr\MethodCall + && $stmt->expr->var instanceof Node\Expr\Variable + && $stmt->expr->var->name === 'this' + && $stmt->expr->name->toString() === 'beConstructedWith' + ) { + // replace $this->beConstructedWith(...) with $this->_testSubject = new TestSubject(...) + return $this->writeInstantiation($subjectClassname, $globalCollaborators); + } + + return $stmt; + }, $node->stmts); + } + + private function writeInstantiation(string $subjectClassname, array $globalCollaborators = []): Node\Stmt\Expression + { + return new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\PropertyFetch( + new Node\Expr\Variable('this'), + '_testSubject' + ), + new Node\Expr\New_( + new Node\Name($subjectClassname), + $globalCollaborators + ) + ) + ); + } +} diff --git a/lib/Transunit/Pass/NamespacePass.php b/lib/Transunit/Pass/NamespacePass.php new file mode 100644 index 0000000..31c3a42 --- /dev/null +++ b/lib/Transunit/Pass/NamespacePass.php @@ -0,0 +1,99 @@ +findFirstInstanceOf($ast, Namespace_::class)]; + } + + public function rewrite(Node $node): void + { + if (!$node instanceof Namespace_) { + return; + } + + $this->moveToNamespace($node); + $this->importSubjectClass($node); + $this->importMockingLibraryClasses($node); + } + + /** + * namespace tests\unit\Foo\Bar; + */ + private function moveToNamespace(Namespace_ $node): void + { + $ns = $node->name->getParts(); + + if ($ns[0] === 'spec') { + array_shift($ns); + $ns = array_merge(['tests', 'unit'], $ns); + } + + $node->name = new Name($ns); + } + + /** + * use Foo\Bar\Baz; + */ + private function importSubjectClass(Namespace_ $node): void + { + $ns = $node->name->getParts(); + + if ($ns[0] === 'tests' && $ns[1] === 'unit') { + array_shift($ns); + array_shift($ns); + } + + $fcqn = implode('\\', $ns); + + // check if already imported + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Use_) { + foreach ($stmt->uses as $use) { + if ($use->name->toString() === $fcqn) { + return; + } + } + } + } + + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Class_) { + $testClassname = $stmt->name->toString(); + $ns[] = substr($testClassname, 0, -4); + + break; + } + } + + $use = new Node\Stmt\Use_([ + new Node\Stmt\UseUse(new Name($fcqn)), + ]); + + array_unshift($node->stmts, $use); + } + + private function importMockingLibraryClasses(Namespace_ $node): void + { + array_unshift($node->stmts, new Node\Stmt\Use_([ + new Node\Stmt\UseUse(new Name('Prophecy\Prophecy\ObjectProphecy')), + ])); + + array_unshift($node->stmts, new Node\Stmt\Use_([ + new Node\Stmt\UseUse(new Name('Prophecy\PhpUnit\ProphecyTrait')), + ])); + + array_unshift($node->stmts, new Node\Stmt\Use_([ + new Node\Stmt\UseUse(new Name('PHPUnit\Framework\TestCase')), + ])); + } +} diff --git a/lib/Transunit/Pass/TestMethodPass.php b/lib/Transunit/Pass/TestMethodPass.php new file mode 100644 index 0000000..6ebfc75 --- /dev/null +++ b/lib/Transunit/Pass/TestMethodPass.php @@ -0,0 +1,178 @@ +find($ast, function (Node $node) { + if ($node instanceof Node\Stmt\ClassMethod && !in_array($node->name->toString(), ['setUp', 'let'],true)) { + return $node; + } + + return null; + }); + } + + public function rewrite(Node $node): void + { + if (!$node instanceof Node\Stmt\ClassMethod) { + return; + } + + $this->renameSetup($node); + $this->addPrefix($node); + $this->prophesizeGlobalCollaborators($node); + $this->prophesizeLocalCollaborators($node); + } + + /** + * public function test_it_should_do_something(): void { ... } + */ + private function addPrefix(Node\Stmt\ClassMethod $node): void + { + $methodName = $node->name->toString(); + + if (0 !== strpos($methodName, 'it_')) { + return; + } + + $node->name = new Node\Identifier('test_' . $methodName); + $node->flags = Modifiers::PUBLIC; + $node->returnType = new Node\Name('void'); + } + + /** + * protected function setUp(): void { ... } + */ + private function renameSetup(Node\Stmt\ClassMethod $node): void + { + if ($node->name->toString() !== 'let') { + return; + } + + $node->name = new Node\Identifier('setUp'); + $node->flags = Modifiers::PROTECTED; + } + + /** + * protected function setUp(): void { + * $this->classCollaborator = $this->prophesize(ClassCollaborator::class); + * } + */ + private function prophesizeGlobalCollaborators(Node\Stmt\ClassMethod $node): void + { + if (!in_array($node->name->toString(), ['let', 'setUp'], true)) { + return; + } + + $newStmts = []; + + while (count($node->params) > 0) { + $param = array_pop($node->params); + + $newStmts[] = new Node\Stmt\Expression( + new Node\Expr\Assign( + // assign class property + new Node\Expr\PropertyFetch( + new Node\Expr\Variable('this'), + $param->var->name + ), + new Node\Expr\MethodCall( + new Node\Expr\Variable('this'), + 'prophesize', + [ + new Node\Arg(new Node\Expr\ClassConstFetch( + new Node\Name($param->type->toString()), + 'class' + )), + ] + ) + ) + ); + + foreach ($node->stmts as $currentStmt) { + $newStmts[] = $currentStmt; + } + } + + $node->stmts = $newStmts; + } + + /** + * public function test_it_should_do_something(): void { + * $methodCollaborator = $this->prophesize(MethodCollaborator::class); + * ... + * } + */ + private function prophesizeLocalCollaborators(Node\Stmt\ClassMethod $node): void + { + if (in_array($node->name->toString(), ['let', 'setUp'], true)) { + return; + } + + $localCollaborators = []; + + foreach ($node->params as $param) { + $variableName = $param->var->name; + $localCollaborators[$variableName] = $param->type->toString(); + + array_unshift($node->stmts, new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\Variable($variableName), + new Node\Expr\MethodCall( + new Node\Expr\Variable('this'), + 'prophesize', + [ + new Node\Arg(new Node\Expr\ClassConstFetch( + new Node\Name($param->type->toString()), + 'class' + )), + ] + ), + ) + )); + } + + $node->params = []; + + $nodeFinder = new NodeFinder(); + + // find all variables where the variable name matches the key within $localCollaborators, + // and modify it to call ->reveal() on the variable: + + $args = $nodeFinder->findInstanceOf($node->stmts, Node\Arg::class); + + $args = array_filter($args, function ($a) use ($localCollaborators) { + if (!$a->value instanceof Node\Expr\Variable) { + return false; + } + + if (!array_key_exists($a->value->name, $localCollaborators)) { + return false; + } + + return true; + }); + + if (empty($args)) { + return; + } + + // call ->reveal() on the collaborator instance. + foreach ($args as $arg) { + $collaboratorVariable = $arg->value; + $arg->value = new Node\Expr\MethodCall( + $collaboratorVariable, + 'reveal', + [] + ); + } + } +} diff --git a/lib/Transunit/Transunit.php b/lib/Transunit/Transunit.php new file mode 100644 index 0000000..0d2278b --- /dev/null +++ b/lib/Transunit/Transunit.php @@ -0,0 +1,74 @@ +files()->in($path)->name('*Spec.php'); + + $root = dirname(__DIR__, 2); + $exportDir = "{$root}/var"; + $f->remove($exportDir); + $f->mkdir($exportDir); + + foreach ($specs as $file) { + $newFilename = substr($file->getBasename(), 0, -8) . 'Test.php'; + $modifiedCode = self::processFile($file->getRealPath()); + $fullPathToNewTestFile = "{$exportDir}/{$newFilename}"; + $f->touch($fullPathToNewTestFile); + file_put_contents($fullPathToNewTestFile, $modifiedCode); + } + } + + private static function processFile(string $path): string + { + $phpCode = @file_get_contents($path); + + if (null === $phpCode) { + throw new \RuntimeException('Failed to read file: ' . $path); + } + + $parser = (new ParserFactory)->createForHostVersion(); + $sourceAst = $parser->parse($phpCode); + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new CloningVisitor()); + + $newStmts = $traverser->traverse($sourceAst); + + $nodeFinder = new NodeFinder(); + $passes = [ + new Pass\NamespacePass(), + new Pass\ClassnamePass(), + new Pass\GlobalCollaboratorPass(), + new Pass\GlobalTestSubjectInstancePass(), + new Pass\CallTestSubjectPass(), + new Pass\AssertionPass(), + new Pass\TestMethodPass(), + ]; + + /** @var Pass $pass */ + foreach ($passes as $pass) { + $nodes = $pass->find($nodeFinder, $newStmts); + foreach ($nodes as $node) { + $pass->rewrite($node); + } + } + + $prettyPrinter = new PrettyPrinter\Standard(); + $modifiedCode = $prettyPrinter->printFormatPreserving($newStmts, $sourceAst, $parser->getTokens()); + + return $modifiedCode; + } +} diff --git a/transunit.php b/transunit.php new file mode 100644 index 0000000..9658734 --- /dev/null +++ b/transunit.php @@ -0,0 +1,8 @@ +