在软件开发中,测试是一个非常重要的环节。测试能帮我们发现代码中的bug,保证代码的质量,确保代码按照预期工作。

测试分为很多种:单元测试、集成测试、功能测试、性能测试、安全测试等。其中,单元测试是最基础、最重要的测试。

单元测试,是对软件中最小的可测试单元进行检查和验证。在PHP中,最小的可测试单元通常是一个函数或一个类的方法。单元测试,就是编写代码来测试这些函数或方法,确保它们按照预期工作。

单元测试的好处:

  1. 保证代码质量:单元测试能帮我们发现代码中的bug,保证代码的正确性。
  2. 方便重构:有了单元测试,重构代码时就不用担心改坏了功能,只要运行测试,就能知道重构是否正确。
  3. 文档作用:单元测试本身就是一种文档,通过看测试代码,就能知道函数或方法是如何使用的。
  4. 提高开发效率:虽然写单元测试需要花时间,但长期来看,单元测试能减少调试和修复bug的时间,提高开发效率。
  5. 减少回归bug:修改代码后,运行单元测试,就能知道是否引入了新的bug,减少回归bug。

PHPUnit是PHP最流行的单元测试框架,它是xUnit家族的一员(和JUnit、NUnit等类似),提供了丰富的断言和测试功能,能帮我们轻松编写和运行单元测试。

今天分享PHPUnit单元测试的详解,从安装到使用,帮你掌握PHP单元测试。

PHPUnit安装

方法一:Composer安装(推荐)

用Composer安装PHPUnit:

composer require --dev phpunit/phpunit

这会把PHPUnit安装到项目的vendor目录中,作为开发依赖。安装完成后,可以用以下命令运行PHPUnit:

vendor/bin/phpunit

Windows下:

vendor\bin\phpunit.bat

方法二:下载PHAR文件

下载PHPUnit的PHAR文件:

wget https://phar.phpunit.de/phpunit.phar
chmod +x phpunit.phar
mv phpunit.phar /usr/local/bin/phpunit

然后就可以直接用phpunit命令运行测试。

验证安装

phpunit --version

如果显示PHPUnit的版本号,说明安装成功。

PHPUnit基本使用

1. 编写第一个测试

假设我们有一个简单的函数,需要测试:

// src/Math.php
class Math {
    public function add($a, $b) {
        return $a + $b;
    }
    
    public function subtract($a, $b) {
        return $a - $b;
    }
}

编写测试类:

// tests/MathTest.php
use PHPUnit\Framework\TestCase;

class MathTest extends TestCase {
    public function testAdd() {
        $math = new Math();
        $result = $math->add(2, 3);
        $this->assertEquals(5, $result);
    }
    
    public function testSubtract() {
        $math = new Math();
        $result = $math->subtract(5, 3);
        $this->assertEquals(2, $result);
    }
}

测试类的命名规则:

  • 测试类继承PHPUnit\Framework\TestCase
  • 测试类名通常是被测试类名 + Test
  • 测试方法名通常是test + 被测试方法名,或者以test开头
  • 测试文件通常放在tests目录下

2. 运行测试

phpunit tests/MathTest.php

或者运行整个tests目录:

phpunit tests

运行结果:

PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 00:00.005, Memory: 4.00 MB

OK (2 tests, 2 assertions)

两个点表示两个测试都通过了,OK (2 tests, 2 assertions)表示2个测试,2个断言,全部通过。

3. 测试失败

如果测试失败,会显示详细的错误信息:

public function testAdd() {
    $math = new Math();
    $result = $math->add(2, 3);
    $this->assertEquals(6, $result); // 预期6,实际5
}

运行结果:

PHPUnit 9.5.0 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)

Time: 00:00.010, Memory: 4.00 MB

There was 1 failure:

1) MathTest::testAdd
Failed asserting that 5 matches expected 6.

/path/to/tests/MathTest.php:8

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

F表示测试失败,下面显示了失败的原因和位置。

常用断言

断言是单元测试的核心,用来验证结果是否符合预期。PHPUnit提供了丰富的断言方法:

1. 相等断言

$this->assertEquals($expected, $actual); // 相等(==)
$this->assertNotEquals($expected, $actual); // 不相等
$this->assertSame($expected, $actual); // 完全相同(===,类型和值都相同)
$this->assertNotSame($expected, $actual); // 不完全相同

2. 布尔断言

$this->assertTrue($condition); // 为真
$this->assertFalse($condition); // 为假

3. 空值断言

$this->assertNull($value); // 为null
$this->assertNotNull($value); // 不为null
$this->assertEmpty($value); // 为空(null、''、0、'0'、[]、false都算空)
$this->assertNotEmpty($value); // 不为空

4. 数值断言

$this->assertGreaterThan($expected, $actual); // 大于
$this->assertGreaterThanOrEqual($expected, $actual); // 大于等于
$this->assertLessThan($expected, $actual); // 小于
$this->assertLessThanOrEqual($expected, $actual); // 小于等于

5. 字符串断言

$this->assertStringContainsString($needle, $haystack); // 包含字符串
$this->assertStringStartsWith($prefix, $string); // 以...开头
$this->assertStringEndsWith($suffix, $string); // 以...结尾
$this->assertMatchesRegularExpression($pattern, $string); // 匹配正则表达式
$this->assertStringMatchesFormat($format, $string); // 匹配格式

6. 数组断言

$this->assertArrayHasKey($key, $array); // 数组有指定键
$this->assertArrayNotHasKey($key, $array); // 数组没有指定键
$this->assertContains($needle, $array); // 数组包含指定值
$this->assertNotContains($needle, $array); // 数组不包含指定值
$this->assertCount($expectedCount, $array); // 数组元素个数
$this->assertEmpty($array); // 数组为空

7. 对象断言

$this->assertInstanceOf($className, $object); // 对象是指定类的实例
$this->assertObjectHasAttribute($attributeName, $object); // 对象有指定属性

8. 文件断言

$this->assertFileExists($filename); // 文件存在
$this->assertFileNotExists($filename); // 文件不存在
$this->assertFileEquals($expected, $actual); // 两个文件内容相同

9. 异常断言

$this->expectException(\Exception::class); // 预期抛出异常
$this->expectExceptionMessage('错误信息'); // 预期异常消息
$this->expectExceptionCode(100); // 预期异常代码

测试夹具(Fixture)

测试夹具,是测试运行前需要准备的环境和数据。PHPUnit提供了setUp()tearDown()方法,在每个测试方法运行前和运行后执行。

use PHPUnit\Framework\TestCase;

class UserTest extends TestCase {
    private $user;
    
    protected function setUp(): void {
        // 每个测试方法运行前执行
        $this->user = new User();
        $this->user->setName('张三');
        $this->user->setEmail('zhangsan@example.com');
    }
    
    protected function tearDown(): void {
        // 每个测试方法运行后执行
        $this->user = null;
    }
    
    public function testGetName() {
        $this->assertEquals('张三', $this->user->getName());
    }
    
    public function testGetEmail() {
        $this->assertEquals('zhangsan@example.com', $this->user->getEmail());
    }
}

setUp()方法在每个测试方法运行前执行,用来初始化测试数据;tearDown()方法在每个测试方法运行后执行,用来清理测试数据。

如果需要在所有测试方法运行前只执行一次,可以用setUpBeforeClass()tearDownAfterClass()

public static function setUpBeforeClass(): void {
    // 所有测试方法运行前只执行一次
    // 比如创建数据库连接、创建表等
}

public static function tearDownAfterClass(): void {
    // 所有测试方法运行后只执行一次
    // 比如关闭数据库连接、删除表等
}

数据提供器(Data Provider)

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

use PHPUnit\Framework\TestCase;

class MathTest extends TestCase {
    /**
     * @dataProvider additionProvider
     */
    public function testAdd($a, $b, $expected) {
        $math = new Math();
        $this->assertEquals($expected, $math->add($a, $b));
    }
    
    public function additionProvider() {
        return [
            [0, 0, 0],
            [1, 1, 2],
            [2, 3, 5],
            [-1, 1, 0],
            [100, -50, 50],
        ];
    }
}

@dataProvider注解指定了数据提供器方法,数据提供器返回一个二维数组,每个子数组的元素会作为参数传递给测试方法。这样,testAdd方法会用5组数据运行5次。

测试异常

测试方法是否抛出了预期的异常:

public function testDivideByZero() {
    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('除数不能为0');
    
    $math = new Math();
    $math->divide(10, 0);
}

如果方法抛出了预期的异常,测试通过;如果没有抛出异常,或者抛出了其他异常,测试失败。

测试输出

测试方法的输出:

public function testOutput() {
    $this->expectOutputString('Hello World');
    echo 'Hello World';
}

expectOutputString()验证方法的输出是否等于预期的字符串。

跳过测试和标记测试

有时候,某些测试在特定条件下需要跳过,或者标记为不完整:

public function testSomething() {
    if (!extension_loaded('mysqli')) {
        $this->markTestSkipped('需要mysqli扩展');
    }
    // 测试代码
}

public function testIncomplete() {
    $this->markTestIncomplete('这个测试还没写完');
}

markTestSkipped()跳过测试,markTestIncomplete()标记测试为不完整。

PHPUnit配置文件

可以用XML配置文件来配置PHPUnit,文件名通常是phpunit.xml

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         verbose="true">
    <testsuites>
        <testsuite name="Application Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <coverage>
        <include>
            <directory>src</directory>
        </include>
    </coverage>
</phpunit>

配置文件说明:

  • bootstrap:测试运行前加载的文件,通常是Composer的autoload.php
  • colors:是否显示彩色输出
  • verbose:是否显示详细信息
  • testsuites:测试套件,指定测试文件目录
  • coverage:代码覆盖率配置,指定需要统计覆盖率的目录

有了配置文件,直接运行phpunit命令就会自动读取配置。

代码覆盖率

PHPUnit可以生成代码覆盖率报告,看看测试覆盖了多少代码。

生成HTML格式的覆盖率报告:

phpunit --coverage-html coverage-report

这会在coverage-report目录生成HTML格式的覆盖率报告,用浏览器打开index.html就能查看。

注意:代码覆盖率需要安装Xdebug或PCOV扩展。

实战:测试一个用户注册类

假设我们有一个用户注册类,需要测试:

// src/User.php
class User {
    private $name;
    private $email;
    private $password;
    
    public function __construct($name, $email, $password) {
        $this->setName($name);
        $this->setEmail($email);
        $this->setPassword($password);
    }
    
    public function setName($name) {
        if (empty($name)) {
            throw new \InvalidArgumentException('用户名不能为空');
        }
        if (strlen($name) < 2 || strlen($name) > 20) {
            throw new \InvalidArgumentException('用户名长度必须在2-20之间');
        }
        $this->name = $name;
    }
    
    public function setEmail($email) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('邮箱格式不正确');
        }
        $this->email = $email;
    }
    
    public function setPassword($password) {
        if (strlen($password) < 6) {
            throw new \InvalidArgumentException('密码长度不能少于6位');
        }
        $this->password = password_hash($password, PASSWORD_DEFAULT);
    }
    
    public function getName() {
        return $this->name;
    }
    
    public function getEmail() {
        return $this->email;
    }
    
    public function verifyPassword($password) {
        return password_verify($password, $this->password);
    }
}

编写测试:

// tests/UserTest.php
use PHPUnit\Framework\TestCase;

class UserTest extends TestCase {
    public function testValidUserCreation() {
        $user = new User('张三', 'zhangsan@example.com', '123456');
        $this->assertEquals('张三', $user->getName());
        $this->assertEquals('zhangsan@example.com', $user->getEmail());
    }
    
    public function testEmptyNameThrowsException() {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('用户名不能为空');
        new User('', 'test@example.com', '123456');
    }
    
    public function testShortNameThrowsException() {
        $this->expectException(\InvalidArgumentException::class);
        new User('A', 'test@example.com', '123456');
    }
    
    public function testLongNameThrowsException() {
        $this->expectException(\InvalidArgumentException::class);
        new User(str_repeat('A', 21), 'test@example.com', '123456');
    }
    
    public function testInvalidEmailThrowsException() {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('邮箱格式不正确');
        new User('张三', 'invalid-email', '123456');
    }
    
    public function testShortPasswordThrowsException() {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('密码长度不能少于6位');
        new User('张三', 'test@example.com', '12345');
    }
    
    public function testPasswordVerification() {
        $user = new User('张三', 'test@example.com', '123456');
        $this->assertTrue($user->verifyPassword('123456'));
        $this->assertFalse($user->verifyPassword('wrongpassword'));
    }
    
    /**
     * @dataProvider validEmailProvider
     */
    public function testValidEmails($email) {
        $user = new User('张三', $email, '123456');
        $this->assertEquals($email, $user->getEmail());
    }
    
    public function validEmailProvider() {
        return [
            ['test@example.com'],
            ['user.name@domain.co.uk'],
            ['user+tag@example.org'],
            ['123456@example.com'],
        ];
    }
}

这个测试类,测试了用户注册类的各种情况:正常创建、用户名不能为空、用户名太短/太长、邮箱格式不正确、密码太短、密码验证、合法邮箱等。用数据提供器测试了多个合法邮箱。

单元测试最佳实践

  1. 测试要独立:每个测试方法应该独立运行,不依赖其他测试的结果,也不依赖运行顺序。
  2. 测试要快速:单元测试应该快速运行,不要依赖数据库、网络等外部资源。如果需要,用Mock替代。
  3. 测试要可读:测试代码应该清晰易读,方法名和变量名要有意义。
  4. 一个测试只测一件事:每个测试方法只测试一个功能点,这样失败时容易定位问题。
  5. 测试要覆盖边界条件:不仅要测试正常情况,还要测试边界条件和异常情况。
  6. 先写测试,再写代码(TDD):测试驱动开发,先写测试,再写代码让测试通过,能保证代码的可测试性和质量。
  7. 定期运行测试:每次修改代码后,都要运行测试,确保没有引入新的bug。
  8. 保持测试更新:代码修改后,对应的测试也要更新,保持测试和代码同步。
  9. 不要测试实现细节:测试应该测试行为,而不是实现细节,这样重构时不需要改测试。
  10. 合理使用Mock:对于依赖外部资源(数据库、API等)的代码,用Mock替代,让测试独立快速。

总结

PHPUnit是PHP最流行的单元测试框架,掌握它能帮你写出更高质量的代码。

核心要点:

  1. 安装:Composer安装(推荐)或下载PHAR文件
  2. 基本使用:测试类继承TestCase,测试方法以test开头,用断言验证结果
  3. 常用断言:assertEquals、assertTrue、assertFalse、assertNull、assertEmpty、assertContains、assertArrayHasKey、assertInstanceOf、expectException等
  4. 测试夹具:setUp()/tearDown()在每个测试前后执行,setUpBeforeClass()/tearDownAfterClass()在所有测试前后执行
  5. 数据提供器:用@dataProvider注解,用一组数据运行同一个测试
  6. 测试异常:用expectException()测试方法是否抛出预期异常
  7. 配置文件:phpunit.xml配置测试套件、bootstrap、覆盖率等
  8. 代码覆盖率:用--coverage-html生成覆盖率报告
  9. 最佳实践:测试独立、快速、可读,一个测试测一件事,覆盖边界条件,TDD,定期运行测试

单元测试,是保证代码质量的重要手段。虽然写单元测试需要花时间,但长期来看,它能减少bug,方便重构,提高开发效率。作为PHP开发者,应该养成写单元测试的习惯,用PHPUnit为你的代码保驾护航。