diff --git a/src/Rector/TestCaseAutoMockRefactorRector.php b/src/Rector/TestCaseAutoMockRefactorRector.php new file mode 100644 index 0000000..3bd1731 --- /dev/null +++ b/src/Rector/TestCaseAutoMockRefactorRector.php @@ -0,0 +1,443 @@ +commentsManager = $this->createMock(ICommentsManager::class); + + $this->sorter = new CommentersSorter($this->commentsManager); + } + + public function testSort(): void { + $this->commentsManager->expects($this->once()) + ->method('getForObject') + ->willReturn([]); + + $this->assertEquals(true, $this->sorter->testedMethod()); + } +} +CODE_SAMPLE, + <<<'CODE_SAMPLE' +class CommentersSorterTest extends TestCase { + protected CommentersSorter $sorter; + + #[\Override] + protected function setUp(): void { + parent::setUp(); + + $this->sorter = $this->createInstanceWithMocks(CommentersSorter::class); + } + + public function testSort(): void { + $this->mocks[ICommentsManager::class]->expects($this->once()) + ->method('getForObject') + ->willReturn([]); + + $this->assertEquals(true, $this->sorter->testedMethod()); + } +} +CODE_SAMPLE + , + [ + new TestCaseAutoMockRefactorRector(), + ], + ), + ], + ); + } + + /** + * @return array> + */ + #[Override] + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + * + * @psalm-suppress MoreSpecificImplementedParamType + */ + #[Override] + public function refactor(Node $node): ?Node + { + if (!($node instanceof Class_)) { + return null; + } + + if (!isset($node->extends) || ((string) $node->extends !== 'Test\TestCase')) { + // Not extending Nextcloud TestCase, skip + return null; + } + + $setupMethod = $node->getMethod('setUp'); + if (!$setupMethod instanceof ClassMethod) { + return null; + } + + $constructorCall = $this->collectConstructorCall($setupMethod); + if ($constructorCall === null || !($constructorCall->class instanceof Node\Name)) { + return null; + } + + if ($constructorCall->class->getLast() . 'Test' !== (string) $node->name) { + // The test class name does not match the tested class + return null; + } + + $mockedServices = iterator_to_array($this->collectMockedServices($constructorCall)); + if ($mockedServices === []) { + return null; + } + + $mockAssignments = $this->collectMockAssignments($setupMethod, $mockedServices); + if ($mockAssignments === []) { + return null; + } + + if (count($mockAssignments) !== count($constructorCall->args)) { + // For now we skip the complicated cases + return null; + } + + $this->replaceStatement( + $setupMethod, + $constructorCall, + new MethodCall( + new Node\Expr\Variable('this'), + 'createInstanceWithMocks', + [ + new Arg(new ClassConstFetch($constructorCall->class, 'class')), + ], + ), + ); + $this->removeStatements( + $setupMethod, + $mockAssignments, + ); + $this->removePropertiesByName( + $node, + array_keys($mockedServices), + ); + $constFetchMap = []; + foreach ($mockAssignments as $mockAssignment) { + if ( + $mockAssignment->expr instanceof Assign + && $mockAssignment->expr->var instanceof PropertyFetch + && $mockAssignment->expr->var->name instanceof Node\Identifier + && $mockAssignment->expr->expr instanceof MethodCall + && $mockAssignment->expr->expr->args[0] instanceof Arg + && $mockAssignment->expr->expr->args[0]->value instanceof ClassConstFetch + ) { + $constFetchMap[$mockAssignment->expr->var->name->name] = $mockAssignment->expr->expr->args[0]->value; + } + } + $this->replaceMethodCallsToMocks( + $node, + $constFetchMap, + ); + $this->reorderSetupStatements($setupMethod); + + return $node; + } + + private function collectConstructorCall(ClassMethod $setupMethod): ?Node\Expr\New_ + { + $stmts = $setupMethod->getStmts(); + $nodeFinder = new NodeFinder(); + if ($stmts === null) { + return null; + } + + /** + * @var ?Node\Expr\New_ + */ + return $nodeFinder->findFirst($stmts, fn (Node $node) => $node instanceof Node\Expr\New_); + } + + /** + * @return Generator + */ + private function collectMockedServices(Node\Expr\New_ $constructorCall): Generator + { + foreach ($constructorCall->args as $arg) { + if (!($arg instanceof Arg)) { + continue; + } + if (!($arg->value instanceof PropertyFetch)) { + continue; + } + if (!($arg->value->var instanceof Variable)) { + continue; + } + if (!($arg->value->var->name === 'this')) { + continue; + } + if (!($arg->value->name instanceof Node\Identifier)) { + continue; + } + yield $arg->value->name->name => $arg->value; + } + } + + /** + * @param array $constructorArgs + * + * @return list + */ + private function collectMockAssignments(ClassMethod $setupMethod, array $constructorArgs): array + { + $stmts = $setupMethod->getStmts(); + $nodeFinder = new NodeFinder(); + if ($stmts === null) { + return []; + } + + /** + * @var list $exprs + */ + $exprs = $nodeFinder->find($stmts, fn (Node $node) => $node instanceof Expression + && $node->expr instanceof Assign + && $node->expr->var instanceof PropertyFetch + && $node->expr->var->var instanceof Variable + && $node->expr->var->var->name === 'this' + && $node->expr->expr instanceof MethodCall + && $node->expr->expr->var instanceof Variable + && $node->expr->expr->var->name === 'this' + && $node->expr->expr->name instanceof Node\Identifier + && $node->expr->expr->name->name === 'createMock' + && $node->expr->var->name instanceof Node\Identifier + && isset($constructorArgs[$node->expr->var->name->name])); + + return $exprs; + } + + private function replaceStatement(ClassMethod $node, Node $search, Node $replace): void + { + $this->traverseWithVisitor($node, new class ($search, $replace) extends NodeVisitorAbstract { + public function __construct( + private Node $search, + private Node $replace, + ) { + } + + public function leaveNode(Node $node): ?Node + { + if ($node === $this->search) { + return $this->replace; + } + + return null; + } + }); + } + + /** + * @param list $toRemove + */ + private function removeStatements(ClassMethod $node, array $toRemove): void + { + $this->traverseWithVisitor($node, new class ($toRemove) extends NodeVisitorAbstract { + /** + * @param list $toRemove + */ + public function __construct( + private array $toRemove, + ) { + } + + public function leaveNode(Node $node): ?int + { + if (in_array($node, $this->toRemove, true)) { + return NodeTraverser::REMOVE_NODE; + } + + return null; + } + }); + } + + /** + * @param list $toRemove + */ + private function removePropertiesByName(Class_ $node, array $toRemove): void + { + $this->traverseWithVisitor($node, new class ($toRemove) extends NodeVisitorAbstract { + /** + * @param list $toRemove + */ + public function __construct( + private array $toRemove, + ) { + } + + public function leaveNode(Node $node): ?int + { + if ($node instanceof Property && in_array($node->props[0]->name->name, $this->toRemove, true)) { + return NodeTraverser::REMOVE_NODE; + } + + return null; + } + }); + } + + /** + * @param array $mocks + */ + private function replaceMethodCallsToMocks(Class_ $node, array $mocks): void + { + $this->traverseWithVisitor($node, new class ($mocks) extends NodeVisitorAbstract { + /** + * @param array $mocks + */ + public function __construct( + private array $mocks, + ) { + } + + public function leaveNode(Node $node): ?Node + { + if ( + $node instanceof PropertyFetch + && $node->var instanceof Variable + && $node->var->name === 'this' + && $node->name instanceof Node\Identifier + && isset($this->mocks[$node->name->name]) + ) { + return new ArrayDimFetch( + new PropertyFetch( + new Variable('this'), + 'mocks', + ), + // We reuse the ClassConstFetch from the createMock args + $this->mocks[$node->name->name], + ); + } + + return null; + } + }); + } + + private function traverseWithVisitor(Class_ | ClassMethod $node, NodeVisitor $visitor): void + { + if ($node->stmts === null) { + return; + } + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + + /** @var list $newStmts */ + $newStmts = $traverser->traverse($node->stmts); + $node->stmts = $newStmts; + } + + private function reorderSetupStatements(ClassMethod $setupMethod): void + { + /** + * @var list|null $stmts + */ + $stmts = $setupMethod->getStmts(); + $nodeFinder = new NodeFinder(); + if ($stmts === null) { + return; + } + + $firstMockUse = -1; + $firstCreateUse = -1; + foreach ($stmts as $key => $stmt) { + if ($firstMockUse < 0) { + // Search in subnodes + $mockArrayUse = $nodeFinder->findFirst( + $stmt, + fn (Node $node) => $node instanceof ArrayDimFetch + && $node->var instanceof PropertyFetch + && $node->var->var instanceof Variable + && $node->var->var->name === 'this' + && $node->var->name instanceof Node\Identifier + && $node->var->name->name === 'mocks', + ); + if ($mockArrayUse !== null) { + $firstMockUse = $key; + } + } + if ($firstCreateUse < 0) { + // Search in subnodes + $createUse = $nodeFinder->findFirst( + $stmt, + fn (Node $node) => $node instanceof MethodCall + && $node->name instanceof Node\Identifier + && $node->name->name === 'createInstanceWithMocks' + && $node->var instanceof Variable + && $node->var->name === 'this', + ); + if ($createUse !== null) { + $firstCreateUse = $key; + } + } + } + if ($firstCreateUse < 0 || $firstMockUse < 0 || $firstCreateUse < $firstMockUse) { + // No reorder needed + return; + } + // Remove create + $create = array_splice($stmts, $firstCreateUse, 1); + array_splice($stmts, $firstMockUse, 0, $create); + $setupMethod->stmts = $stmts; + } +} diff --git a/tests/Rector/TestCaseAutoMockRefactorRector/Fixture/test_fixture_fetch.php.inc b/tests/Rector/TestCaseAutoMockRefactorRector/Fixture/test_fixture_fetch.php.inc new file mode 100644 index 0000000..2313887 --- /dev/null +++ b/tests/Rector/TestCaseAutoMockRefactorRector/Fixture/test_fixture_fetch.php.inc @@ -0,0 +1,98 @@ +commentsManager = $this->createMock(ICommentsManager::class); + $this->commentsManager->method('m') + ->expects($this->any()) + ->willReturn(true); + + $this->sorter = new CommentersSorter($this->commentsManager); + } + + /** + * @param $data + */ + #[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'sortDataProvider')] + public function testSort($data): void { + $this->commentsManager->expects($this->once()) + ->method('getForObject') + ->willReturn([]); + + $workArray = $data['input']; + $this->sorter->sort($workArray, ['itemType' => 'files', 'itemId' => '24']); + + $this->assertEquals($data['expected'], $workArray); + } +} + +?> +----- +sorter = $this->createInstanceWithMocks(CommentersSorter::class); + $this->mocks[ICommentsManager::class]->method('m') + ->expects($this->any()) + ->willReturn(true); + } + + /** + * @param $data + */ + #[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'sortDataProvider')] + public function testSort($data): void { + $this->mocks[ICommentsManager::class]->expects($this->once()) + ->method('getForObject') + ->willReturn([]); + + $workArray = $data['input']; + $this->sorter->sort($workArray, ['itemType' => 'files', 'itemId' => '24']); + + $this->assertEquals($data['expected'], $workArray); + } +} + +?> diff --git a/tests/Rector/TestCaseAutoMockRefactorRector/TestCaseAutoMockRefactorRectorTest.php b/tests/Rector/TestCaseAutoMockRefactorRector/TestCaseAutoMockRefactorRectorTest.php new file mode 100644 index 0000000..91fef2f --- /dev/null +++ b/tests/Rector/TestCaseAutoMockRefactorRector/TestCaseAutoMockRefactorRectorTest.php @@ -0,0 +1,33 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/config.php'; + } +} diff --git a/tests/Rector/TestCaseAutoMockRefactorRector/config/config.php b/tests/Rector/TestCaseAutoMockRefactorRector/config/config.php new file mode 100644 index 0000000..dee61b7 --- /dev/null +++ b/tests/Rector/TestCaseAutoMockRefactorRector/config/config.php @@ -0,0 +1,16 @@ +withRules([ + TestCaseAutoMockRefactorRector::class, + ]);