作为PHP开发者,你有没有过这样的经历:改了一个功能,结果另一个地方出bug了;上线了一个新功能,用户反馈说老功能不能用了;排查一个bug,花了半天时间,最后发现是一个很小的改动引起的连锁反应。

这些问题,很多时候都是因为没有测试导致的。我们写完代码,手动测一下觉得没问题就上线了,但手动测试覆盖不了所有的场景,很容易遗漏。而且代码是不断迭代的,今天改了这里,明天改了那里,谁也不能保证改动不会影响其他地方。

单元测试就是为了解决这些问题而生的。单元测试是对代码中最小的可测试单元(函数、方法、类)进行验证,确保它们在各种情况下都能正确工作。有了单元测试,我们可以:

  1. 保证代码质量:每个单元都经过测试,减少bug
  2. 放心重构:重构代码后,运行测试就能知道有没有破坏原有功能
  3. 文档作用:测试用例就是最好的文档,看测试就知道代码怎么用
  4. 提升设计:为了让代码可测试,我们会写出更松耦合、更易维护的代码
  5. 减少调试时间:出了问题,测试能快速定位是哪个单元的问题

PHPUnit是PHP最流行的单元测试框架,几乎是PHP单元测试的标准。今天分享PHPUnit的入门教程,帮你从零开始学会写单元测试。

安装PHPUnit

安装PHPUnit最简单的方式是用Composer:

composer require --dev phpunit/phpunit ^5.0

安装完成后,vendor/bin目录下会有phpunit可执行文件。运行:

vendor/bin/phpunit --version

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

也可以下载phpunit.phar全局使用:

wget https://phar.phpunit.de/phpunit-5.7.phar
chmod +x phpunit-5.7.phar
sudo mv phpunit-5.7.phar /usr/local/bin/phpunit
phpunit --version

配置文件

在项目根目录创建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>
</phpunit>

关键配置:

  • bootstrap:测试启动时加载的文件,一般是Composer的autoload
  • colors:终端输出是否带颜色
  • testsuites:测试套件,指定测试文件目录
  • whitelist:代码覆盖率白名单,指定要统计覆盖率的源码目录

第一个测试

假设我们有一个简单的计算器类,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;
    }
}

对应的测试文件,tests/CalculatorTest.php:

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

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

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

    public function testMultiply()
    {
        $calc = new Calculator();
        $result = $calc->multiply(4, 3);
        $this->assertEquals(12, $result);
    }

    public function testDivide()
    {
        $calc = new Calculator();
        $result = $calc->divide(10, 2);
        $this->assertEquals(5, $result);
    }

    public function testDivideByZero()
    {
        $this->expectException(\InvalidArgumentException::class);
        $calc = new Calculator();
        $calc->divide(10, 0);
    }
}

测试类的命名规范:

  • 测试类继承PHPUnit\Framework\TestCase
  • 测试类名以Test结尾
  • 测试方法名以test开头,或者用/* @test /注解
  • 测试文件放在tests目录,文件名以Test结尾

运行测试:

vendor/bin/phpunit

输出:

PHPUnit 5.7.27 by Sebastian Bergmann and contributors.

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

Time: 100 ms, Memory: 4.00MB

OK (5 tests, 5 assertions)

5个测试全部通过!

常用断言方法

PHPUnit提供了丰富的断言方法,常用的有:

  • assertEquals($expected, $actual):断言两个值相等(==比较)
  • assertSame($expected, $actual):断言两个值完全相同(===比较,类型也相同)
  • assertTrue($condition):断言条件为真
  • assertFalse($condition):断言条件为假
  • assertNull($value):断言值为null
  • assertNotNull($value):断言值不为null
  • assertContains($needle, $haystack):断言数组包含某个元素
  • assertCount($expected, $array):断言数组元素个数
  • assertEmpty($value):断言值为空
  • assertNotEmpty($value):断言值不为空
  • assertInstanceOf($expected, $actual):断言对象是某个类的实例
  • expectException($exception):断言会抛出某个异常
  • assertRegExp($pattern, $string):断言字符串匹配正则
  • assertGreaterThan($expected, $actual):断言实际值大于期望值
  • assertLessThan($expected, $actual):断言实际值小于期望值

合理使用断言,能让测试更精确、更有意义。

setUp和tearDown

如果每个测试方法都需要创建相同的对象,可以用setUp方法在每个测试前初始化:

class CalculatorTest extends TestCase
{
    private $calc;

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

    protected function tearDown()
    {
        $this->calc = null;
    }

    public function testAdd()
    {
        $result = $this->calc->add(2, 3);
        $this->assertEquals(5, $result);
    }
    // ...
}
  • setUp():每个测试方法执行前调用,用于初始化测试数据
  • tearDown():每个测试方法执行后调用,用于清理资源
  • setUpBeforeClass():整个测试类执行前调用一次(静态方法)
  • tearDownAfterClass():整个测试类执行后调用一次(静态方法)

数据提供者

如果同一个测试需要用多组数据测试,可以用数据提供者(Data Provider):

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

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

数据提供者是一个返回数组的方法,每个子数组是一组测试数据,会作为参数传给测试方法。用数据提供者可以用一组测试方法测试多组数据,减少重复代码。

Mock对象

测试依赖其他类的代码时,可以用Mock对象模拟依赖,隔离测试单元。

假设我们有一个UserService,依赖UserRepository:

class UserService
{
    private $repository;

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

    public function getUser($id)
    {
        $user = $this->repository->find($id);
        if (!$user) {
            throw new \RuntimeException('User not found');
        }
        return $user;
    }
}

测试时用Mock模拟UserRepository:

class UserServiceTest extends TestCase
{
    public function testGetUser()
    {
        $user = ['id' => 1, 'name' => 'Tom'];
        $mockRepo = $this->createMock(UserRepository::class);
        $mockRepo->method('find')
                 ->with(1)
                 ->willReturn($user);

        $service = new UserService($mockRepo);
        $result = $service->getUser(1);
        $this->assertEquals($user, $result);
    }

    public function testGetUserNotFound()
    {
        $this->expectException(\RuntimeException::class);
        $mockRepo = $this->createMock(UserRepository::class);
        $mockRepo->method('find')->willReturn(null);

        $service = new UserService($mockRepo);
        $service->getUser(999);
    }
}

Mock对象能让我们只测试当前单元,不依赖外部资源(数据库、API等),测试更快、更稳定。

代码覆盖率

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

需要安装Xdebug或phpdbg扩展。运行:

vendor/bin/phpunit --coverage-html coverage-report

会在coverage-report目录生成HTML格式的覆盖率报告,用浏览器打开index.html就能看到详细的覆盖率数据,包括哪些行被测试覆盖了,哪些没有。

代码覆盖率不是越高越好,但它能帮我们发现没有被测试到的代码。一般来说,核心业务逻辑的覆盖率应该比较高。

最佳实践

  1. 测试要独立:每个测试方法不依赖其他测试的结果,测试之间不能有状态共享
  2. 测试要快速:单元测试应该很快就能跑完,不要在单元测试里连数据库、调API
  3. 测试要有意义:不要为了覆盖率而写无意义的测试,每个测试都应该验证一个具体的行为
  4. 测试要易读:测试代码也是代码,要写得清晰、易读,方法名和变量名要有意义
  5. 先写测试还是先写代码:TDD(测试驱动开发)是先写测试再写代码,但不强制,关键是要有测试
  6. 持续集成:把测试加入持续集成流程,每次提交自动运行测试,及时发现问题
  7. 测试也要维护:代码重构时,测试也要跟着更新,保持测试的有效性

总结

单元测试是保证代码质量的重要手段,PHPUnit是PHP单元测试的标准框架。掌握PHPUnit的基本用法——安装配置、编写测试、断言方法、setUp/tearDown、数据提供者、Mock对象、代码覆盖率——能帮你写出更可靠、更易维护的代码。

写单元测试一开始可能会觉得麻烦、费时间,但长期来看,它能帮你减少bug、放心重构、提升代码质量。如果你还没有写单元测试的习惯,强烈建议你从今天开始,给你的代码加上测试。

希望这篇入门教程能帮你走上单元测试之路。