Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PHP-Parser 应用之生成源代码的单元测试方法 #50

Open
guanguans opened this issue Oct 13, 2022 · 0 comments
Open

PHP-Parser 应用之生成源代码的单元测试方法 #50

guanguans opened this issue Oct 13, 2022 · 0 comments
Labels
2022 2022 PHP PHP

Comments

@guanguans
Copy link
Owner

guanguans commented Oct 13, 2022

PHP-Parser 应用之生成源代码的单元测试方法

PHP-Parser 是由 nikic 开发的一个 PHP 抽象语法树(AST)解析器,可方便的将代码与抽象语法树互相转换。工程上常用来生成模板代码(如 rector)、生成抽象语法树进行静态分析(如 phpstan)。最近学习应用(生成模板代码)了一下,编写了一个简单的生成源代码的单元测试方法的命令(GenerateTestsCommand)。

效果

generate-tests-command

generated-tests

流程概述

  1. 扫描拿到指定目录的 PHP 文件结果集
  2. 提取文件内容转化为抽象语法树
  3. 遍历抽象语法树节点
  4. 从源类节点提取测试类的基本信息
  5. 获取需要生成的测试方法节点
  6. 遍历修改模板测试类(或源测试类)抽象语法树
  7. 打印输出修改后测试类语法树
  8. 输出统计信息

GenerateTestsCommand

<?php

/**
 * This file is part of the guanguans/laravel-skeleton.
 *
 * (c) guanguans <ityaozm@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled.
 *
 * @see https://github.com/guanguans/laravel-skeleton
 */

namespace App\Console\Commands;

use Composer\XdebugHandler\XdebugHandler;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use PhpParser\BuilderFactory;
use PhpParser\Error;
use PhpParser\ErrorHandler\Collecting;
use PhpParser\JsonDecoder;
use PhpParser\Lexer\Emulative;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\NodeDumper;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\CloningVisitor;
use PhpParser\NodeVisitor\NodeConnectingVisitor;
use PhpParser\NodeVisitor\ParentConnectingVisitor;
use PhpParser\NodeVisitorAbstract;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter\Standard;
use ReflectionClass;
use ReflectionMethod;
use RuntimeException;
use SebastianBergmann\Timer\ResourceUsageFormatter;
use SebastianBergmann\Timer\Timer;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;

class GenerateTestsCommand extends Command
{
    /** @var string */
    protected $signature = '
        generate:tests
        {--dir=* : The directories to search for files}
        {--path=* : The paths to search for files}
        {--name=* : The names to search for files}
        {--not-path=* : The paths to exclude from the search}
        {--not-name=* : The names to exclude from the search}
        {--base-namespace=Tests\Unit : The base namespace for the generated tests}
        {--base-dir=./tests/Unit/ : The base directory for the generated tests}
        {--t|template-file=./tests/Unit/ExampleTest.php : The template file to use for the generated tests}
        {--f|method-format=snake : The format(snake/camel) to use for the method names}
        {--m|parse-mode=1 : The mode(1,2,3,4) to use for the PHP parser}
        {--M|memory-limit= : The memory limit to use for the PHP parser}';
    /** @var string */
    protected $description = 'Generate tests for the given files';
    /** @var array */
    private static $statistics = [
        'scanned_files' => 0,
        'scanned_classes' => 0,
        'related_classes' => 0,
        'added_methods' => 0
    ];

    /** @var \Symfony\Component\Finder\Finder */
    private $fileFinder;
    /** @var \SebastianBergmann\Timer\ResourceUsageFormatter */
    private $resourceUsageFormatter;
    /** @var \PhpParser\Lexer\Emulative */
    private $lexer;
    /** @var \PhpParser\Parser */
    private $parser;
    /** @var \PhpParser\ErrorHandler\Collecting */
    private $errorHandler;
    /** @var \PhpParser\BuilderFactory */
    private $builderFactory;
    /** @var \PhpParser\NodeFinder */
    private $nodeFinder;
    /** @var \PhpParser\NodeDumper */
    private $nodeDumper;
    /** @var \PhpParser\JsonDecoder */
    private $jsonDecoder;
    /** @var \PhpParser\PrettyPrinter\Standard */
    private $prettyPrinter;
    /** @var \PhpParser\NodeTraverser */
    private $nodeTraverser;
    /** @var \PhpParser\NodeVisitor\ParentConnectingVisitor */
    private $parentConnectingVisitor;
    /** @var \PhpParser\NodeVisitor\NodeConnectingVisitor */
    private $nodeConnectingVisitor;
    /** @var \PhpParser\NodeVisitor\CloningVisitor */
    private $cloningVisitor;
    private $classUpdatingVisitor;

    protected function initialize(InputInterface $input, OutputInterface $output)
    {
        $this->checkOptions();
        $this->initializeEnvs();
        $this->initializeProperties();
    }

    public function handle(Timer $timer)
    {
        $timer->start();
        $this->withProgressBar($this->fileFinder, function (SplFileInfo $fileInfo) {
            try {
                $originalNodes = $this->parser->parse($fileInfo->getContents());
            } catch (Error $e) {
                $this->newLine();
                $this->error(sprintf("The file of %s parse error: %s.", $fileInfo->getRealPath(), $e->getMessage()));

                return;
            }

            $originalNamespaceNodes = $this->nodeFinder->find($originalNodes, function (Node $node) {
                return $node instanceof Node\Stmt\Namespace_ && $node->name;
            });

            /** @var Node\Stmt\Namespace_ $originalNamespaceNode */
            foreach ($originalNamespaceNodes as $originalNamespaceNode) {
                $originalClassNamespace = $originalNamespaceNode->name->toString();

                /** @var Class_[]|Trait_[] $originalClassNodes */
                $originalClassNodes = $this->nodeFinder->find($originalNamespaceNode, function (Node $node) {
                    return ($node instanceof Class_ || $node instanceof Trait_) && $node->name;
                });
                self::$statistics['scanned_classes'] += count($originalClassNodes);
                foreach ($originalClassNodes as $originalClassNode) {
                    // 准备基本信息
                    $testClassNamespace = Str::finish($this->option('base-namespace'), '\\').$originalClassNamespace;
                    $testClassName = "{$originalClassNode->name->name}Test";
                    $testClassFullName = $testClassNamespace.'\\'.$testClassName;
                    $testClassBaseName = str_replace('\\', DIRECTORY_SEPARATOR, $originalClassNamespace);
                    $testClassFile = Str::finish($this->option('base-dir'), DIRECTORY_SEPARATOR). $testClassBaseName.DIRECTORY_SEPARATOR."$testClassName.php";

                    // 获取需要生成的测试方法节点
                    $testClassAddedMethodNodes = array_map(function (ClassMethod $node) {
                        return tap(
                            $this->builderFactory
                                ->method(Str::{$this->option('method-format')}('test_' . Str::snake($node->name->name)))
                                ->makePublic()
                                ->getNode()
                        )->setAttribute('isAdded', true);
                    }, array_filter($originalClassNode->getMethods(), function (ClassMethod $node) {
                        return $node->isPublic() && ! $node->isAbstract() && $node->name->toString() !== '__construct';
                    }));
                    if ($isExistsTestClassFile = file_exists($testClassFile)) {
                        $originalTestClassMethodNames = array_filter(array_map(function (ReflectionMethod $method) {
                            return Str::{$this->option('method-format')}($method->getName());
                        }, (new ReflectionClass($testClassFullName))->getMethods(ReflectionMethod::IS_PUBLIC)), function ($name) {
                            return Str::startsWith($name, 'test');
                        });

                        $testClassAddedMethodNodes = array_filter($testClassAddedMethodNodes, function (ClassMethod $node) use ($originalTestClassMethodNames) {
                            return ! in_array($node->name->name, $originalTestClassMethodNames, true);
                        });
                        if (empty($testClassAddedMethodNodes)) {
                            continue;
                        }
                    }

                    // 修改抽象语法树(遍历节点)
                    $originalTestClassNodes = $this->parser->parse(
                        $isExistsTestClassFile ? file_get_contents($testClassFile) : file_get_contents($this->option('template-file')),
                        $this->errorHandler
                    );

                    $this->classUpdatingVisitor->testClassNamespace = $testClassNamespace;
                    $this->classUpdatingVisitor->testClassName = $testClassName;
                    $this->classUpdatingVisitor->testClassAddedMethodNodes = $testClassAddedMethodNodes;

                    $nodeTraverser = clone $this->nodeTraverser;
                    $nodeTraverser->addVisitor($this->classUpdatingVisitor);
                    $testClassNodes = $nodeTraverser->traverse($originalTestClassNodes);

                    // 打印输出语法树
                    if (! file_exists($testClassDir = dirname($testClassFile)) && ! mkdir($testClassDir, 0755, true) && ! is_dir($testClassDir)) {
                        throw new RuntimeException(sprintf('Directory "%s" was not created', $testClassDir));
                    }
                    file_put_contents($testClassFile, $this->prettyPrinter->printFormatPreserving($testClassNodes, $originalTestClassNodes, $this->lexer->getTokens()));

                    self::$statistics['related_classes']++;
                    self::$statistics['added_methods'] += count($testClassAddedMethodNodes);
                }
            }
        });

        $this->newLine();
        $this->table(array_map(function ($name) {
            return Str::of($name)->snake()->replace('_', ' ')->title();
        }, array_keys(self::$statistics)), [self::$statistics]);

        $this->info($this->resourceUsageFormatter->resourceUsage($timer->stop()));

        return self::SUCCESS;
    }

    protected function checkOptions()
    {
        if (! in_array($this->option('parse-mode'), [
            ParserFactory::PREFER_PHP7,
            ParserFactory::PREFER_PHP5,
            ParserFactory::ONLY_PHP7,
            ParserFactory::ONLY_PHP5])
        ) {
            $this->error('The parse-mode option is not valid(1,2,3,4).');
            exit(1);
        }

        if (! in_array($this->option('method-format'), ['snake','camel'])) {
            $this->error('The method-format option is not valid(snake/camel).');
            exit(1);
        }

        if (! $this->option('base-namespace')) {
            $this->error('The base-namespace option is required.');
            exit(1);
        }

        if (! $this->option('base-dir') || ! file_exists($this->option('base-dir')) || ! is_dir($this->option('base-dir'))) {
            $this->error('The base-dir option is not a valid directory.');
            exit(1);
        }

        if (! $this->option('template-file') || ! file_exists($this->option('template-file')) || ! is_file($this->option('template-file'))) {
            $this->error('The template-file option is not a valid file.');
            exit(1);
        }
    }

    protected function initializeEnvs()
    {
        $xdebug = new XdebugHandler(__CLASS__);
        $xdebug->check();
        unset($xdebug);

        extension_loaded('xdebug') and ini_set('xdebug.max_nesting_level', 2048);
        ini_set('zend.assertions', 0);
        $this->option('memory-limit') and ini_set('memory_limit', $this->option('memory-limit'));
    }

    protected function initializeProperties()
    {
        $this->fileFinder = tap(Finder::create()->files()->ignoreDotFiles(true)->ignoreVCS(true), function (Finder $finder) {
            $methods = [
                'in' => $this->option('dir') ?: [app_path('Services'), app_path('Support'), app_path('Traits')],
                'path' => $this->option('path') ?: [],
                'notPath' => $this->option('not-path') ?: ['tests', 'Tests', 'test', 'Test', 'Macros', 'Facades'],
                'name' => $this->option('name') ?: ['*.php'],
                'notName' => $this->option('not-name') ?: ['*Test.php', '*TestCase.php', '*.blade.php'],
            ];
            foreach ($methods as $method => $parameters) {
                $finder->{$method}($parameters);
            }

            self::$statistics['scanned_files'] = $finder->count();
        });

        $this->resourceUsageFormatter = new ResourceUsageFormatter();
        $this->lexer = new Emulative(['usedAttributes' => ['comments', 'startLine', 'endLine', 'startTokenPos', 'endTokenPos']]);
        $this->parser = (new ParserFactory())->create((int)$this->option('parse-mode'), $this->lexer);
        $this->errorHandler = new Collecting();
        $this->builderFactory = new BuilderFactory();
        $this->nodeFinder = new NodeFinder();
        // $this->nodeDumper = new NodeDumper();
        // $this->jsonDecoder = new JsonDecoder();
        $this->nodeTraverser = new NodeTraverser();
        // $this->parentConnectingVisitor = new ParentConnectingVisitor();
        // $this->nodeConnectingVisitor = new NodeConnectingVisitor();
        $this->cloningVisitor = new CloningVisitor();
        $this->nodeTraverser->addVisitor($this->cloningVisitor);

        $this->classUpdatingVisitor = new class('', '', []) extends NodeVisitorAbstract {
            /** @var string */
            public $testClassNamespace;
            /** @var string */
            public $testClassName;
            /** @var \PhpParser\Node\Stmt\ClassMethod[] */
            public $testClassAddedMethodNodes = [];

            public function __construct(string $testClassNamespace, string $testClassName, array $testClassAddedMethodNodes)
            {
                $this->testClassNamespace = $testClassNamespace;
                $this->testClassName = $testClassName;
                $this->testClassAddedMethodNodes = $testClassAddedMethodNodes;
            }

            public function leaveNode(Node $node)
            {
                if ($node instanceof  Node\Stmt\Namespace_) {
                    $node->name = new Node\Name($this->testClassNamespace);
                }

                if ($node instanceof Node\Stmt\Class_) {
                    $node->name->name = $this->testClassName;
                    $node->stmts = array_merge($node->stmts, $this->testClassAddedMethodNodes);
                }
            }
        };

        $this->prettyPrinter = new class() extends Standard {
            protected function pStmt_ClassMethod(ClassMethod $node)
            {
                return ($node->getAttribute('isAdded') ? $this->nl : '') . parent::pStmt_ClassMethod($node);
            }
        };
    }
}

原文链接

@guanguans guanguans added the PHP PHP label Oct 13, 2022
@guanguans guanguans added the 2022 2022 label Nov 11, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2022 2022 PHP PHP
Projects
None yet
Development

No branches or pull requests

1 participant