单元测试是软件开发中非常重要的一环,它能帮助我们发现代码中的bug,保证代码质量,方便重构,提升开发效率。在PHP生态中,PHPUnit是最流行、最标准的单元测试框架,由Sebastian Bergmann开发和维护,几乎是PHP单元测试的代名词。
2016年,PHPUnit已经发展到5.x版本(最新稳定版是PHPUnit 5.4),功能非常完善,支持各种断言、数据提供器、Mock对象、测试覆盖率、注解等高级特性。Laravel、Symfony等主流PHP框架都内置了PHPUnit测试支持,Composer也可以方便地安装PHPUnit。
但是,很多PHP开发者还没有养成写单元测试的习惯,觉得写测试浪费时间,或者不知道怎么写测试。作为一个追求代码质量的开发者,我强烈建议大家学习和使用单元测试。今天就来系统地讲解PHPUnit单元测试实战,从安装配置到高级用法,帮助你写出高质量、可维护的PHP代码。
一、单元测试简介
1. 什么是单元测试
单元测试(Unit Testing)是指对软件中的最小可测试单元进行检查和验证。在面向对象编程中,最小单元通常是方法(函数)。单元测试的目的是验证每个方法是否按照预期工作,输入特定的值,是否返回预期的结果。
单元测试的特点:
- 自动化:可以自动运行,不需要人工干预
- 快速:运行速度快,几秒钟就能跑完所有测试
- 独立:每个测试用例独立运行,不依赖其他测试
- 可重复:多次运行结果一致
- 覆盖全面:可以覆盖各种正常和异常情况
2. 为什么要写单元测试
- 保证代码质量:单元测试能发现代码中的bug,确保代码按照预期工作
- 方便重构:有了单元测试,重构代码时可以放心修改,运行测试就能知道有没有破坏功能
- 提升开发效率:虽然写测试需要时间,但是长期来看,测试能减少调试和修复bug的时间,提升整体开发效率
- 文档作用:单元测试是代码的活文档,通过测试用例可以了解代码的用法和预期行为
- 减少回归bug:修改代码后运行测试,可以及时发现是否引入了新的bug
- 提升设计:为了让代码可测试,需要写出低耦合、高内聚的代码,这会倒逼你提升代码设计
3. 单元测试的原则
- FIRST原则:
- Fast(快速):测试运行要快,几秒钟内完成 - Independent(独立):每个测试独立运行,不依赖其他测试的结果 - Repeatable(可重复):多次运行结果一致,不依赖环境 - Self-validating(自我验证):测试自动判断通过或失败,不需要人工检查 - Timely(及时):及时编写测试,最好在写代码之前或同时写测试
- 测试隔离:每个测试只测试一个功能点,不测试多个功能的组合
- 只测试公共接口:测试类的公共方法,不测试私有方法(私有方法通过公共方法间接测试)
- 不要测试实现细节:测试代码的行为,而不是实现方式,这样重构时不需要修改测试
二、PHPUnit安装与配置
1. 安装PHPUnit
推荐使用Composer安装PHPUnit:
# 安装PHPUnit到项目(开发依赖)
composer require --dev phpunit/phpunit ^5.4
# 全局安装
composer global require phpunit/phpunit ^5.4也可以下载PHPUnit的PHAR包:
# 下载PHPUnit PHAR
wget https://phar.phpunit.de/phpunit-5.4.phar
chmod +x phpunit-5.4.phar
mv phpunit-5.4.phar /usr/local/bin/phpunit验证安装:
vendor/bin/phpunit --version
# PHPUnit 5.4.6 by Sebastian Bergmann and contributors.2. 目录结构
推荐的项目目录结构:
project/
├── src/ # 源代码
│ ├── Calculator.php
│ └── ...
├── tests/ # 测试代码
│ ├── CalculatorTest.php
│ └── ...
├── vendor/ # Composer依赖
├── composer.json
├── phpunit.xml # PHPUnit配置文件
└── ...测试文件命名:{ClassName}Test.php,测试类命名:{ClassName}Test,测试方法命名:test{MethodName}或使用@test注解。
3. phpunit.xml配置
PHPUnit的配置文件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 processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">src/</directory>
</whitelist>
</filter>
<logging>
<log type="coverage-html" target="coverage/" charset="UTF-8" yui="true" highlight="true"/>
<log type="coverage-clover" target="coverage.xml"/>
</logging>
</phpunit>配置说明:
bootstrap:测试启动时加载的文件,通常是Composer的autoload.phpcolors:是否使用彩色输出convertErrorsToExceptions等:是否将PHP错误转换为异常testsuites:测试套件,指定测试文件目录filter/whitelist:代码覆盖率白名单,指定需要统计覆盖率的源代码目录logging:日志输出,包括覆盖率报告
三、编写第一个测试
1. 被测代码
先写一个简单的计算器类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;
}
}2. 测试代码
编写测试类tests/CalculatorTest.php:
<?php
use PHPUnit\Framework\TestCase;
use App\Calculator;
class CalculatorTest extends TestCase
{
public function testAdd()
{
$calculator = new Calculator();
$result = $calculator->add(2, 3);
$this->assertEquals(5, $result);
}
public function testSubtract()
{
$calculator = new Calculator();
$result = $calculator->subtract(5, 3);
$this->assertEquals(2, $result);
}
public function testMultiply()
{
$calculator = new Calculator();
$result = $calculator->multiply(2, 3);
$this->assertEquals(6, $result);
}
public function testDivide()
{
$calculator = new Calculator();
$result = $calculator->divide(6, 3);
$this->assertEquals(2, $result);
}
public function testDivideByZero()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Division by zero');
$calculator = new Calculator();
$calculator->divide(6, 0);
}
}3. 运行测试
# 运行所有测试
vendor/bin/phpunit
# 运行指定测试文件
vendor/bin/phpunit tests/CalculatorTest.php
# 运行指定测试方法
vendor/bin/phpunit --filter testAdd
# 显示详细信息
vendor/bin/phpunit --testdox
# 生成代码覆盖率报告(需要Xdebug扩展)
vendor/bin/phpunit --coverage-html coverage/运行结果:
PHPUnit 5.4.6 by Sebastian Bergmann and contributors.
..... 5 / 5 (100%)
Time: 100 ms, Memory: 4.00MB
OK (5 tests, 5 assertions)四、常用断言方法
断言(Assertion)是单元测试的核心,用于验证实际结果是否符合预期。PHPUnit提供了丰富的断言方法。
1. 相等性断言
// 相等(==比较)
$this->assertEquals($expected, $actual);
// 不相等
$this->assertNotEquals($expected, $actual);
// 严格相等(===比较,类型和值都相等)
$this->assertSame($expected, $actual);
// 严格不相等
$this->assertNotSame($expected, $actual);2. 布尔断言
// 为true
$this->assertTrue($condition);
// 为false
$this->assertFalse($condition);3. 空值断言
// 为null
$this->assertNull($value);
// 不为null
$this->assertNotNull($value);
// 为空(empty(),包括0、''、null、false、空数组)
$this->assertEmpty($value);
// 不为空
$this->assertNotEmpty($value);4. 类型断言
// 是指定类型(如'integer'、'string'、'array'、'bool')
$this->assertInternalType('integer', $value);
// 是指定类的实例
$this->assertInstanceOf(Calculator::class, $object);
// 不是指定类的实例
$this->assertNotInstanceOf(Calculator::class, $object);5. 数组断言
// 数组包含指定键
$this->assertArrayHasKey('key', $array);
// 数组不包含指定键
$this->assertArrayNotHasKey('key', $array);
// 数组包含指定值
$this->assertContains('value', $array);
// 数组不包含指定值
$this->assertNotContains('value', $array);
// 数组元素个数
$this->assertCount(3, $array);
// 数组等于
$this->assertEquals([1, 2, 3], $array);6. 字符串断言
// 字符串包含子串
$this->assertContains('hello', $string);
// 字符串以指定内容开头
$this->assertStringStartsWith('hello', $string);
// 字符串以指定内容结尾
$this->assertStringEndsWith('world', $string);
// 字符串匹配正则表达式
$this->assertRegExp('/^hello/', $string);
// 字符串长度
$this->assertSame(5, strlen($string));7. 数值断言
// 大于
$this->assertGreaterThan(5, $value);
// 大于等于
$this->assertGreaterThanOrEqual(5, $value);
// 小于
$this->assertLessThan(10, $value);
// 小于等于
$this->assertLessThanOrEqual(10, $value);
// 浮点数相等(指定精度)
$this->assertEquals(1.0, $value, '', 0.0001);8. 文件断言
// 文件存在
$this->assertFileExists($filepath);
// 文件不存在
$this->assertFileNotExists($filepath);
// 文件等于
$this->assertFileEquals($expected, $actual);9. 异常断言
// 期望抛出指定异常
$this->expectException(\InvalidArgumentException::class);
// 期望异常消息
$this->expectExceptionMessage('error message');
// 期望异常消息匹配正则
$this->expectExceptionMessageRegExp('/error/');
// 期望异常代码
$this->expectExceptionCode(404);五、测试固件(setUp/tearDown)
在测试中,经常需要在每个测试方法运行前初始化一些对象,运行后清理资源。PHPUnit提供了setUp()和tearDown()方法。
1. setUp和tearDown
<?php
use PHPUnit\Framework\TestCase;
use App\Calculator;
class CalculatorTest extends TestCase
{
private $calculator;
// 每个测试方法运行前调用
protected function setUp()
{
$this->calculator = new Calculator();
}
// 每个测试方法运行后调用
protected function tearDown()
{
$this->calculator = null;
}
public function testAdd()
{
$result = $this->calculator->add(2, 3);
$this->assertEquals(5, $result);
}
public function testSubtract()
{
$result = $this->calculator->subtract(5, 3);
$this->assertEquals(2, $result);
}
}2. setUpBeforeClass和tearDownAfterClass
如果需要在整个测试类运行前/后执行一次(而不是每个测试方法),使用setUpBeforeClass()和tearDownAfterClass():
class DatabaseTest extends TestCase
{
private static $db;
// 整个测试类运行前调用一次
public static function setUpBeforeClass()
{
self::$db = new PDO('mysql:host=localhost;dbname=test', 'root', '');
}
// 整个测试类运行后调用一次
public static function tearDownAfterClass()
{
self::$db = null;
}
}3. @before和@after注解
也可以使用@before和@after注解标记自定义方法:
class MyTest extends TestCase
{
private $calculator;
/**
* @before
*/
public function initCalculator()
{
$this->calculator = new Calculator();
}
/**
* @after
*/
public function cleanup()
{
$this->calculator = null;
}
}六、数据提供器(Data Provider)
数据提供器可以让一个测试方法使用多组数据运行,避免重复代码。
1. 基本用法
class CalculatorTest extends TestCase
{
/**
* @dataProvider additionProvider
*/
public function testAdd($a, $b, $expected)
{
$calculator = new Calculator();
$this->assertEquals($expected, $calculator->add($a, $b));
}
public function additionProvider()
{
return [
[0, 0, 0],
[0, 1, 1],
[1, 0, 1],
[1, 1, 2],
[2, 3, 5],
[-1, 1, 0],
[-1, -1, -2],
];
}
}运行结果:
PHPUnit 5.4.6 by Sebastian Bergmann and contributors.
....... 7 / 7 (100%)
Time: 100 ms, Memory: 4.00MB
OK (7 tests, 7 assertions)一个测试方法变成了7个测试,每组数据运行一次。
2. 命名数据集
可以给每组数据命名,方便识别:
public function additionProvider()
{
return [
'zero plus zero' => [0, 0, 0],
'zero plus one' => [0, 1, 1],
'one plus zero' => [1, 0, 1],
'one plus one' => [1, 1, 2],
'positive numbers' => [2, 3, 5],
'negative and positive' => [-1, 1, 0],
'negative numbers' => [-1, -1, -2],
];
}3. 多个数据提供器
一个测试类可以有多个数据提供器,每个测试方法使用不同的数据提供器。
七、Mock对象
在测试中,经常需要模拟(Mock)一些依赖对象,比如数据库连接、外部API、邮件服务等。PHPUnit提供了强大的Mock功能。
1. 创建Mock对象
<?php
use PHPUnit\Framework\TestCase;
class UserServiceTest extends TestCase
{
public function testGetUser()
{
// 创建UserRepository的Mock对象
$userRepository = $this->createMock(UserRepository::class);
// 设置Mock方法的预期行为
$userRepository->expects($this->once())
->method('findById')
->with(1)
->willReturn(['id' => 1, 'name' => '张三']);
// 创建UserService,注入Mock的UserRepository
$userService = new UserService($userRepository);
// 调用方法
$user = $userService->getUser(1);
// 断言
$this->assertEquals('张三', $user['name']);
}
}2. Mock方法的常用设置
$mock = $this->createMock(SomeClass::class);
// 设置方法被调用的次数
$mock->expects($this->once()) // 调用一次
->method('someMethod');
$mock->expects($this->exactly(3)) // 调用3次
->method('someMethod');
$mock->expects($this->atLeastOnce()) // 至少调用一次
->method('someMethod');
$mock->expects($this->never()) // 不调用
->method('someMethod');
$mock->expects($this->any()) // 任意次数
->method('someMethod');
// 设置参数匹配
$mock->expects($this->once())
->method('someMethod')
->with($this->equalTo(1), $this->stringContains('hello'));
// 设置返回值
$mock->method('someMethod')->willReturn('value');
$mock->method('someMethod')->willReturnArgument(0); // 返回第一个参数
$mock->method('someMethod')->willReturnSelf(); // 返回自身
$mock->method('someMethod')->willThrowException(new \Exception('error')); // 抛出异常
// 根据参数返回不同值
$mock->method('someMethod')
->willReturnMap([
[1, 'value1'],
[2, 'value2'],
]);3. Mock抽象类和接口
// Mock接口
$mock = $this->createMock(SomeInterface::class);
// Mock抽象类
$mock = $this->getMockForAbstractClass(SomeAbstractClass::class);4. Mock的注意事项
- Mock只模拟外部依赖,不要Mock被测类本身
- Mock的方法要设置预期行为,否则默认返回null
- 不要过度使用Mock,简单的依赖可以直接使用真实对象
- Mock应该测试交互(方法是否被调用、参数是否正确),而不是实现细节
八、测试覆盖率
测试覆盖率(Code Coverage)衡量测试代码覆盖了多少源代码。需要安装Xdebug扩展。
1. 生成覆盖率报告
# 生成HTML覆盖率报告
vendor/bin/phpunit --coverage-html coverage/
# 生成Clover XML报告(CI工具使用)
vendor/bin/phpunit --coverage-clover coverage.xml
# 生成文本报告
vendor/bin/phpunit --coverage-text2. 覆盖率指标
- 行覆盖率(Line Coverage):有多少行代码被执行过
- 函数/方法覆盖率(Function/Method Coverage):有多少函数/方法被调用过
- 类覆盖率(Class Coverage):有多少类被实例化过
- 分支覆盖率(Branch Coverage):有多少if/else分支被覆盖
- 路径覆盖率(Path Coverage):有多少代码路径被覆盖
3. 覆盖率不是越高越好
- 100%覆盖率不代表没有bug,只能说明所有代码都被执行过
- 追求100%覆盖率可能会浪费时间在不重要的代码上
- 重点测试核心业务逻辑和复杂的代码
- 覆盖率是参考指标,不是目标
九、测试组织与最佳实践
1. 测试命名规范
- 测试类:
{被测类名}Test,如CalculatorTest - 测试方法:
test{方法名}{场景}{预期结果},如testAddpositiveNumbersreturnsSum - 或者使用
@test注解,方法名可以更自然:itshouldaddtwonumbers
2. 测试结构(AAA模式)
每个测试方法遵循AAA(Arrange-Act-Assert)模式:
- Arrange(准备):初始化测试数据和对象
- Act(执行):调用被测方法
- Assert(断言):验证结果
public function testAdd()
{
// Arrange
$calculator = new Calculator();
// Act
$result = $calculator->add(2, 3);
// Assert
$this->assertEquals(5, $result);
}3. 一个测试只测试一个点
每个测试方法只测试一个功能点,不要在一个测试中测试多个功能。这样测试失败时能快速定位问题。
4. 测试要独立
每个测试独立运行,不依赖其他测试的结果,不依赖执行顺序。使用setUp初始化数据,tearDown清理资源。
5. 测试要快速
单元测试应该快速运行,几秒钟内完成。避免在单元测试中访问数据库、网络、文件系统(这些属于集成测试)。使用Mock模拟外部依赖。
6. 测试要可读
测试代码也是代码,要写得清晰、易读。使用描述性的方法名,合理的注释,清晰的结构。
7. 先写测试(TDD)
测试驱动开发(Test-Driven Development):先写测试,再写代码,最后重构。
- Red(红):写一个失败的测试
- Green(绿):写最少的代码让测试通过
- Refactor(重构):重构代码,保持测试通过
TDD能帮助你写出更简洁、更可测试、更高质量的代码。
8. 持续集成
将测试集成到CI(持续集成)流程中,每次提交代码自动运行测试,及时发现问题。
十、常见问题与排错
1. 测试运行慢
- 检查是否在测试中访问了数据库、网络、文件系统
- 使用Mock模拟外部依赖
- 减少不必要的setUp操作
- 使用测试套件分组,只运行需要的测试
2. 测试不稳定(有时通过有时失败)
- 检查测试是否依赖执行顺序
- 检查测试是否依赖外部状态(时间、随机数、数据库)
- 使用setUp/tearDown确保每个测试独立
- 避免使用全局变量和静态变量
3. 无法Mock类
- 确保类不是final的(final类无法Mock)
- 确保方法不是final的(final方法无法Mock)
- 确保方法不是private的(private方法无法Mock)
- 使用
getMockForAbstractClassMock抽象类
4. 覆盖率报告不生成
- 确保安装了Xdebug扩展:
php -m | grep xdebug - 确保phpunit.xml中配置了whitelist
- 确保源代码目录正确
5. 自动加载找不到类
- 确保composer.json中配置了autoload
- 确保运行了
composer dump-autoload - 确保phpunit.xml的bootstrap指向了autoload.php
- 确保命名空间和目录结构正确
总结
PHPUnit是2016年PHP生态中最流行、最标准的单元测试框架,是写出高质量PHP代码的必备工具。
本文从单元测试简介、PHPUnit安装配置、编写第一个测试、常用断言方法、测试固件(setUp/tearDown)、数据提供器、Mock对象、测试覆盖率、测试组织与最佳实践、常见问题排错等方面,系统讲解了PHPUnit单元测试实战。
单元测试的核心要点:
- 理解单元测试的价值:保证质量、方便重构、提升效率、文档作用
- 遵循FIRST原则:快速、独立、可重复、自我验证、及时
- 掌握常用断言:assertEquals、assertTrue、assertNull、assertContains等
- 使用setUp/tearDown初始化和清理
- 使用数据提供器减少重复代码
- 使用Mock模拟外部依赖
- 遵循AAA模式和一个测试一个点
- 追求合理的覆盖率,不是100%
- 尝试TDD,先写测试再写代码
- 将测试集成到CI流程
单元测试是一种投资,短期来看需要花时间写测试,但是长期来看,测试能减少bug、方便重构、提升代码质量和开发效率。作为一个追求卓越的PHP开发者,强烈建议你学习和使用PHPUnit,养成写单元测试的习惯。
希望本文能帮助你入门PHPUnit,写出高质量、可维护的PHP代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录