PHP Parser 简介和应用 - 为你的代码自动补全单元测试

简介

PHP Parser是由 nikic 开发的一款php抽象语法树(AST)解析工具。PHP Parser同时兼顾接口易用,结构简洁,工具链完善等诸多优点。在工程上,普遍使用PHP Paser生成模板代码,或使用其生成的抽象语法树进行静态分析。

抽象语法树 VS 反射

反射

反射可以在程序运行时动态解析类和方法的结构,基于反射获得的结构,程序可以动态地访问类和类的属性方法,也可以用于创建类实例。相对于抽象语法树,反射取得的结构能够更清晰地反映类结构,因而常用于框架实现路由分发等功能。

抽象语法树

抽象语法树是程序语言源代码经过语法分析和词法分析后获得的解析结构。除反射能够获取到的信息外,还包含了注释、方法与函数的逻辑结构,我们可以认为抽象语法树与源代码是等价的。

PHP Parser详解

功能入口

PhpParser\ParserFactory::create(int $kind, Lexer $lexer = null, array $parserOptions = []): PhpParser\Parser

创建解析器,$kind One of ::PREFER_PHP7, ::PREFER_PHP5, ::ONLY_PHP7 or ::ONLY_PHP5

PhpParser\Parser::parse(string $code, ErrorHandler $errorHandler = null): Node\Stmt[]|null

所有解析器都实现了该方法,传入代码并返回抽象语法树。

PrettyPrinter\Standard::prettyPrintFile($ast): string

用于将抽象语法树转化成代码。
file

命名空间

PhpParser\Node

包含抽象语法树的所有节点,代码中的变量声明、类引用、逻辑表达式都可以用对应的Node表示。

PhpParser\Node\Stmt

包含表达式节点,如表示namespace的Namespace、class的Class、类方法的ClassMethod等。在后面示例中我会演示如何解析和修改表达式。

PhpParser\Builder

该命名空间下包含生成节点的工厂类,通过getNode方法可以获得对应的节点。

应用示例

一、解析和生成源代码

详见官方示例

二、为代码自动添加测试

需求描述

假设我们在Service层使用了一系列公共静态方法(public static function)提供服务,我们希望确保每一个方法都有单元测试,所以需要查找Service中存在的方法,并生成测试类和测试方法。当然,由于每个Service中包含很多方法,当增加方法时,我们不希望每次手动把旧的测试转移过来,所以需要增量添加测试

输出结果(新建测试)
<?php

namespace Test\Unit;

use Tests\TestCase;
class ArticleServiceTest extends TestCase
{
    public function testGetArticles()
    {
    }
    public function testGetArticle()
    {
    }
    public function testCreateArticle()
    {
    }
}
输出结果(增量添加)
<?php

namespace Test\Unit;

use Tests\TestCase;
class ArticleServiceTest extends TestCase
{
    public function testGetArticles()
    {
        // 假设这个测试已存在
        // 生成的代码将会保留这一段注释
    }
    public function testGetArticle()
    {
    }
    public function testCreateArticle()
    {
    }
}
程序代码
use Illuminate\Console\Command;
use PhpParser\Builder\Method;
use PhpParser\Builder\Use_;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter;
use Exception;
/** 略 */
    public function handle()
    {
        // 解析需测试文件
        $namespaces = $this->parseFile(app_path("Http/Services/V1/ArticleService.php"));
        array_walk($namespaces, function ($namespace) {
            [$namespace, $classes] = $namespace;

            // 解析文件中的类
            foreach ($classes as $class) {
                $className = $class->name->name;
                $testClassName = "{$className}Test";
                $testFilePath = base_path("tests/Unit/{$testClassName}.php");

                $classMethodNames = array_filter(array_map(function ($bodyPart) {
                    if ($bodyPart instanceof ClassMethod && $bodyPart->isPublic() && $bodyPart->isStatic()) {
                        $methodName = $bodyPart->name->name;
                        return 'test' . strtoupper($methodName[0]) . substr($methodName, 1);
                    }
                    return null;
                }, $class->stmts));

                if (file_exists($testFilePath)) {
                    // 读取已添加的测试
                    [$testNamespace, $testClassMethodNames, $testClass] = $this->getExistsTest($testFilePath);
                    $todoMethodNames = array_diff($classMethodNames, $testClassMethodNames);
                } else {
                    // 创建空的测试类
                    $testNamespace = $this->prepareTestFile('Test\Unit');
                    $todoMethodNames = $classMethodNames;
                    $testClass = new Class_($testClassName);
                    $testClass->extends = new Node\Name('TestCase');
                }

                $testNamespace->stmts = array_filter($testNamespace->stmts, function ($stmt) {
                    return !($stmt instanceof Class_);
                });

                $testClass->stmts = array_merge($testClass->stmts, array_map(function ($methodName) {
                    $method = new Method($methodName);
                    $method->makePublic();

                    return $method->getNode();
                }, $todoMethodNames));

                $testNamespace->stmts[] = $testClass;

                $prettyPrinter = new PrettyPrinter\Standard;
                echo $prettyPrinter->prettyPrintFile([$testNamespace]);
            }
        });
    }

    /**
     * 解析已有的测试
     *
     * @param $testFilePath
     * @return array
     * @throws Exception
     */
    protected function getExistsTest($testFilePath)
    {
        $testClasses = $this->parseFile($testFilePath);
        if (count($testClasses) !== 1) {
            throw new Exception('测试文件需有且仅有一个PHP片段');
        }
        [$testNamespace, $testClasses] = $testClasses[0];
        if (count($testClasses) !== 1) {
            throw new Exception('测试文件需有且仅有一个类');
        }
        $testClass = $testClasses[0];

        $testClassMethodNames = array_filter(array_map(function ($bodyPart) {
            if ($bodyPart instanceof ClassMethod && $bodyPart->isPublic()) {
                return $bodyPart->name->name;
            }
            return null;
        }, $testClass->stmts));

        return [$testNamespace, $testClassMethodNames, $testClass];
    }

    protected function prepareTestFile($namespace)
    {
        $namespace = new \PhpParser\Builder\Namespace_($namespace);
        $namespace->addStmt(new Use_('Tests\TestCase', Node\Stmt\Use_::TYPE_NORMAL));

        return $namespace->getNode();
    }

    protected function parseFile($path)
    {
        $code = file_get_contents($path);
        $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
        $ast = $parser->parse($code);
        if (!count($ast)) {
            throw new Exception('未发现php代码');
        }
        $namespaces = $this->parsePHPSegments($ast);

        return $namespaces;
    }

    protected function parsePHPSegments($segments)
    {
        $segments = array_filter($segments, function ($segment) {
            return $segment instanceof Namespace_;
        });

        $segments = array_map(function (Namespace_ $segment) {
            return [$segment, $this->parseNamespace($segment)];
        }, $segments);

        return $segments;
    }

    protected function parseNamespace(Namespace_ $namespace)
    {
        $classes = array_values(array_filter($namespace->stmts, function ($class) {
            return $class instanceof Class_;
        }));

        return $classes;
    }

结语

感谢nikic的杰作,我们可以通过PHP Paser便捷地解析和修改PHP代码,具备了元编程能力。在此基础上,我们可以实现静态代码分析(SCA)、模板生成代码等工具。使用这些工具开发者可以排查潜在BUG、优化项目代码,减少重复劳动。

PHP Paser算是作者最喜欢的PHP包,喜欢程度甚至高于Laravel这样的全栈框架。本文举了一个简单的自动创建单元测试的例子,除这以外还可以实现更多更好用的功能;第一次写教程,文笔不是很好,算是抛砖引玉了。

php
本作品采用《CC 协议》,转载必须注明作者和本文链接
为码农摸鱼事业而奋斗
本帖由系统于 4年前 自动加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 3

一直感觉少了啥,才发现没补充示例的需求,现在补上了

5年前 评论

@Kamicloud 请问下这个如何使用呢?比如我想集成到laravel中,怎么通过你说的这个方式,反生成一个控制器或者一个server文件的所有方法的测试用例呢?

5年前 评论

@zhangya4548 我不是很确定你说的测试用例是不是指的是输入参数,如果是输入参数这个问题会变得很复杂。
因为单元测试不像接口测试,接口只要是合法的字符串即可输入,而输入参数可能是PHP的任意一种数据结构,所以难以自动化。这是我对测试用例的理解,如果你有好的测试用例解决方案,也希望你能分享。
我现在也在做一个接口自动化测试的工具,设计上构造测试样例和测试基境依然是必要的。后边工具成熟我再写篇文章开源出来,因为工具用到的PHP Parser,顺势写了篇文章介绍它,抛砖引玉。

5年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!