2016年我在项目中全面推行了单元测试用PHPUnit做PHP的单元测试框架。

说起来之前也写单元测试但是都是比较基础的用法比如写一个测试类几个测试方法用assertEquals断言结果就完了。很多高级技巧都不知道直到深入研究了PHPUnit才发现原来PHPUnit有这么多好用的技巧能大幅提升写测试的效率和测试的质量。

而且之前写测试也比较随意没有什么规范和最佳实践导致测试维护起来很麻烦有时候改了代码测试就挂了一大堆排查起来很费劲。

后来我花了一些时间深入学习了PHPUnit的高级用法和最佳实践在项目中推行之后写测试的效率提升了很多测试的质量也提升了很多维护起来也轻松了很多。

今天就来聊聊PHPUnit的一些进阶技巧和最佳实践可能有些你还不知道。

一、数据提供者(Data Provider)

第一个技巧是数据提供者这是PHPUnit中非常实用的一个功能能帮我们减少重复的测试代码。

1. 什么是数据提供者

数据提供者就是一个方法返回一组测试数据测试方法会用这组数据中的每一组参数运行一次这样就不用为每一组参数写一个测试方法,了。

比如我们要测试一个计算两个数之和的方法需要测试多组数据比如1+1=2,2+3=5,0+0=0,-1+1=0等。如果不用数据提供者就要写四个测试方法或者在一个测试方法里写四个断言但是这样如果一个断言失败了后面的就不会执行了而且也不知道是哪组数据失败了。

用了数据提供者就很方便定义一个数据提供者方法返回这些测试数据然后测试方法用,@dataProvider注解指定这个数据提供者PHPUnit就会自动用每组数据运行一次测试方法而且每组数据都是独立的一组失败了不影响其他组还能清楚地看到是哪组数据失败了。

2. 数据提供者的用法

数据提供者的用法很简单看个例子:

class CalculatorTest extends PHPUnit_Framework_TestCase
{
    /**
     * 数据提供者
     */
    public function additionProvider()
    {
        return [
            [1, 1, 2],
            [2, 3, 5],
            [0, 0, 0],
            [-1, 1, 0],
            [100, -50, 50],
        ];
    }

    /**
     * @dataProvider additionProvider
     */
    public function testAdd($a, $b, $expected)
    {
        $calculator = new Calculator();
        $this->assertEquals($expected, $calculator->add($a, $b));
    }
}

这个例子中additionProvider方法返回了五组测试数据testAdd方法用,@dataProvider additionProvider指定了数据提供者PHPUnit就会用每组数据运行一次testAdd方法总共运行五次。

运行测试的时候会看到五个测试结果每个都有编号和数据比如testAdd with data set #0 (1, 1, 2),这样如果失败了就能清楚地看到是哪组数据失败了。

3. 数据提供者的命名

数据提供者返回的每组数据可以给起一个名字这样测试结果更清晰。

比如:

public function additionProvider()
{
    return [
        '正整数相加' => [1, 1, 2],
        '较大数相加' => [2, 3, 5],
        '零相加' => [0, 0, 0],
        '正负相加' => [-1, 1, 0],
        '正负数相加' => [100, -50, 50],
    ];
}

这样运行测试的时候就会显示testAdd with data set "正整数相加" (1, 1, 2),更清晰更容易理解。

4. 数据提供者的注意事项

使用数据提供者的时候有几个注意事项:

  • 数据提供者方法必须是public的返回一个数组或者实现了Iterator接口的对象。
  • 数据提供者的名字和测试方法的,@dataProvider注解中的名字要一致。
  • 数据提供者中的每组数据的参数数量要和测试方法的参数数量一致。
  • 数据提供者会在测试运行之前就被调用所以不要在数据提供者中做太耗时的操作或者有副作用的操作。
  • 如果数据提供者返回空数组测试方法会被标记为有风险(risky),因为没有测试数据。

数据提供者是PHPUnit中非常实用的一个功能能帮我们减少重复的测试代码让测试更清晰更易维护一定要掌握。

二、Mock和Stub

第二个技巧是Mock和Stub这是单元测试中非常重要的一个概念能帮我们隔离依赖只测试我们想测试的部分。

1. 什么是Mock和Stub

在写单元测试的时候我们的代码往往会依赖其他的类或者组件比如数据库缓存第三方API等。如果直接用真实的依赖测试会有几个问题:

  • 测试,慢:数据库第三方API等操作比较慢导致测试运行时间长。
  • 测试不稳定:依赖的服务可能会出问题或者数据变化导致测试有时候过有时候不过不稳定。
  • 测试复杂:需要准备测试数据清理测试数据等很麻烦。
  • 无法测试异常:很难模拟依赖抛出异常的情况比如数据库连接失败第三方API返回错误,等。

为了解决这些问题我们就需要用Mock和Stub来模拟这些依赖这样就不用用真实的依赖了测试更快更稳定更简单。

  • Stub(桩):模拟依赖的返回值比如模拟一个数据库查询返回指定的数据不关心方法是怎么调用的只关心返回什么。
  • Mock(模拟):不仅模拟返回值还验证方法的调用情况比如验证某个方法被调用了几次参数是什么,等。

简单说Stub是,"模拟返回",Mock是,"模拟返回,+,验证调用"。

2. PHPUnit中的Mock用法

PHPUnit内置了Mock的功能用getMockBuilder方法就能创建Mock对象。

看个例子假设我们有一个UserService类依赖UserRepository类来查询用户数据:

class UserService
{
    private $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function getUserById($id)
    {
        $user = $this->userRepository->findById($id);
        if (!$user) {
            throw new UserNotFoundException("User not found: $id");
        }
        return $user;
    }
}

我们要测试UserService的getUserById方法就可以Mock一个UserRepository对象模拟findById方法的返回值:

class UserServiceTest extends PHPUnit_Framework_TestCase
{
    public function testGetUserByIdReturnsUser()
    {
        // 创建Mock对象
        $userRepository = $this->getMockBuilder(UserRepository::class)
            ->getMock();

        // 设置Stub:当findById被调用,参数为1时,返回一个用户对象
        $user = new User(1, '张三');
        $userRepository->method('findById')
            ->with(1)
            ->willReturn($user);

        // 测试
        $userService = new UserService($userRepository);
        $result = $userService->getUserById(1);
        $this->assertEquals($user, $result);
    }

    public function testGetUserByIdThrowsExceptionWhenUserNotFound()
    {
        // 创建Mock对象
        $userRepository = $this->getMockBuilder(UserRepository::class)
            ->getMock();

        // 设置Stub:当findById被调用,参数为999时,返回null
        $userRepository->method('findById')
            ->with(999)
            ->willReturn(null);

        // 测试异常
        $this->expectException(UserNotFoundException::class);
        $userService = new UserService($userRepository);
        $userService->getUserById(999);
    }
}

这个例子中我们用getMockBuilder创建了UserRepository的Mock对象然后用method,with,willReturn设置了Stub模拟findById方法的返回值。这样测试UserService的时候就不用真实的UserRepository了不用连数据库测试更快更稳定。

3. 验证方法调用(Mock)

上面的例子是Stub只模拟返回值如果我们还要验证方法的调用情况比如验证某个方法被调用了几次参数是什么就需要用Mock的期望(expects)。

看个例子假设我们有一个EmailService类发送邮件我们要测试UserService的registerUser方法是否调用了EmailService的sendWelcomeEmail方法:

class UserService
{
    private $userRepository;
    private $emailService;

    public function __construct(UserRepository $userRepository, EmailService $emailService)
    {
        $this->userRepository = $userRepository;
        $this->emailService = $emailService;
    }

    public function registerUser($name, $email)
    {
        $user = $this->userRepository->create($name, $email);
        $this->emailService->sendWelcomeEmail($user);
        return $user;
    }
}

测试的时候验证sendWelcomeEmail被调用了一次参数是创建的用户:

public function testRegisterUserSendsWelcomeEmail()
{
    // 创建Mock对象
    $userRepository = $this->getMockBuilder(UserRepository::class)
        ->getMock();
    $emailService = $this->getMockBuilder(EmailService::class)
        ->getMock();

    $user = new User(1, '张三', 'zhangsan@example.com');

    // 设置UserRepository的Stub
    $userRepository->method('create')
        ->with('张三', 'zhangsan@example.com')
        ->willReturn($user);

    // 设置EmailService的Mock期望:验证sendWelcomeEmail被调用一次,参数是$user
    $emailService->expects($this->once())
        ->method('sendWelcomeEmail')
        ->with($user);

    // 测试
    $userService = new UserService($userRepository, $emailService);
    $result = $userService->registerUser('张三', 'zhangsan@example.com');
    $this->assertEquals($user, $result);
}

这个例子中我们用expects($this->once()),设置了期望验证sendWelcomeEmail方法被调用了一次用with($user),验证参数是,$user。如果方法没有被调用或者调用次数不对或者参数不对测试就会失败。

PHPUnit提供了几种调用次数的匹配器:

  • $this->any():任意次数不验证次数。
  • $this->never():从不被调用。
  • $this->once():被调用一次。
  • $this->exactly($count):被调用,$count次。
  • $this->at($index):第,$index次被调用。

4. Mock的注意事项

使用Mock的时候有几个注意事项:

  • Mock的类不能是final的方法也不能是final的否则无法Mock。如果需要Mock,final类或者final方法可以用Mockery等第三方Mock库功能更强大。
  • Mock的方法默认返回null如果需要返回特定的值要用willReturn设置。
  • 不要Mock你自己的简单值对象比如User这样的简单类直接new一个就行Mock反而麻烦。
  • 不要过度使用Mock只Mock那些慢的不稳定的复杂的依赖比如数据库第三方API等。简单的依赖直接用真实的就行。
  • Mock的期望要合理不要验证太多实现细节否则改了代码测试就挂了维护成本,高。只验证重要的行为比如是否发送了邮件是否记录了日志,等。

Mock和Stub是单元测试中非常重要的技巧能帮我们隔离依赖写出快速稳定的单元测试一定要掌握。

三、测试异常

第三个技巧是测试异常这也是PHPUnit中常用的一个功能。

1. 测试异常的方法

PHPUnit提供了几种测试异常的方法:

  • expectException:在测试方法中调用,$this->expectException(ExceptionClass::class),然后执行会抛出异常的代码如果抛出了指定的异常测试通过否则失败。
  • @expectedException注解:在测试方法上面加,@expectedException ExceptionClass注解效果和expectException一样但是这种方式已经不推荐了因为不够灵活推荐用expectException方法。
  • try-catch:用try-catch捕获异常然后断言异常的类型消息等这种方式最灵活但是代码多一些。

2. expectException的用法

expectException是PHPUnit推荐的测试异常的方法用法很简单:

public function testDivideByZeroThrowsException()
{
    $calculator = new Calculator();
    
    $this->expectException(InvalidArgumentException::class);
    $this->expectExceptionMessage('Cannot divide by zero');
    $this->expectExceptionCode(100);
    
    $calculator->divide(1, 0);
}

这个例子中我们用expectException指定了期望的异常类是InvalidArgumentException用expectExceptionMessage指定了期望的异常消息用expectExceptionCode指定了期望的异常代码。如果divide(1, 0),抛出了符合这些条件的异常测试通过否则失败。

注意expectException等方法要在执行会抛出异常的代码之前调用因为一旦异常抛出后面的代码就不会执行了。

3. 测试异常的注意事项

测试异常的时候有几个注意事项:

  • expectException等方法要在抛出异常的代码之前调用。
  • 一个测试方法只能测试一个异常因为异常抛出后后面的代码就不会执行了。如果要测试多个异常要写多个测试方法。
  • 不要只测试异常的类型还要测试异常的消息或者代码确保抛出的是正确的异常而不是其他异常刚好类型一样。
  • 如果异常有额外的属性或者需要更复杂的验证可以用try-catch捕获异常然后断言异常的属性。

测试异常是单元测试中很重要的一部分因为异常处理也是代码的重要功能需要测试确保在错误的情况下代码能正确抛出异常。

四、代码覆盖率

第四个技巧是代码覆盖率这是衡量测试覆盖程度的重要指标。

1. 什么是代码覆盖率

代码覆盖率是指测试运行的时候覆盖了多少代码比如多少行代码被执行了多少方法被调用了多少分支被走到了,等。

代码覆盖率是衡量测试是否充分的一个指标覆盖率高说明测试覆盖了大部分代码bug被发现的概率就,高;覆盖率低说明很多代码没有被测试可能有隐藏的bug。

但是要注意代码覆盖率高不代表测试质量高因为覆盖率只说明代码被执行了不说明断言充分可能代码执行了但是没有断言结果这样即使代码有bug测试也不会失败。所以覆盖率是一个参考指标不是唯一指标还要看测试的质量。

2. PHPUnit中生成覆盖率报告

PHPUnit内置了代码覆盖率的功能用,--coverage-html参数就能生成HTML格式的覆盖率报告。

首先需要安装Xdebug或者phpdbg因为PHPUnit需要它们来收集覆盖率数据。Xdebug是PHP的调试扩展也能收集覆盖率数据比较常用。

安装好Xdebug之后运行测试的时候加上,--coverage-html参数指定报告输出目录:

phpunit --coverage-html ./coverage-report

运行完之后就会在coverage-report目录生成HTML格式的覆盖率报告用浏览器打开index.html就能看到覆盖率的详细信息包括:

  • 总体覆盖率:总的行覆盖率方法覆盖率类覆盖率,等。
  • 每个文件的覆盖率:每个PHP文件的覆盖率是多少。
  • 每个文件的详细代码:打开某个文件能看到代码哪些行被覆盖了(绿色),哪些行没有被覆盖(红色),哪些行是死代码(灰色)。

这样就能清楚地看到哪些代码没有被测试然后补充测试提高覆盖率。

3. 覆盖率的注意事项

使用代码覆盖率的时候有几个注意事项:

  • 覆盖率不是越高越好100%的覆盖率不代表没有bug因为可能断言不充分或者测试数据不充分。不要为了追求100%的覆盖率而写没有意义的测试。
  • 覆盖率是一个参考指标用来发现没有被测试的代码而不是用来考核,的。不要把覆盖率作为考核指标否则大家会为了覆盖率而写低质量的测试。
  • 有些代码很难测试或者不值得测试比如简单的getter/setter常量定义等可以排除在覆盖率之外不用追求这些代码的覆盖率。
  • 覆盖率报告要定期生成查看发现覆盖率下降了要及时补充测试。
  • 可以在持续集成中设置覆盖率阈值比如行覆盖率不能低于80%,如果低于阈值构建失败这样能保证测试的覆盖程度。

代码覆盖率是一个很有用的工具能帮我们发现没有被测试的代码提高测试的充分性但是要合理使用不要过度追求覆盖率。

五、测试组织和最佳实践

最后聊聊测试的组织和一些最佳实践。

1. 测试目录结构

好的测试目录结构能让测试更清晰更易维护。一般推荐的目录结构,是:

project/
├── src/                    # 源代码
│   ├── Model/
│   ├── Service/
│   └── Controller/
├── tests/                  # 测试代码
│   ├── Unit/               # 单元测试
│   │   ├── Model/
│   │   ├── Service/
│   │   └── Controller/
│   ├── Integration/        # 集成测试
│   └── Functional/         # 功能测试
├── phpunit.xml             # PHPUnit配置文件
└── composer.json

测试目录和源代码目录对应源代码在src/Model/,对应的测试就在tests/Unit/Model/,这样容易找到对应的测试。

测试分单元测试集成测试功能测试等不同类型分开存放因为它们的运行时间和目的不同。单元测试快经常运行;集成测试和功能测试慢提交前或者持续集成中运行。

2. 测试命名规范

好的测试命名能让测试更清晰更容易理解。一般推荐的命名规范,是:

  • 测试类,名:和被测类对应比如被测类是UserService测试类就是UserServiceTest。
  • 测试方法,名:描述测试的内容比如testGetUserByIdReturnsUser,testGetUserByIdThrowsExceptionWhenUserNotFound这样看方法名就知道测试的是什么。
  • 测试方法名可以用中文或者英文但是要统一描述清楚。

PHPUnit运行测试的时候会显示测试类和测试方法的名字好的命名能让测试结果更清晰出了问题也容易定位。

3. 测试的结构(Arrange-Act-Assert)

每个测试方法推荐用Arrange-Act-Assert(AAA)的结构:

  • Arrange(准备):准备测试数据和环境比如创建对象设置Mock准备测试数据,等。
  • Act(执行):执行要测试的方法或者操作。
  • Assert(断言):断言执行的结果是否符合预期。

比如:

public function testAddReturnsCorrectSum()
{
    // Arrange
    $calculator = new Calculator();
    $a = 1;
    $b = 2;
    $expected = 3;

    // Act
    $result = $calculator->add($a, $b);

    // Assert
    $this->assertEquals($expected, $result);
}

这样的结构清晰易读每个部分职责明确容易理解和维护。

4. 测试的独立性

每个测试方法都应该是独立的不依赖其他测试方法的结果也不影响其他测试方法。

PHPUnit运行测试的时候每个测试方法都会创建一个新的测试类实例所以测试方法之间不会共享实例属性但是还是要注意不要依赖全局状态比如数据库数据缓存文件,等。

如果测试需要准备数据要在setUp方法中准备在tearDown方法中清理确保每个测试运行前环境都是干净的运行后也清理干净不影响其他测试。

5. 持续集成

单元测试要和持续集成结合每次提交代码都自动运行测试确保提交的代码没有破坏现有功能。

可以用Jenkins,Travis CI,GitLab CI等持续集成工具配置每次提交自动运行phpunit生成测试报告和覆盖率报告如果测试失败或者覆盖率低于阈值就通知开发人员及时修复。

持续集成能让测试真正发挥作用及时发现问题而不是等发布前才跑测试发现一堆问题。

六、写在最后

PHP单元测试PHPUnit进阶:这些技巧你可能不知道。

PHPUnit是PHP中最常用的单元测试框架功能很强大除了基础的断言之外还有很多高级技巧比如数据提供者Mock测试异常代码覆盖率等掌握这些技巧能大幅提升写测试的效率和测试的质量。

单元测试是保证代码质量的重要手段好的单元测试能帮我们快速发现bug放心重构提升代码质量但是写好单元测试也需要掌握一些技巧和最佳实践才能事半功倍。

很多程序员觉得写单元测试麻烦浪费时间但是实际上写单元测试是,"磨刀不误砍柴工",前期花一些时间写测试后期能节省更多调试和修复bug的时间而且有了测试重构的时候也更放心能不断优化代码而不怕破坏现有功能。

希望我的这些经验和技巧能对大家有所帮助特别是做PHP开发的朋友。

最后用一句话结尾:

"单元测试不是负担而是保障保障代码的质量保障重构的勇气保障用户的体验。"

愿大家都能写出高质量的单元测试做出稳定可靠的软件。