单元测试是软件开发中非常重要的一环,它能帮助我们发现代码中的bug,保证代码质量,方便重构,提升开发效率。在PHP生态中,PHPUnit是最流行、最标准的单元测试框架,由Sebastian Bergmann开发和维护,几乎是PHP单元测试的代名词。

2016年,PHPUnit已经发展到5.x版本(最新稳定版是PHPUnit 5.4),功能非常完善,支持各种断言、数据提供器、Mock对象、测试覆盖率、注解等高级特性。Laravel、Symfony等主流PHP框架都内置了PHPUnit测试支持,Composer也可以方便地安装PHPUnit。

但是,很多PHP开发者还没有养成写单元测试的习惯,觉得写测试浪费时间,或者不知道怎么写测试。作为一个追求代码质量的开发者,我强烈建议大家学习和使用单元测试。今天就来系统地讲解PHPUnit单元测试实战,从安装配置到高级用法,帮助你写出高质量、可维护的PHP代码。

一、单元测试简介

1. 什么是单元测试

单元测试(Unit Testing)是指对软件中的最小可测试单元进行检查和验证。在面向对象编程中,最小单元通常是方法(函数)。单元测试的目的是验证每个方法是否按照预期工作,输入特定的值,是否返回预期的结果。

单元测试的特点:

  • 自动化:可以自动运行,不需要人工干预
  • 快速:运行速度快,几秒钟就能跑完所有测试
  • 独立:每个测试用例独立运行,不依赖其他测试
  • 可重复:多次运行结果一致
  • 覆盖全面:可以覆盖各种正常和异常情况

2. 为什么要写单元测试

  • 保证代码质量:单元测试能发现代码中的bug,确保代码按照预期工作
  • 方便重构:有了单元测试,重构代码时可以放心修改,运行测试就能知道有没有破坏功能
  • 提升开发效率:虽然写测试需要时间,但是长期来看,测试能减少调试和修复bug的时间,提升整体开发效率
  • 文档作用:单元测试是代码的活文档,通过测试用例可以了解代码的用法和预期行为
  • 减少回归bug:修改代码后运行测试,可以及时发现是否引入了新的bug
  • 提升设计:为了让代码可测试,需要写出低耦合、高内聚的代码,这会倒逼你提升代码设计

3. 单元测试的原则

  • FIRST原则

- Fast(快速):测试运行要快,几秒钟内完成 - Independent(独立):每个测试独立运行,不依赖其他测试的结果 - Repeatable(可重复):多次运行结果一致,不依赖环境 - Self-validating(自我验证):测试自动判断通过或失败,不需要人工检查 - Timely(及时):及时编写测试,最好在写代码之前或同时写测试

  • 测试隔离:每个测试只测试一个功能点,不测试多个功能的组合
  • 只测试公共接口:测试类的公共方法,不测试私有方法(私有方法通过公共方法间接测试)
  • 不要测试实现细节:测试代码的行为,而不是实现方式,这样重构时不需要修改测试

二、PHPUnit安装与配置

1. 安装PHPUnit

推荐使用Composer安装PHPUnit:

# 安装PHPUnit到项目(开发依赖)
composer require --dev phpunit/phpunit ^5.4

# 全局安装
composer global require phpunit/phpunit ^5.4

也可以下载PHPUnit的PHAR包:

# 下载PHPUnit PHAR
wget https://phar.phpunit.de/phpunit-5.4.phar
chmod +x phpunit-5.4.phar
mv phpunit-5.4.phar /usr/local/bin/phpunit

验证安装:

vendor/bin/phpunit --version
# PHPUnit 5.4.6 by Sebastian Bergmann and contributors.

2. 目录结构

推荐的项目目录结构:

project/
├── src/                    # 源代码
│   ├── Calculator.php
│   └── ...
├── tests/                  # 测试代码
│   ├── CalculatorTest.php
│   └── ...
├── vendor/                 # Composer依赖
├── composer.json
├── phpunit.xml            # PHPUnit配置文件
└── ...

测试文件命名:{ClassName}Test.php,测试类命名:{ClassName}Test,测试方法命名:test{MethodName}或使用@test注解。

3. phpunit.xml配置

PHPUnit的配置文件phpunit.xml,放在项目根目录:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Application Test Suite">
            <directory>tests/</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">src/</directory>
        </whitelist>
    </filter>
    <logging>
        <log type="coverage-html" target="coverage/" charset="UTF-8" yui="true" highlight="true"/>
        <log type="coverage-clover" target="coverage.xml"/>
    </logging>
</phpunit>

配置说明:

  • bootstrap:测试启动时加载的文件,通常是Composer的autoload.php
  • colors:是否使用彩色输出
  • convertErrorsToExceptions等:是否将PHP错误转换为异常
  • testsuites:测试套件,指定测试文件目录
  • filter/whitelist:代码覆盖率白名单,指定需要统计覆盖率的源代码目录
  • logging:日志输出,包括覆盖率报告

三、编写第一个测试

1. 被测代码

先写一个简单的计算器类src/Calculator.php

<?php
namespace App;

class Calculator
{
    public function add($a, $b)
    {
        return $a + $b;
    }

    public function subtract($a, $b)
    {
        return $a - $b;
    }

    public function multiply($a, $b)
    {
        return $a * $b;
    }

    public function divide($a, $b)
    {
        if ($b == 0) {
            throw new \InvalidArgumentException('Division by zero');
        }
        return $a / $b;
    }
}

2. 测试代码

编写测试类tests/CalculatorTest.php

<?php
use PHPUnit\Framework\TestCase;
use App\Calculator;

class CalculatorTest extends TestCase
{
    public function testAdd()
    {
        $calculator = new Calculator();
        $result = $calculator->add(2, 3);
        $this->assertEquals(5, $result);
    }

    public function testSubtract()
    {
        $calculator = new Calculator();
        $result = $calculator->subtract(5, 3);
        $this->assertEquals(2, $result);
    }

    public function testMultiply()
    {
        $calculator = new Calculator();
        $result = $calculator->multiply(2, 3);
        $this->assertEquals(6, $result);
    }

    public function testDivide()
    {
        $calculator = new Calculator();
        $result = $calculator->divide(6, 3);
        $this->assertEquals(2, $result);
    }

    public function testDivideByZero()
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Division by zero');

        $calculator = new Calculator();
        $calculator->divide(6, 0);
    }
}

3. 运行测试

# 运行所有测试
vendor/bin/phpunit

# 运行指定测试文件
vendor/bin/phpunit tests/CalculatorTest.php

# 运行指定测试方法
vendor/bin/phpunit --filter testAdd

# 显示详细信息
vendor/bin/phpunit --testdox

# 生成代码覆盖率报告(需要Xdebug扩展)
vendor/bin/phpunit --coverage-html coverage/

运行结果:

PHPUnit 5.4.6 by Sebastian Bergmann and contributors.

.....                                                               5 / 5 (100%)

Time: 100 ms, Memory: 4.00MB

OK (5 tests, 5 assertions)

四、常用断言方法

断言(Assertion)是单元测试的核心,用于验证实际结果是否符合预期。PHPUnit提供了丰富的断言方法。

1. 相等性断言

// 相等(==比较)
$this->assertEquals($expected, $actual);

// 不相等
$this->assertNotEquals($expected, $actual);

// 严格相等(===比较,类型和值都相等)
$this->assertSame($expected, $actual);

// 严格不相等
$this->assertNotSame($expected, $actual);

2. 布尔断言

// 为true
$this->assertTrue($condition);

// 为false
$this->assertFalse($condition);

3. 空值断言

// 为null
$this->assertNull($value);

// 不为null
$this->assertNotNull($value);

// 为空(empty(),包括0、''、null、false、空数组)
$this->assertEmpty($value);

// 不为空
$this->assertNotEmpty($value);

4. 类型断言

// 是指定类型(如'integer'、'string'、'array'、'bool')
$this->assertInternalType('integer', $value);

// 是指定类的实例
$this->assertInstanceOf(Calculator::class, $object);

// 不是指定类的实例
$this->assertNotInstanceOf(Calculator::class, $object);

5. 数组断言

// 数组包含指定键
$this->assertArrayHasKey('key', $array);

// 数组不包含指定键
$this->assertArrayNotHasKey('key', $array);

// 数组包含指定值
$this->assertContains('value', $array);

// 数组不包含指定值
$this->assertNotContains('value', $array);

// 数组元素个数
$this->assertCount(3, $array);

// 数组等于
$this->assertEquals([1, 2, 3], $array);

6. 字符串断言

// 字符串包含子串
$this->assertContains('hello', $string);

// 字符串以指定内容开头
$this->assertStringStartsWith('hello', $string);

// 字符串以指定内容结尾
$this->assertStringEndsWith('world', $string);

// 字符串匹配正则表达式
$this->assertRegExp('/^hello/', $string);

// 字符串长度
$this->assertSame(5, strlen($string));

7. 数值断言

// 大于
$this->assertGreaterThan(5, $value);

// 大于等于
$this->assertGreaterThanOrEqual(5, $value);

// 小于
$this->assertLessThan(10, $value);

// 小于等于
$this->assertLessThanOrEqual(10, $value);

// 浮点数相等(指定精度)
$this->assertEquals(1.0, $value, '', 0.0001);

8. 文件断言

// 文件存在
$this->assertFileExists($filepath);

// 文件不存在
$this->assertFileNotExists($filepath);

// 文件等于
$this->assertFileEquals($expected, $actual);

9. 异常断言

// 期望抛出指定异常
$this->expectException(\InvalidArgumentException::class);

// 期望异常消息
$this->expectExceptionMessage('error message');

// 期望异常消息匹配正则
$this->expectExceptionMessageRegExp('/error/');

// 期望异常代码
$this->expectExceptionCode(404);

五、测试固件(setUp/tearDown)

在测试中,经常需要在每个测试方法运行前初始化一些对象,运行后清理资源。PHPUnit提供了setUp()tearDown()方法。

1. setUp和tearDown

<?php
use PHPUnit\Framework\TestCase;
use App\Calculator;

class CalculatorTest extends TestCase
{
    private $calculator;

    // 每个测试方法运行前调用
    protected function setUp()
    {
        $this->calculator = new Calculator();
    }

    // 每个测试方法运行后调用
    protected function tearDown()
    {
        $this->calculator = null;
    }

    public function testAdd()
    {
        $result = $this->calculator->add(2, 3);
        $this->assertEquals(5, $result);
    }

    public function testSubtract()
    {
        $result = $this->calculator->subtract(5, 3);
        $this->assertEquals(2, $result);
    }
}

2. setUpBeforeClass和tearDownAfterClass

如果需要在整个测试类运行前/后执行一次(而不是每个测试方法),使用setUpBeforeClass()tearDownAfterClass()

class DatabaseTest extends TestCase
{
    private static $db;

    // 整个测试类运行前调用一次
    public static function setUpBeforeClass()
    {
        self::$db = new PDO('mysql:host=localhost;dbname=test', 'root', '');
    }

    // 整个测试类运行后调用一次
    public static function tearDownAfterClass()
    {
        self::$db = null;
    }
}

3. @before和@after注解

也可以使用@before@after注解标记自定义方法:

class MyTest extends TestCase
{
    private $calculator;

    /**
     * @before
     */
    public function initCalculator()
    {
        $this->calculator = new Calculator();
    }

    /**
     * @after
     */
    public function cleanup()
    {
        $this->calculator = null;
    }
}

六、数据提供器(Data Provider)

数据提供器可以让一个测试方法使用多组数据运行,避免重复代码。

1. 基本用法

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

    public function additionProvider()
    {
        return [
            [0, 0, 0],
            [0, 1, 1],
            [1, 0, 1],
            [1, 1, 2],
            [2, 3, 5],
            [-1, 1, 0],
            [-1, -1, -2],
        ];
    }
}

运行结果:

PHPUnit 5.4.6 by Sebastian Bergmann and contributors.

.......                                                             7 / 7 (100%)

Time: 100 ms, Memory: 4.00MB

OK (7 tests, 7 assertions)

一个测试方法变成了7个测试,每组数据运行一次。

2. 命名数据集

可以给每组数据命名,方便识别:

public function additionProvider()
{
    return [
        'zero plus zero' => [0, 0, 0],
        'zero plus one' => [0, 1, 1],
        'one plus zero' => [1, 0, 1],
        'one plus one' => [1, 1, 2],
        'positive numbers' => [2, 3, 5],
        'negative and positive' => [-1, 1, 0],
        'negative numbers' => [-1, -1, -2],
    ];
}

3. 多个数据提供器

一个测试类可以有多个数据提供器,每个测试方法使用不同的数据提供器。

七、Mock对象

在测试中,经常需要模拟(Mock)一些依赖对象,比如数据库连接、外部API、邮件服务等。PHPUnit提供了强大的Mock功能。

1. 创建Mock对象

<?php
use PHPUnit\Framework\TestCase;

class UserServiceTest extends TestCase
{
    public function testGetUser()
    {
        // 创建UserRepository的Mock对象
        $userRepository = $this->createMock(UserRepository::class);

        // 设置Mock方法的预期行为
        $userRepository->expects($this->once())
            ->method('findById')
            ->with(1)
            ->willReturn(['id' => 1, 'name' => '张三']);

        // 创建UserService,注入Mock的UserRepository
        $userService = new UserService($userRepository);

        // 调用方法
        $user = $userService->getUser(1);

        // 断言
        $this->assertEquals('张三', $user['name']);
    }
}

2. Mock方法的常用设置

$mock = $this->createMock(SomeClass::class);

// 设置方法被调用的次数
$mock->expects($this->once())        // 调用一次
    ->method('someMethod');

$mock->expects($this->exactly(3))    // 调用3次
    ->method('someMethod');

$mock->expects($this->atLeastOnce()) // 至少调用一次
    ->method('someMethod');

$mock->expects($this->never())        // 不调用
    ->method('someMethod');

$mock->expects($this->any())          // 任意次数
    ->method('someMethod');

// 设置参数匹配
$mock->expects($this->once())
    ->method('someMethod')
    ->with($this->equalTo(1), $this->stringContains('hello'));

// 设置返回值
$mock->method('someMethod')->willReturn('value');
$mock->method('someMethod')->willReturnArgument(0); // 返回第一个参数
$mock->method('someMethod')->willReturnSelf(); // 返回自身
$mock->method('someMethod')->willThrowException(new \Exception('error')); // 抛出异常

// 根据参数返回不同值
$mock->method('someMethod')
    ->willReturnMap([
        [1, 'value1'],
        [2, 'value2'],
    ]);

3. Mock抽象类和接口

// Mock接口
$mock = $this->createMock(SomeInterface::class);

// Mock抽象类
$mock = $this->getMockForAbstractClass(SomeAbstractClass::class);

4. Mock的注意事项

  • Mock只模拟外部依赖,不要Mock被测类本身
  • Mock的方法要设置预期行为,否则默认返回null
  • 不要过度使用Mock,简单的依赖可以直接使用真实对象
  • Mock应该测试交互(方法是否被调用、参数是否正确),而不是实现细节

八、测试覆盖率

测试覆盖率(Code Coverage)衡量测试代码覆盖了多少源代码。需要安装Xdebug扩展。

1. 生成覆盖率报告

# 生成HTML覆盖率报告
vendor/bin/phpunit --coverage-html coverage/

# 生成Clover XML报告(CI工具使用)
vendor/bin/phpunit --coverage-clover coverage.xml

# 生成文本报告
vendor/bin/phpunit --coverage-text

2. 覆盖率指标

  • 行覆盖率(Line Coverage):有多少行代码被执行过
  • 函数/方法覆盖率(Function/Method Coverage):有多少函数/方法被调用过
  • 类覆盖率(Class Coverage):有多少类被实例化过
  • 分支覆盖率(Branch Coverage):有多少if/else分支被覆盖
  • 路径覆盖率(Path Coverage):有多少代码路径被覆盖

3. 覆盖率不是越高越好

  • 100%覆盖率不代表没有bug,只能说明所有代码都被执行过
  • 追求100%覆盖率可能会浪费时间在不重要的代码上
  • 重点测试核心业务逻辑和复杂的代码
  • 覆盖率是参考指标,不是目标

九、测试组织与最佳实践

1. 测试命名规范

  • 测试类:{被测类名}Test,如CalculatorTest
  • 测试方法:test{方法名}{场景}{预期结果},如testAddpositiveNumbersreturnsSum
  • 或者使用@test注解,方法名可以更自然:itshouldaddtwonumbers

2. 测试结构(AAA模式)

每个测试方法遵循AAA(Arrange-Act-Assert)模式:

  • Arrange(准备):初始化测试数据和对象
  • Act(执行):调用被测方法
  • Assert(断言):验证结果
public function testAdd()
{
    // Arrange
    $calculator = new Calculator();

    // Act
    $result = $calculator->add(2, 3);

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

3. 一个测试只测试一个点

每个测试方法只测试一个功能点,不要在一个测试中测试多个功能。这样测试失败时能快速定位问题。

4. 测试要独立

每个测试独立运行,不依赖其他测试的结果,不依赖执行顺序。使用setUp初始化数据,tearDown清理资源。

5. 测试要快速

单元测试应该快速运行,几秒钟内完成。避免在单元测试中访问数据库、网络、文件系统(这些属于集成测试)。使用Mock模拟外部依赖。

6. 测试要可读

测试代码也是代码,要写得清晰、易读。使用描述性的方法名,合理的注释,清晰的结构。

7. 先写测试(TDD)

测试驱动开发(Test-Driven Development):先写测试,再写代码,最后重构。

  1. Red(红):写一个失败的测试
  2. Green(绿):写最少的代码让测试通过
  3. Refactor(重构):重构代码,保持测试通过

TDD能帮助你写出更简洁、更可测试、更高质量的代码。

8. 持续集成

将测试集成到CI(持续集成)流程中,每次提交代码自动运行测试,及时发现问题。

十、常见问题与排错

1. 测试运行慢

  • 检查是否在测试中访问了数据库、网络、文件系统
  • 使用Mock模拟外部依赖
  • 减少不必要的setUp操作
  • 使用测试套件分组,只运行需要的测试

2. 测试不稳定(有时通过有时失败)

  • 检查测试是否依赖执行顺序
  • 检查测试是否依赖外部状态(时间、随机数、数据库)
  • 使用setUp/tearDown确保每个测试独立
  • 避免使用全局变量和静态变量

3. 无法Mock类

  • 确保类不是final的(final类无法Mock)
  • 确保方法不是final的(final方法无法Mock)
  • 确保方法不是private的(private方法无法Mock)
  • 使用getMockForAbstractClass Mock抽象类

4. 覆盖率报告不生成

  • 确保安装了Xdebug扩展:php -m | grep xdebug
  • 确保phpunit.xml中配置了whitelist
  • 确保源代码目录正确

5. 自动加载找不到类

  • 确保composer.json中配置了autoload
  • 确保运行了composer dump-autoload
  • 确保phpunit.xml的bootstrap指向了autoload.php
  • 确保命名空间和目录结构正确

总结

PHPUnit是2016年PHP生态中最流行、最标准的单元测试框架,是写出高质量PHP代码的必备工具。

本文从单元测试简介、PHPUnit安装配置、编写第一个测试、常用断言方法、测试固件(setUp/tearDown)、数据提供器、Mock对象、测试覆盖率、测试组织与最佳实践、常见问题排错等方面,系统讲解了PHPUnit单元测试实战。

单元测试的核心要点:

  1. 理解单元测试的价值:保证质量、方便重构、提升效率、文档作用
  2. 遵循FIRST原则:快速、独立、可重复、自我验证、及时
  3. 掌握常用断言:assertEquals、assertTrue、assertNull、assertContains等
  4. 使用setUp/tearDown初始化和清理
  5. 使用数据提供器减少重复代码
  6. 使用Mock模拟外部依赖
  7. 遵循AAA模式和一个测试一个点
  8. 追求合理的覆盖率,不是100%
  9. 尝试TDD,先写测试再写代码
  10. 将测试集成到CI流程

单元测试是一种投资,短期来看需要花时间写测试,但是长期来看,测试能减少bug、方便重构、提升代码质量和开发效率。作为一个追求卓越的PHP开发者,强烈建议你学习和使用PHPUnit,养成写单元测试的习惯。

希望本文能帮助你入门PHPUnit,写出高质量、可维护的PHP代码。