单元测试是保证代码质量的重要手段,它能帮助我们发现代码中的bug,保证代码的正确性,提高代码的可维护性。在PHP开发中,PHPUnit是最流行的单元测试框架,几乎是PHP单元测试的事实标准。今天就来分享PHPUnit的实战教程,帮助大家快速上手PHP单元测试。

为什么要写单元测试

很多开发者觉得写单元测试浪费时间,不如直接写业务代码。但是从长期来看,写单元测试能大大提高开发效率和代码质量。

1. 发现bug:单元测试能在开发阶段就发现代码中的bug,而不是等到上线后才发现,大大降低了修复成本。

2. 保证重构安全:有了单元测试,我们可以放心地重构代码,只要运行测试就能知道重构是否破坏了原有功能。

3. 文档作用:单元测试是最好的文档,通过测试用例可以清楚地了解代码的功能和使用方法。

4. 提高设计质量:为了让代码可测试,我们会自然地写出低耦合、高内聚的代码,提高代码的设计质量。

5. 减少调试时间:有了单元测试,出现问题时可以快速定位到具体的函数或方法,大大减少调试时间。

安装PHPUnit

PHPUnit推荐通过Composer安装:

# 安装PHPUnit
composer require --dev phpunit/phpunit

# 查看版本
vendor/bin/phpunit --version

2016年时,PHPUnit的最新稳定版本是PHPUnit 5.x,支持PHP 5.6和PHP 7.0。

第一个测试用例

假设我们有一个简单的计算器类:

<?php
// src/Calculator.php
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('除数不能为零');
        }
        return $a / $b;
    }
}

对应的测试用例:

<?php
// tests/CalculatorTest.php
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    private $calculator;

    protected function setUp()
    {
        $this->calculator = new Calculator();
    }

    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);
    }

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

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

    public function testDivideByZero()
    {
        $this->expectException(InvalidArgumentException::class);
        $this->calculator->divide(6, 0);
    }
}

运行测试:

vendor/bin/phpunit tests/CalculatorTest.php

PHPUnit核心概念

1. TestCase(测试用例):所有测试类都继承自PHPUnit\Framework\TestCase,测试方法以test开头。

2. setUp()和tearDown()

  • setUp():每个测试方法执行前调用,用于初始化测试环境
  • tearDown():每个测试方法执行后调用,用于清理测试环境

3. 断言(Assertion):断言是测试的核心,用于验证代码的行为是否符合预期。常用的断言方法:

  • assertEquals($expected, $actual):断言两个值相等
  • assertSame($expected, $actual):断言两个值完全相同(类型和值)
  • assertTrue($condition):断言条件为真
  • assertFalse($condition):断言条件为假
  • assertNull($value):断言值为null
  • assertNotNull($value):断言值不为null
  • assertContains($needle, $haystack):断言数组包含某个元素
  • assertCount($expectedCount, $array):断言数组长度
  • assertInstanceOf($expected, $actual):断言对象是某个类的实例
  • expectException($exception):断言会抛出某个异常

4. 数据提供器(Data Provider):用于为同一个测试方法提供多组测试数据:

/**
 * @dataProvider additionProvider
 */
public function testAdd($a, $b, $expected)
{
    $this->assertEquals($expected, $this->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],
    ];
}

5. 测试替身(Test Double)

  • Stub(桩对象):替换依赖,返回预设值
  • Mock(模拟对象):验证方法是否被调用,调用参数和次数
// Stub示例
public function testStub()
{
    $stub = $this->createMock(SomeClass::class);
    $stub->method('doSomething')
         ->willReturn('foo');
    $this->assertEquals('foo', $stub->doSomething());
}

// Mock示例
public function testMock()
{
    $mock = $this->createMock(SomeClass::class);
    $mock->expects($this->once())
         ->method('doSomething')
         ->with($this->equalTo('param'));
    $mock->doSomething('param');
}

配置文件phpunit.xml

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>
            <directory suffix=".php">src/</directory>
        </whitelist>
    </filter>
    <logging>
        <log type="coverage-html" target="build/coverage"/>
        <log type="junit" target="build/logfile.xml"/>
    </logging>
</phpunit>

配置好后,直接运行vendor/bin/phpunit即可执行所有测试。

代码覆盖率

PHPUnit支持生成代码覆盖率报告,需要安装Xdebug扩展:

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

# 生成文本格式的覆盖率报告
vendor/bin/phpunit --coverage-text

代码覆盖率能帮助我们发现哪些代码没有被测试覆盖到,从而补充测试用例。但是要注意,100%的代码覆盖率不代表代码没有bug,它只是说明所有代码都被执行过了。

实战:测试数据库操作

数据库操作的测试比较复杂,通常需要使用测试数据库或Mock。这里介绍使用SQLite内存数据库进行测试的方法:

<?php
// src/UserRepository.php
class UserRepository
{
    private $db;

    public function __construct(PDO $db)
    {
        $this->db = $db;
    }

    public function findById($id)
    {
        $stmt = $this->db->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch(PDO::FETCH_ASSOC);
    }

    public function create($name, $email)
    {
        $stmt = $this->db->prepare('INSERT INTO users (name, email) VALUES (?, ?)');
        $stmt->execute([$name, $email]);
        return $this->db->lastInsertId();
    }
}
<?php
// tests/UserRepositoryTest.php
use PHPUnit\Framework\TestCase;

class UserRepositoryTest extends TestCase
{
    private $db;
    private $repository;

    protected function setUp()
    {
        // 使用SQLite内存数据库
        $this->db = new PDO('sqlite::memory:');
        $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        // 创建表
        $this->db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)');
        $this->repository = new UserRepository($this->db);
    }

    public function testCreate()
    {
        $id = $this->repository->create('张三', 'zhangsan@example.com');
        $this->assertNotEmpty($id);

        $user = $this->repository->findById($id);
        $this->assertEquals('张三', $user['name']);
        $this->assertEquals('zhangsan@example.com', $user['email']);
    }

    public function testFindByIdNotFound()
    {
        $user = $this->repository->findById(999);
        $this->assertFalse($user);
    }
}

单元测试最佳实践

1. 测试命名清晰:测试方法名应该描述测试的场景和预期结果,比如testAddPositiveNumberstestDivideByZeroThrowsException

2. 一个测试只测一件事:每个测试方法应该只验证一个行为,这样失败时能快速定位问题。

3. 测试独立:测试之间不应该有依赖,每个测试都应该能独立运行,运行顺序不影响结果。

4. 测试快速:单元测试应该快速运行,避免依赖外部资源(数据库、网络、文件系统),使用Mock或Stub替换依赖。

5. 测试可读:测试代码应该和业务代码一样清晰易读,使用有意义的变量名,添加必要的注释。

6. 先写测试(TDD):测试驱动开发(Test-Driven Development),先写测试,再写代码,能保证代码的可测试性和正确性。

7. 持续集成:将单元测试集成到CI流程中,每次提交代码都自动运行测试,保证代码质量。

总结

PHPUnit是PHP单元测试的标准框架,掌握PHPUnit能大大提高我们的代码质量和开发效率。单元测试不是浪费时间,而是投资,它能在长期带来丰厚的回报。

希望这篇教程能帮助大家快速上手PHP单元测试,写出更高质量的代码。