Skip to content

PHPUnit 单元测试

概述

PHPUnit 是 PHP 生态中最广泛使用的单元测试框架,由 Sebastian Bergmann 创建和维护。它提供了一套完整的测试基础设施,包括断言库、测试运行器、代码覆盖率和模拟对象生成等功能。PHPUnit 遵循 xUnit 架构模式,是保障 PHP 代码质量、实现持续集成不可或缺的工具。

PHP 版本要求

PHPUnit 10.x 支持 PHP 8.1+。PHPUnit 11.x 支持 PHP 8.3+。本文基于 PHPUnit 10.x 和 PHP 8.1+ 编写。

基础概念

什么是单元测试

单元测试是对软件中最小可测试单元(通常是函数或方法)进行验证的过程。每个测试用例(Test Case)验证一个特定的行为或功能是否符合预期。

测试的 AAA 模式

单元测试通常遵循 Arrange-Act-Assert(准备-执行-断言)模式:

  1. Arrange(准备):设置测试数据和环境
  2. Act(执行):调用被测试的方法
  3. Assert(断言):验证结果是否符合预期

安装与配置

安装 PHPUnit

bash
# 通过 Composer 安装(推荐)
composer require --dev phpunit/phpunit:^10.0

# 安装到全局
composer global require phpunit/phpunit:^10.0

# 验证
./vendor/bin/phpunit --version

phpunit.xml 配置

xml
<!-- 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/10.5/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         failOnRisky="true"
         failOnWarning="true"
         cacheDirectory=".phpunit.cache">

    <!-- 测试源文件目录 -->
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>

    <!-- 源代码目录(覆盖率统计用) -->
    <source>
        <include>
            <directory>src</directory>
        </include>
        <exclude>
            <directory>src/Console</directory>
            <file>src/Kernel.php</file>
        </exclude>
    </source>

    <!-- 代码覆盖率过滤 -->
    <coverage>
        <report>
            <html outputDirectory="coverage/html"/>
            <text outputFile="coverage/coverage.txt"/>
        </report>
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </coverage>
</phpunit>

详细说明

TestCase 基础

基本测试结构

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

/**
 * Calculator 单元测试
 */
class CalculatorTest extends TestCase
{
    private Calculator $calculator;

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

    /**
     * 每个测试方法执行后调用
     */
    protected function tearDown(): void
    {
        unset($this->calculator);
    }

    /**
     * 测试加法
     */
    public function testAdd(): void
    {
        // Arrange
        $a = 5;
        $b = 3;

        // Act
        $result = $this->calculator->add($a, $b);

        // Assert
        $this->assertSame(8, $result);
    }

    /**
     * 测试除法
     */
    public function testDivide(): void
    {
        $result = $this->calculator->divide(10, 2);
        $this->assertSame(5.0, $result);
    }

    /**
     * 测试除以零异常
     */
    public function testDivideByZeroThrowsException(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('除数不能为零');

        $this->calculator->divide(10, 0);
    }

    /**
     * 测试浮点数精度
     */
    public function testFloatPrecision(): void
    {
        $result = $this->calculator->divide(1, 3);
        $this->assertEqualsWithDelta(0.333, $result, 0.001);
    }
}

断言方法大全

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class AssertionsDemoTest extends TestCase
{
    // === 值比较断言 ===

    public function testAssertSame(): void
    {
        $value = 42;
        $this->assertSame(42, $value); // 严格比较(===)
    }

    public function testAssertEquals(): void
    {
        $value = 42;
        $this->assertEquals('42', $value); // 宽松比较(==)
    }

    public function testAssertTrue(): void
    {
        $this->assertTrue(true);
    }

    public function testAssertFalse(): void
    {
        $this->assertFalse(false);
    }

    public function testAssertNull(): void
    {
        $this->assertNull(null);
    }

    public function testAssertNotNull(): void
    {
        $this->assertNotNull('value');
    }

    public function testAssertEmpty(): void
    {
        $this->assertEmpty([]);
        $this->assertEmpty('');
        $this->assertEmpty(0);
    }

    public function testAssertNotEmpty(): void
    {
        $this->assertNotEmpty([1, 2, 3]);
    }

    // === 类型断言 ===

    public function testAssertIsInt(): void
    {
        $this->assertIsInt(42);
    }

    public function testAssertIsString(): void
    {
        $this->assertIsString('hello');
    }

    public function testAssertIsArray(): void
    {
        $this->assertIsArray([1, 2, 3]);
    }

    public function testAssertIsBool(): void
    {
        $this->assertIsBool(true);
    }

    public function testAssertIsFloat(): void
    {
        $this->assertIsFloat(3.14);
    }

    public function testAssertIsObject(): void
    {
        $this->assertIsObject(new \stdClass());
    }

    public function testAssertIsCallable(): void
    {
        $this->assertIsCallable('strlen');
    }

    // === 数组断言 ===

    public function testAssertContains(): void
    {
        $this->assertContains(3, [1, 2, 3, 4]);
    }

    public function testAssertArrayHasKey(): void
    {
        $this->assertArrayHasKey('name', ['name' => 'Alice']);
    }

    public function testAssertCount(): void
    {
        $this->assertCount(3, [1, 2, 3]);
    }

    // === 字符串断言 ===

    public function testAssertStringContainsString(): void
    {
        $this->assertStringContainsString('world', 'hello world');
    }

    public function testAssertStringStartsWith(): void
    {
        $this->assertStringStartsWith('hello', 'hello world');
    }

    public function testAssertStringEndsWith(): void
    {
        $this->assertStringEndsWith('world', 'hello world');
    }

    public function testAssertMatchesRegularExpression(): void
    {
        $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', '2024-01-15');
    }

    public function testAssertStringEqualsFile(): void
    {
        $this->assertStringEqualsFile(__DIR__ . '/fixtures/expected.txt', 'expected content');
    }

    // === 异常断言 ===

    public function testExpectException(): void
    {
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('操作失败');
        $this->expectExceptionCode(1001);

        throw new \RuntimeException('操作失败', 1001);
    }

    // === 文件系统断言 ===

    public function testAssertFileExists(): void
    {
        $this->assertFileExists(__FILE__);
    }

    public function testAssertFileIsReadable(): void
    {
        $this->assertFileIsReadable(__FILE__);
    }

    public function testAssertDirectoryExists(): void
    {
        $this->assertDirectoryExists(__DIR__);
    }

    // === 数值比较 ===

    public function testAssertGreaterThan(): void
    {
        $this->assertGreaterThan(10, 20);
    }

    public function testAssertLessThan(): void
    {
        $this->assertLessThan(20, 10);
    }

    public function testAssertGreaterThanOrEqual(): void
    {
        $this->assertGreaterThanOrEqual(10, 10);
    }

    public function testAssertEqualsWithDelta(): void
    {
        // 浮点数比较
        $this->assertEqualsWithDelta(3.14159, M_PI, 0.001);
    }
}

数据提供器(Data Providers)

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class DataProviderTest extends TestCase
{
    /**
     * 测试数据提供器:验证邮箱格式
     *
     * @dataProvider validEmailProvider
     */
    public function testValidEmail(string $email): void
    {
        $validator = new EmailValidator();
        $this->assertTrue($validator->isValid($email));
    }

    /**
     * 有效邮箱数据
     */
    public static function validEmailProvider(): array
    {
        return [
            'basic' => ['user@example.com'],
            'with dots' => ['first.last@example.com'],
            'with plus' => ['user+tag@example.com'],
            'with numbers' => ['user123@example.com'],
            'subdomain' => ['user@mail.example.com'],
        ];
    }

    /**
     * 测试数据提供器:验证无效邮箱
     *
     * @dataProvider invalidEmailProvider
     */
    public function testInvalidEmail(string $email): void
    {
        $validator = new EmailValidator();
        $this->assertFalse($validator->isValid($email));
    }

    /**
     * 无效邮箱数据
     */
    public static function invalidEmailProvider(): array
    {
        return [
            'missing @' => ['userexample.com'],
            'missing domain' => ['user@'],
            'double @' => ['user@@example.com'],
            'space' => ['user @example.com'],
            'empty' => [''],
        ];
    }

    /**
     * 数学运算数据提供器
     *
     * @dataProvider additionProvider
     */
    public function testAdd(int $a, int $b, int $expected): void
    {
        $calc = new Calculator();
        $this->assertSame($expected, $calc->add($a, $b));
    }

    public static function additionProvider(): array
    {
        return [
            'positive numbers' => [2, 3, 5],
            'with zero' => [0, 5, 5],
            'negative numbers' => [-2, -3, -5],
            'mixed signs' => [-2, 5, 3],
            'large numbers' => [1000000, 2000000, 3000000],
        ];
    }
}

测试生命周期

php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class LifecycleTest extends TestCase
{
    /**
     * 类级别:在整个测试类之前执行一次
     */
    public static function setUpBeforeClass(): void
    {
        echo "setUpBeforeClass: 初始化共享资源" . PHP_EOL;
        // 例如:创建测试数据库连接
    }

    /**
     * 类级别:在整个测试类之后执行一次
     */
    public static function tearDownAfterClass(): void
    {
        echo "tearDownAfterClass: 清理共享资源" . PHP_EOL;
        // 例如:关闭数据库连接
    }

    /**
     * 方法级别:每个测试方法之前执行
     */
    protected function setUp(): void
    {
        parent::setUp();
        echo "setUp: 准备测试环境" . PHP_EOL;
        // 例如:创建被测试对象
    }

    /**
     * 方法级别:每个测试方法之后执行
     */
    protected function tearDown(): void
    {
        echo "tearDown: 清理测试环境" . PHP_EOL;
        parent::tearDown();
        // 例如:释放资源
    }

    /**
     * 在每个断言之前执行(PHPUnit 10+)
     */
    protected function assertPreConditions(): void
    {
        echo "assertPreConditions: 验证前置条件" . PHP_EOL;
        // 例如:确保环境变量已设置
    }

    /**
     * 在每个断言之后执行(PHPUnit 10+)
     */
    protected function assertPostConditions(): void
    {
        echo "assertPostConditions: 验证后置条件" . PHP_EOL;
    }

    public function testOne(): void
    {
        $this->assertTrue(true);
    }

    public function testTwo(): void
    {
        $this->assertSame(42, 42);
    }
}

组织测试

测试目录结构

tests/
├── Unit/                    # 单元测试
│   ├── Services/
│   │   ├── UserServiceTest.php
│   │   └── PaymentServiceTest.php
│   ├── Models/
│   │   └── UserTest.php
│   └── ValueObjects/
│       └── EmailTest.php
├── Integration/             # 集成测试
│   ├── Repository/
│   │   └── UserRepositoryTest.php
│   └── Http/
│       └── ApiEndpointTest.php
├── Feature/                 # 功能测试
│   └── UserRegistrationTest.php
├── Fixtures/                # 测试夹具
│   ├── data/
│   │   └── users.csv
│   └── expected/
│       └── output.json
├── bootstrap.php            # 测试引导文件
└── TestCase.php             # 自定义基类

自定义基类 TestCase

php
<?php
declare(strict_types=1);

namespace Tests;

use PHPUnit\Framework\TestCase;

abstract class TestCase extends TestCase
{
    /**
     * 创建一个模拟的 PDO 连接
     */
    protected function createMockPdo(): \PDO
    {
        return $this->createMock(\PDO::class);
    }

    /**
     * 设置私有属性
     */
    protected function setPrivateProperty(
        object $object,
        string $property,
        mixed $value
    ): void {
        $reflection = new \ReflectionProperty($object, $property);
        $reflection->setAccessible(true);
        $reflection->setValue($object, $value);
    }

    /**
     * 获取私有属性
     */
    protected function getPrivateProperty(
        object $object,
        string $property
    ): mixed {
        $reflection = new \ReflectionProperty($object, $property);
        $reflection->setAccessible(true);
        return $reflection->getValue($object);
    }

    /**
     * 调用私有方法
     */
    protected function invokePrivateMethod(
        object $object,
        string $method,
        array $args = []
    ): mixed {
        $reflection = new \ReflectionMethod($object, $method);
        $reflection->setAccessible(true);
        return $reflection->invokeArgs($object, $args);
    }
}

实战示例

被测类:UserService

php
<?php
declare(strict_types=1);

namespace App\Services;

class UserService
{
    public function __construct(
        private UserRepositoryInterface $repository,
        private EmailValidatorInterface $emailValidator,
    ) {
    }

    public function createUser(string $name, string $email): User
    {
        if (empty($name) || strlen($name) < 2) {
            throw new \InvalidArgumentException('用户名至少2个字符');
        }

        if (!$this->emailValidator->isValid($email)) {
            throw new \InvalidArgumentException('无效的邮箱地址');
        }

        $existing = $this->repository->findByEmail($email);
        if ($existing !== null) {
            throw new \RuntimeException('邮箱已被注册');
        }

        $user = new User($name, $email);
        $this->repository->save($user);

        return $user;
    }

    public function getUser(int $id): ?User
    {
        return $this->repository->find($id);
    }

    public function deactivateUser(int $id): void
    {
        $user = $this->repository->find($id);
        if ($user === null) {
            throw new \RuntimeException('用户不存在');
        }
        $user->deactivate();
        $this->repository->save($user);
    }
}

测试类

php
<?php
declare(strict_types=1);

namespace Tests\Unit\Services;

use App\Services\UserService;
use App\Models\User;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

class UserServiceTest extends TestCase
{
    private UserService $service;
    private MockObject $repository;
    private MockObject $emailValidator;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(
            \App\Repositories\UserRepositoryInterface::class
        );
        $this->emailValidator = $this->createMock(
            \App\Validators\EmailValidatorInterface::class
        );

        $this->service = new UserService(
            $this->repository,
            $this->emailValidator
        );
    }

    public function testCreateUserSuccess(): void
    {
        // Arrange
        $this->emailValidator->method('isValid')
            ->with('alice@example.com')
            ->willReturn(true);

        $this->repository->method('findByEmail')
            ->with('alice@example.com')
            ->willReturn(null);

        $this->repository->expects($this->once())
            ->method('save')
            ->with($this->isInstanceOf(User::class));

        // Act
        $user = $this->service->createUser('Alice', 'alice@example.com');

        // Assert
        $this->assertInstanceOf(User::class, $user);
        $this->assertSame('Alice', $user->getName());
        $this->assertSame('alice@example.com', $user->getEmail());
    }

    public function testCreateUserWithInvalidEmail(): void
    {
        $this->emailValidator->method('isValid')
            ->with('invalid-email')
            ->willReturn(false);

        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('无效的邮箱地址');

        $this->service->createUser('Alice', 'invalid-email');
    }

    public function testCreateUserWithDuplicateEmail(): void
    {
        $this->emailValidator->method('isValid')
            ->willReturn(true);

        $existingUser = new User('Bob', 'bob@example.com');
        $this->repository->method('findByEmail')
            ->willReturn($existingUser);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('邮箱已被注册');

        $this->service->createUser('Alice', 'bob@example.com');
    }

    public function testGetUserNotFound(): void
    {
        $this->repository->method('find')
            ->with(999)
            ->willReturn(null);

        $result = $this->service->getUser(999);
        $this->assertNull($result);
    }
}

命令行用法

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

# 运行指定目录
./vendor/bin/phpunit tests/Unit/

# 运行指定文件
./vendor/bin/phpunit tests/Unit/Services/UserServiceTest.php

# 运行指定方法
./vendor/bin/phpunit --filter testCreateUserSuccess

# 使用过滤器(方法名模式)
./vendor/bin/phpunit --filter 'UserService'

# 运行指定测试套件
./vendor/bin/phpunit --testsuite unit

# 输出到文件
./vendor/bin/phpunit --log-junit test-results.xml

# 递归测试
./vendor/bin/phpunit --recursive tests/

# 停在第一个失败
./vendor/bin/phpunit --stop-on-failure

# 详细输出
./vendor/bin/phpunit --verbose

# 显示未覆盖的文件
./vendor/bin/phpunit --show-uncovered

注意事项

测试命名规范

方法名格式: test + 功能描述
例如:
- testCreateUserSuccess
- testCreateUserWithInvalidEmail
- testDivideByZeroThrowsException
- testArraySortPreservesKeys

PHP 8.0+ 可使用 attributes:
#[Test]
public function createUserSuccess(): void { ... }

测试隔离

php
<?php
declare(strict_types=1);

// 错误:测试之间有依赖
class BadTest extends TestCase
{
    private static int $counter = 0;

    public function testIncrement(): void
    {
        self::$counter++;
        $this->assertSame(1, self::$counter); // 依赖执行顺序
    }
}

// 正确:每个测试独立
class GoodTest extends TestCase
{
    public function testIncrement(): void
    {
        $counter = 0;
        $counter++;
        $this->assertSame(1, $counter);
    }
}

最佳实践

1. FIRST 原则

  • Fast(快速):测试应该在毫秒级完成
  • Independent(独立):测试之间不应相互依赖
  • Repeatable(可重复):每次运行结果应一致
  • Self-validating(自验证):测试结果应自动判断(通过/失败)
  • Timely(及时):测试应在代码变更后及时编写/更新

2. 测试覆盖率目标

  • 关键业务逻辑:80-100%
  • 工具类/值对象:100%
  • 控制器/中间件:可以较低
  • 覆盖率不是唯一目标,测试质量更重要

下一节

继续学习:Mock 对象

参考链接