PHPUnit

技术栈
工具链
phptestingunit-testingtddxunitcode-coverage

概览

PHPUnit\n\nPHPUnit 是 PHP 生态的事实标准测试框架,由 Sebastian Bergmann 创建。它实现了 xUnit 架构,支持单元测试、集成测试、Mock 对象和代码覆盖率分析。\n\n### 核心特性\n\n- xUnit 架构:经典的 TestCase / TestSuite 模式\n- 断言库:100+ 内置断言方法\n- Mock 对象:强大的测试替身生成\n- 测试组织:@group、@depends、@dataProvider\n- 代码覆盖率:行/分支/路径覆盖率(需 Xdebug/PCOV)\n- CI 集成:JUnit XML 输出,主流 CI/CD 兼容\n- PHPUnit 10/11:全新 Event 系统,更清晰的架构\n- Prophecy 集成:现代化的 Mock 框架支持

安装

1. 环境准备

  • OS:Linux / macOS / Windows
  • PHP:>= 8.1(PHPUnit 10),>= 8.2(PHPUnit 11)
  • PHP 扩展:json, dom, tokenizer, xml, xmlwriter
  • 代码覆盖率:Xdebug 3.x 或 PCOV
  • Composer:最新稳定版

2. 安装命令

项目级安装(推荐)

cd my-project
composer require --dev phpunit/phpunit

全局安装

composer global require phpunit/phpunit

验证安装

./vendor/bin/phpunit --version
# PHPUnit 11.0.x by Sebastian Bergmann and contributors.

生成配置文件

./vendor/bin/phpunit --generate-configuration
# 回答交互式问题生成 phpunit.xml

最小 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">
    <testsuites>
        <testsuite name="unit">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

3. 常见安装问题

版本冲突

# PHPUnit 10 需要 PHP >= 8.1
composer require --dev phpunit/phpunit:^10.0

# PHPUnit 9 兼容 PHP 7.3+
composer require --dev phpunit/phpunit:^9.0

Xdebug 冲突

# 运行测试时禁用 Xdebug 提升性能
php -d xdebug.mode=off ./vendor/bin/phpunit

PCOV 配置(推荐替代 Xdebug)

pecl install pcov
# php.ini: extension=pcov.so

国内镜像

composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/

示例

Hello World:PHPUnit 第一个测试

目标

学习 PHPUnit 基础:创建测试类、编写断言、运行测试、理解测试结果。

完整代码

1. 项目结构

math-app/
├── src/
│   └── Calculator.php
├── tests/
│   └── CalculatorTest.php
├── phpunit.xml
└── composer.json

2. src/Calculator.php

<?php

namespace App;

class Calculator
{
    public function add(float $a, float $b): float
    {
        return $a + $b;
    }

    public function subtract(float $a, float $b): float
    {
        return $a - $b;
    }

    public function multiply(float $a, float $b): float
    {
        return $a * $b;
    }

    public function divide(float $a, float $b): float
    {
        if ($b === 0.0) {
            throw new \InvalidArgumentException('Division by zero');
        }
        return $a / $b;
    }

    public function fibonacci(int $n): array
    {
        if ($n < 0) {
            throw new \InvalidArgumentException('Negative input');
        }

        $sequence = [0, 1];
        for ($i = 2; $i <= $n; $i++) {
            $sequence[] = $sequence[$i - 1] + $sequence[$i - 2];
        }

        return array_slice($sequence, 0, $n + 1);
    }
}

3. tests/CalculatorTest.php

<?php

namespace App\Tests;

use App\Calculator;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    private Calculator $calculator;

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

    public function testAdd(): void
    {
        $this->assertSame(4.0, $this->calculator->add(2.0, 2.0));
        $this->assertSame(0.0, $this->calculator->add(-1.0, 1.0));
    }

    public function testSubtract(): void
    {
        $this->assertSame(3.0, $this->calculator->subtract(5.0, 2.0));
    }

    public function testMultiply(): void
    {
        $this->assertSame(6.0, $this->calculator->multiply(2.0, 3.0));
    }

    public function testDivide(): void
    {
        $this->assertSame(2.5, $this->calculator->divide(5.0, 2.0));
    }

    public function testDivideByZeroThrowsException(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Division by zero');

        $this->calculator->divide(5.0, 0.0);
    }

    #[DataProvider('fibonacciProvider')]
    public function testFibonacci(int $input, array $expected): void
    {
        $this->assertSame($expected, $this->calculator->fibonacci($input));
    }

    public static function fibonacciProvider(): array
    {
        return [
            'zero'  => [0, [0]],
            'one'   => [1, [0, 1]],
            'five'  => [5, [0, 1, 1, 2, 3, 5]],
            'ten'   => [10, [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]],
        ];
    }

    public function testFibonacciNegativeThrowsException(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->calculator->fibonacci(-1);
    }
}

运行步骤

./vendor/bin/phpunit
# 或带详细输出
./vendor/bin/phpunit --testdox

预期输出

PHPUnit 11.0.x by Sebastian Bergmann and contributors.

.......                                                         7 / 7 (100%)

Time: 00:00.012, Memory: 6.00 MB

OK (7 tests, 15 assertions)

TestDox 格式:

Calculator
 ✔ Add
 ✔ Subtract
 ✔ Multiply
 ✔ Divide
 ✔ Divide by zero throws exception
 ✔ Fibonacci with data set "zero"
 ✔ Fibonacci with data set "one"
 ✔ Fibonacci with data set "five"
 ✔ Fibonacci with data set "ten"
 ✔ Fibonacci negative throws exception

常用断言速查

断言 用途
assertSame($a, $b) 严格等于(类型+值)
assertEquals($a, $b) 宽松等于
assertTrue($x) 为 true
assertFalse($x) 为 false
assertNull($x) 为 null
assertCount($n, $arr) 数组元素数
assertContains($v, $arr) 包含某值
expectException(Class::class) 预期异常

教程

PHPUnit 入门教程:实战 TDD 开发

1. 背景

测试驱动开发(TDD)是编写可靠 PHP 应用的核心实践。本教程通过构建一个 User 验证器,展示完整的 Red-Green-Refactor 循环。

2. 前置概念

概念 说明
Red 先写失败的测试
Green 用最少代码让测试通过
Refactor 重构代码,保持测试通过
Mock 模拟依赖对象
@dataProvider 数据驱动测试

3. 分步操作

步骤一:定义需求

我们要构建 UserValidator,验证用户输入:

  • Email 格式必须正确
  • 密码至少 8 个字符,包含大写字母和数字
  • 用户名 3-20 字符,仅字母数字下划线

步骤二:编写测试 tests/UserValidatorTest.php

<?php

namespace App\Tests;

use App\UserValidator;
use PHPUnit\Framework\TestCase;

class UserValidatorTest extends TestCase
{
    private UserValidator $validator;

    protected function setUp(): void
    {
        $this->validator = new UserValidator();
    }

    // ========== Email 验证 ==========

    #[DataProvider('validEmailsProvider')]
    public function testValidEmails(string $email): void
    {
        $this->assertEmpty($this->validator->validateEmail($email));
    }

    #[DataProvider('invalidEmailsProvider')]
    public function testInvalidEmails(string $email, string $expectedError): void
    {
        $errors = $this->validator->validateEmail($email);
        $this->assertContains($expectedError, $errors);
    }

    public static function validEmailsProvider(): array
    {
        return [
            ['user@example.com'],
            ['user.name@domain.co.uk'],
            ['user+tag@domain.org'],
        ];
    }

    public static function invalidEmailsProvider(): array
    {
        return [
            'empty'    => ['', 'Email is required'],
            'no_at'    => ['userexample.com', 'Email is invalid'],
            'no_domain'=> ['user@', 'Email is invalid'],
            'spaces'   => ['user @example.com', 'Email is invalid'],
        ];
    }

    // ========== 密码验证 ==========

    #[DataProvider('validPasswordsProvider')]
    public function testValidPasswords(string $password): void
    {
        $this->assertEmpty($this->validator->validatePassword($password));
    }

    #[DataProvider('invalidPasswordsProvider')]
    public function testInvalidPasswords(string $password, string $expectedError): void
    {
        $errors = $this->validator->validatePassword($password);
        $this->assertContains($expectedError, $errors);
    }

    public static function validPasswordsProvider(): array
    {
        return [
            ['Password1'],
            ['Str0ngP@ss'],
            ['Abcd1234'],
        ];
    }

    public static function invalidPasswordsProvider(): array
    {
        return [
            'short'        => ['Ab1', 'Password must be at least 8 characters'],
            'no_uppercase' => ['password1', 'Password must contain an uppercase letter'],
            'no_digit'     => ['Password', 'Password must contain a digit'],
            'too_short'    => ['Pass1', 'Password must be at least 8 characters'],
        ];
    }

    // ========== 用户名验证 ==========

    public function testValidUsername(): void
    {
        $this->assertEmpty($this->validator->validateUsername('john_doe'));
    }

    public function testUsernameTooShort(): void
    {
        $errors = $this->validator->validateUsername('ab');
        $this->assertContains('Username must be between 3 and 20 characters', $errors);
    }

    public function testUsernameInvalidChars(): void
    {
        $errors = $this->validator->validateUsername('john doe!');
        $this->assertContains('Username must only contain letters, numbers, and underscores', $errors);
    }

    // ========== 集成测试 ==========

    #[DataProvider('validUsersProvider')]
    public function testValidateAllFieldsValid(array $user): void
    {
        $errors = $this->validator->validate($user);
        $this->assertEmpty($errors, 'All fields valid: ' . json_encode($errors));
    }

    public static function validUsersProvider(): array
    {
        return [
            'normal' => [[
                'username' => 'john_doe',
                'email' => 'john@example.com',
                'password' => 'Password1',
            ]],
            'minimal' => [[
                'username' => 'abc',
                'email' => 'a@b.co',
                'password' => 'Abcdefg1',
            ]],
        ];
    }
}

步骤三:实现代码 src/UserValidator.php

<?php

namespace App;

class UserValidator
{
    public function validate(array $data): array
    {
        $errors = [];

        if (isset($data['email'])) {
            $errors = array_merge($errors, $this->validateEmail($data['email']));
        }
        if (isset($data['password'])) {
            $errors = array_merge($errors, $this->validatePassword($data['password']));
        }
        if (isset($data['username'])) {
            $errors = array_merge($errors, $this->validateUsername($data['username']));
        }

        return $errors;
    }

    public function validateEmail(string $email): array
    {
        $errors = [];

        if (empty($email)) {
            $errors[] = 'Email is required';
        } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $errors[] = 'Email is invalid';
        }

        return $errors;
    }

    public function validatePassword(string $password): array
    {
        $errors = [];

        if (strlen($password) < 8) {
            $errors[] = 'Password must be at least 8 characters';
        }
        if (!preg_match('/[A-Z]/', $password)) {
            $errors[] = 'Password must contain an uppercase letter';
        }
        if (!preg_match('/[0-9]/', $password)) {
            $errors[] = 'Password must contain a digit';
        }

        return $errors;
    }

    public function validateUsername(string $username): array
    {
        $errors = [];

        if (strlen($username) < 3 || strlen($username) > 20) {
            $errors[] = 'Username must be between 3 and 20 characters';
        }
        if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
            $errors[] = 'Username must only contain letters, numbers, and underscores';
        }

        return $errors;
    }
}

步骤四:运行测试

./vendor/bin/phpunit --testdox --colors=always

4. TDD 循环总结

阶段 动作
Red 先写测试,运行确认失败
Green 写最少代码让测试通过
Refactor 改进代码结构,测试仍绿色

5. 思考题

  1. 如何使用 Mock 测试依赖外部 API 的类?
  2. setUp()setUpBeforeClass() 的区别?
  3. 如何生成 HTML/Clover 覆盖率报告?