PHP 单元测试:PHPUnit 上手与实战

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

PHP 8.4 · PHPUnit 11

PHP 项目常见的状态是:改一处代码,靠手动点几个页面确认没坏。单元测试把这件事变成一条命令。这一篇从 Composer 装 PHPUnit 开始,把断言、数据供给器、测试替身和数据库测试串成一套能长期维护的做法。

环境准备

PHPUnit 通过 Composer 装成开发依赖,不要全局安装——不同项目可能锁在不同大版本:

composer require --dev phpunit/phpunit ^11.5

确认版本:

vendor/bin/phpunit --version

PHPUnit 11 要求 PHP 8.2 以上,与 PHP 8.4 兼容。顺手在 composer.json 里配好自动加载与测试脚本:

{
  "autoload": {
    "psr-4": { "App\\": "src/" }
  },
  "autoload-dev": {
    "psr-4": { "App\\Tests\\": "tests/" }
  },
  "scripts": {
    "test": "phpunit"
  }
}

autoload-dev 只在开发时加载,测试类不会进生产产物。改完跑一次 composer dump-autoload。

目录约定:

src/            # 生产代码,命名空间 App\
tests/          # 测试,命名空间 App\Tests\
phpunit.xml     # 配置
phpunit.xml.dist

phpunit.xml

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         failOnWarning="true"
         failOnRisky="true">
  <testsuites>
    <testsuite name="unit">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
  <source>
    <include>
      <directory>src</directory>
    </include>
  </source>
</phpunit>

几个关键点:

  • bootstrap 指向 Composer 的自动加载,测试类才能 use App\...。
  • cacheDirectory 存放测试结果缓存,加进 .gitignore。
  • <source> 声明「被测代码在哪」,覆盖率只统计它,不含 tests/ 与 vendor/。
  • failOnWarning / failOnRisky 让「没有断言的测试」也失败,防止假通过。

跑测试:

vendor/bin/phpunit            # 全部
vendor/bin/phpunit --testdox  # 用方法名生成可读清单
vendor/bin/phpunit --filter testFormatsCentsAsCurrency

第一个测试

被测类是一个格式化金额的小工具:

<?php

declare(strict_types=1);

namespace App;

final class Price
{
    public static function format(int $cents, string $currency = 'CNY'): string
    {
        if ($cents < 0) {
            throw new \InvalidArgumentException('cents must be >= 0');
        }

        return number_format($cents / 100, 2, '.', ',') . ' ' . $currency;
    }
}

测试继承 TestCase,每个测试方法是一条行为:

<?php

declare(strict_types=1);

namespace App\Tests;

use App\Price;
use PHPUnit\Framework\TestCase;

final class PriceTest extends TestCase
{
    public function testFormatsCentsAsCurrency(): void
    {
        self::assertSame('19.99 CNY', Price::format(1999));
    }

    public function testRejectsNegativeAmount(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('cents must be >= 0');

        Price::format(-1);
    }
}

expectException 必须写在触发异常的调用之前,异常抛出后代码不会继续执行,写在后面永远不会生效。

断言怎么选

断言 比较方式 用在哪
assertSame ===,类型与值都相同 字符串、整数、同一个对象
assertEquals 宽松相等 只关心值的场景,慎用
assertTrue / assertNull 布尔与空值 状态判断
assertCount 数组 / Countable 数量 集合结果
assertArrayHasKey 键存在 配置、响应结构
assertStringContainsString 子串 日志、异常消息
assertMatchesRegularExpression 正则 格式校验
assertInstanceOf 类型 工厂、异常返回值

assertSame 应该是默认选择。assertEquals 会把 "1" 和 1 判为相等,类型错误就这样被放过。

数据供给器

同一逻辑多组输入,用数据供给器,而不是复制十个测试方法:

use PHPUnit\Framework\Attributes\DataProvider;

#[DataProvider('amountProvider')]
public function testFormatsVariousAmounts(int $cents, string $expected): void
{
    self::assertSame($expected, Price::format($cents));
}

public static function amountProvider(): iterable
{
    yield 'zero' => [0, '0.00 CNY'];
    yield 'one unit' => [100, '1.00 CNY'];
    yield 'thousands' => [100000, '1,000.00 CNY'];
}

供给器方法必须是 public static,返回数组或 iterable;用 yield 加字符串键,失败时会直接告诉你是哪一组。PHPUnit 11 用 attribute,旧的 @dataProvider 注释写法正在被淘汰,新代码别再用。

测试替身

依赖外部服务时,用替身断开真实调用。接口优先:

<?php

declare(strict_types=1);

namespace App;

interface Mailer
{
    public function send(string $to, string $subject): void;
}
final class SignupService
{
    public function __construct(private Mailer $mailer) {}

    public function register(string $email): void
    {
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            throw new \InvalidArgumentException('invalid email');
        }

        $this->mailer->send($email, 'Welcome');
    }
}

验证调用用 createMock:

public function testSendsWelcomeMail(): void
{
    $mailer = $this->createMock(Mailer::class);
    $mailer->expects(self::once())
        ->method('send')
        ->with('ada@example.com', 'Welcome');

    (new SignupService($mailer))->register('ada@example.com');
}

只提供返回值用 createStub,它不对调用次数做断言,语义更清楚:

$mailer = $this->createStub(Mailer::class);
$mailer->method('send')->willThrowException(new \RuntimeException('smtp down'));

常用期望:

$mock->expects(self::never())->method('send');
$mock->expects(self::exactly(2))->method('send');
$mock->expects(self::atLeastOnce())->method('send');

createMock 会自动满足返回类型(返回 null、0、空数组等),不需要手写空实现。但别 mock 你正在测的类——那样测的是替身,不是代码。

测数据库代码

不要为单元测试搭一套 MySQL。PDO 支持 SQLite 内存库,建表在 setUp 里做,每个测试都是干净状态:

final class UserRepositoryTest extends TestCase
{
    private \PDO $pdo;

    protected function setUp(): void
    {
        $this->pdo = new \PDO('sqlite::memory:');
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $this->pdo->exec(
            'CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)'
        );
    }

    public function testInsertsUser(): void
    {
        $repo = new UserRepository($this->pdo);
        $repo->create('ada@example.com');

        $count = (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();

        self::assertSame(1, $count);
    }
}

setUp 每个测试方法执行一次,tearDown 负责清理。不要写成 setUpBeforeClass——那个整个类只跑一次,数据会在测试之间残留。

SQLite 与 MySQL 有差异(类型、AUTO_INCREMENT、函数名),所以涉及方言的 SQL 仍需在真实数据库上跑一次集成测试;单元层用它挡住大部分逻辑错误就够。

覆盖率

覆盖率需要 Xdebug 或 pcov。Xdebug 更常见但更慢,CI 里可以只开覆盖模式:

# Linux / macOS
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
# Windows PowerShell
$env:XDEBUG_MODE="coverage"; vendor/bin/phpunit --coverage-text

HTML 报告更适合定位漏测分支:

vendor/bin/phpunit --coverage-html build/coverage

覆盖率只说明代码被执行过。一个只调用方法、不做任何断言的测试,同样能把行覆盖刷满。先补边界与失败分支,再看数字。

放进 CI

- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpunit --testdox

几个实用参数:

  • --fail-on-warning、--fail-on-risky:CI 不放过「没有断言」的测试。
  • --order-by=random:随机顺序暴露测试之间的隐式依赖;配 --random-order-seed 可复现。
  • --stop-on-failure:第一个失败就停,日志更短。
  • --coverage-text:覆盖率写进日志,配合 --coverage-clover 上传给外部服务。

本地 composer test,提交前跑一次完整套件,和静态检查一起进 CI。

常见错误与注意事项

1. 测试之间共享状态

静态属性、单例、未关闭的连接都会让结果依赖执行顺序。每个测试自己准备数据,setUp 里重建。

2. 用 assertEquals 比较一切

"0"、0、false 在宽松比较下容易混过去。能用 assertSame 就用它。

3. expectException 写在调用之后

异常已经抛出,测试直接失败,后面那行根本执行不到。永远先声明期望。

4. 数据供给器不是静态方法

PHPUnit 10 起要求 public static,否则报错或弃用警告。

5. mock 具体类而不是接口

构造函数不会执行,但方法签名、final 限制都会带来麻烦。依赖接口,测试才好替。

6. 在测试里 sleep

等待真实时间会让套件越来越慢。时间相关逻辑注入 clock,或用 Carbon::setTestNow()。

7. 断言实现细节

断言「内部调用了哪个私有方法」而不是输出结果,重构一次红一片。优先测公开行为。

8. 只测 getter 和构造函数

这类测试不提供任何保护。把精力放在分支、边界与失败路径上。

小结

  • PHPUnit 装成 devDependency,autoload-dev 加载测试命名空间,bootstrap 指向 Composer。
  • assertSame 是默认断言;多组输入用 #[DataProvider],供给器必须 public static。
  • createMock 验证交互,createStub 只提供返回值;依赖接口而不是具体类。
  • 数据库代码用 SQLite 内存库 + setUp 建表,每个测试独立。
  • 覆盖率看趋势,CI 里配 --fail-on-risky 与随机顺序。

下一步