什么是 Pest
Pest 是构建在 PHPUnit 之上的测试框架,Laravel 11 之后的新项目默认采用。它保留了 PHPUnit 丰富的断言集合,同时用基于闭包的简洁语法来编写测试。
与 PHPUnit 的主要区别:
视角 Pest PHPUnit 测试定义 test() / it() 闭包用方法定义的类 断言 Expectation API(expect()->toBe()) $this->assert*()数据集 dataset() / with()@dataProvider钩子 beforeEach() / afterEach()setUp() / tearDown()嵌套 用 describe() 分组 用类分离
Pest 测试仍然通过 PHPUnit 执行,可以与既有的 PHPUnit 测试共存。用 php artisan test 或 vendor/bin/pest 运行。
describe / it / test 的用法差异
test()
最简单的定义。测试名会作为说明使用。
test ( '用户可以用邮箱登录' , function () {
$user = User :: factory () -> create ();
$response = $this -> post ( '/login' , [
'email' => $user -> email ,
'password' => 'password' ,
]);
$response -> assertRedirect ( '/dashboard' );
});
it()
「it should…」这种英文文体,读起来接近自然语言。
it ( '会将未认证用户重定向到登录页' , function () {
$response = $this -> get ( '/dashboard' );
$response -> assertRedirect ( '/login' );
});
describe()
将相关测试分组。可用于共享 beforeEach() 或在文件内进行逻辑整理。
describe ( '订单管理' , function () {
beforeEach ( function () {
$this -> user = User :: factory () -> create ();
$this -> actingAs ( $this -> user );
});
it ( '可以获取订单列表' , function () {
Order :: factory ( 3 ) -> for ( $this -> user ) -> create ();
$response = $this -> get ( '/orders' );
$response -> assertOk () -> assertJsonCount ( 3 , 'data' );
});
it ( '不能获取其他用户的订单' , function () {
$other = User :: factory () -> create ();
Order :: factory () -> for ( $other ) -> create ();
$response = $this -> get ( '/orders' );
$response -> assertOk () -> assertJsonCount ( 0 , 'data' );
});
});
describe() 可以嵌套,但过深的嵌套会让测试难读,一般建议不超过 2 层。
Expectation API
expect() 是 Pest 特有的断言语法,可以链式叠加条件。
基本断言
expect ( $value ) -> toBe ( 42 ); // 严格相等(===)
expect ( $value ) -> toEqual ([ 'a' => 1 ]); // 宽松相等(==)
expect ( $value ) -> toBeTrue ();
expect ( $value ) -> toBeFalse ();
expect ( $value ) -> toBeNull ();
expect ( $value ) -> not -> toBeNull ();
expect ( $string ) -> toContain ( 'Laravel' );
expect ( $array ) -> toHaveCount ( 3 );
expect ( $array ) -> toHaveKey ( 'email' );
expect ( $array ) -> toContain ( 'admin' );
expect ( $number ) -> toBeGreaterThan ( 0 );
expect ( $number ) -> toBeLessThanOrEqual ( 100 );
针对模型的断言
expect ( $user ) -> toBeInstanceOf ( User :: class );
expect ( $user -> email ) -> toMatchRegex ( '/ ^ . + @. + \. . +$ /' );
// 确认已保存到数据库
expect ( User :: where ( 'email' , '[email protected] ' ) -> exists ()) -> toBeTrue ();
and() 链
expect ( $response -> status ()) -> toBe ( 200 )
-> and ( $response -> json ( 'name' )) -> toBe ( 'Laravel' )
-> and ( $response -> json ( 'version' )) -> toBeGreaterThan ( 12 );
用 each() 校验数组每个元素
$users = User :: factory ( 3 ) -> create ();
expect ( $users ) -> each ( function ( $user ) {
$user -> toBeInstanceOf ( User :: class )
-> email -> not -> toBeNull ();
});
使用数据集进行参数化测试
对同一逻辑使用多个输入进行测试时,使用 dataset() 或行内 with()。
行内数据集
it ( '无效邮箱应产生校验错误' , function ( string $email ) {
$response = $this -> post ( '/register' , [ 'email' => $email ]);
$response -> assertInvalid ( 'email' );
}) -> with ([
'纯文本' => [ 'not-an-email' ],
'缺少域名' => [ 'user@' ],
'空字符串' => [ '' ],
]);
命名数据集
在 tests/Datasets 目录中定义数据集。
// tests/Datasets/InvalidEmails.php
dataset ( 'invalid_emails' , [
'plain' => [ 'not-an-email' ],
'no-domain' => [ 'user@' ],
'empty' => [ '' ],
'spaces' => [ 'user @example.com' ],
]);
it ( '无效邮箱应产生校验错误' , function ( string $email ) {
$response = $this -> post ( '/register' , [ 'email' => $email ]);
$response -> assertInvalid ( 'email' );
}) -> with ( 'invalid_emails' );
使用闭包的动态数据集
it ( '不同套餐拥有不同的限制' , function ( string $plan , int $limit ) {
$user = User :: factory () -> create ([ 'plan' => $plan ]);
expect ( $user -> requestLimit ()) -> toBe ( $limit );
}) -> with ([
[ 'free' , 100 ],
[ 'pro' , 1000 ],
[ 'enterprise' , 10000 ],
]);
使用 Mockery 编写单元测试
服务的 Mock
use App\Services\ PaymentGateway ;
use Mockery\ MockInterface ;
test ( '调用了支付服务' , function () {
$mock = $this -> mock ( PaymentGateway :: class , function ( MockInterface $mock ) {
$mock -> expects ( 'charge' )
-> with ( 1000 , 'cny' )
-> andReturn ([ 'status' => 'succeeded' ]);
});
$result = app ( PaymentGateway :: class ) -> charge ( 1000 , 'cny' );
expect ( $result [ 'status' ]) -> toBe ( 'succeeded' );
});
Spy
Spy 与 Mock 不同,它在调用真实实现的同时记录调用。
use App\Services\ NotificationService ;
test ( '确认通知服务被调用' , function () {
$spy = $this -> spy ( NotificationService :: class );
$this -> post ( '/orders' , [ 'amount' => 1000 ]);
$spy -> shouldHaveReceived ( 'send' ) -> once () -> with ( 'order.created' );
});
部分 Mock
只对部分方法进行 Mock,其余方法使用真实实现。
use App\Services\ ReportService ;
use Mockery\ MockInterface ;
test ( '跳过报表写文件' , function () {
$mock = $this -> partialMock ( ReportService :: class , function ( MockInterface $mock ) {
$mock -> expects ( 'writeFile' ) -> andReturnNull ();
});
$result = $mock -> generate ();
expect ( $result ) -> not -> toBeNull ();
});
通过 HTTP Fake 测试外部 API 调用
使用 Http::fake() 可以不发起真实 HTTP 请求而对响应打桩。
基础 Fake
use Illuminate\Support\Facades\ Http ;
test ( '从外部 API 获取用户信息' , function () {
Http :: fake ([
'https://api.example.com/users/*' => Http :: response ([
'id' => 1 ,
'name' => 'Laravel User' ,
], 200 ),
]);
$result = app ( UserApiClient :: class ) -> find ( 1 );
expect ( $result [ 'name' ]) -> toBe ( 'Laravel User' );
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://api.example.com/users/1' );
});
错误响应的测试
test ( 'API 返回错误时抛出异常' , function () {
Http :: fake ([
'api.example.com/*' => Http :: response ([], 503 ),
]);
expect ( fn () => app ( UserApiClient :: class ) -> find ( 1 ))
-> toThrow ( \App\Exceptions\ ApiUnavailableException :: class );
});
模拟网络故障
use Illuminate\Http\Client\ ConnectionException ;
test ( '处理连接错误' , function () {
Http :: fake ( fn () => throw new ConnectionException ( 'Connection refused' ));
$result = app ( UserApiClient :: class ) -> findWithFallback ( 1 );
expect ( $result ) -> toBeNull ();
});
事件、邮件、通知的 Fake
Event::fake()
use Illuminate\Support\Facades\ Event ;
use App\Events\ OrderCreated ;
test ( '创建订单时会派发事件' , function () {
Event :: fake ();
$this -> post ( '/orders' , [ 'product_id' => 1 , 'amount' => 1000 ]);
Event :: assertDispatched ( OrderCreated :: class , function ( $event ) {
return $event -> order -> amount === 1000 ;
});
});
也可以只 Fake 特定事件,其它照常执行。
Event :: fake ([ OrderCreated :: class ]);
Mail::fake()
use Illuminate\Support\Facades\ Mail ;
use App\Mail\ WelcomeEmail ;
test ( '用户注册时发送欢迎邮件' , function () {
Mail :: fake ();
$this -> post ( '/register' , [
'name' => 'Test User' ,
'email' => '[email protected] ' ,
'password' => 'password' ,
'password_confirmation' => 'password' ,
]);
Mail :: assertSent ( WelcomeEmail :: class , function ( $mail ) {
return $mail -> hasTo ( '[email protected] ' );
});
});
Notification::fake()
use Illuminate\Support\Facades\ Notification ;
use App\Notifications\ OrderShipped ;
test ( '发货时发送通知' , function () {
Notification :: fake ();
$user = User :: factory () -> create ();
$order = Order :: factory () -> for ( $user ) -> create ();
$this -> post ( "/orders/{ $order -> id }/ship" );
Notification :: assertSentTo ( $user , OrderShipped :: class , function ( $notification ) use ( $order ) {
return $notification -> order -> id === $order -> id ;
});
});
Artisan 命令的测试
use Illuminate\Support\Facades\ Artisan ;
test ( '清理无效数据的命令能正常工作' , function () {
$expired = Order :: factory () -> create ([ 'expires_at' => now () -> subDay ()]);
$valid = Order :: factory () -> create ([ 'expires_at' => now () -> addDay ()]);
$this -> artisan ( 'orders:cleanup' )
-> assertSuccessful ()
-> expectsOutput ( 'Cleaned up 1 expired order(s).' );
expect ( Order :: find ( $expired -> id )) -> toBeNull ();
expect ( Order :: find ( $valid -> id )) -> not -> toBeNull ();
});
交互式命令的测试
test ( '交互式命令确认后执行处理' , function () {
$this -> artisan ( 'reports:generate' )
-> expectsQuestion ( '要执行吗?' , 'yes' )
-> expectsOutput ( '报表已生成。' )
-> assertExitCode ( 0 );
});
RefreshDatabase 与 LazilyRefreshDatabase
RefreshDatabase
在每个测试后回滚数据库以保持干净状态。测试套件启动时会执行迁移。
// tests/Pest.php
uses ( RefreshDatabase :: class ) -> in ( 'Feature' );
use Illuminate\Foundation\Testing\ RefreshDatabase ;
test ( '可以创建用户' , function () {
$user = User :: factory () -> create ([ 'name' => 'Laravel' ]);
expect ( User :: count ()) -> toBe ( 1 );
expect ( $user -> name ) -> toBe ( 'Laravel' );
});
LazilyRefreshDatabase
RefreshDatabase 会对所有测试都检查迁移,而 LazilyRefreshDatabase 会延迟到实际会修改数据库的测试运行时才执行迁移。当有很多不接触数据库的测试时可加速。
// tests/Pest.php
uses ( LazilyRefreshDatabase :: class ) -> in ( 'Feature' );
多数项目选择 RefreshDatabase 更安全。当测试规模庞大且大量测试不接触数据库时,可以考虑 LazilyRefreshDatabase。
视角 RefreshDatabaseLazilyRefreshDatabase迁移执行时机 测试套件启动时 首次 DB 操作时 不接触 DB 的测试开销 有 无 推荐场景 一般场景 测试数量多且大量不接触 DB 时
代码覆盖率的度量
需要 Xdebug 或 PCOV。
# 在终端展示覆盖率
php artisan test --coverage
# 强制最低覆盖率(低于则 CI 失败)
php artisan test --coverage --min=80
# 输出 HTML 报告
vendor/bin/pest --coverage-html=coverage/
# 查看慢速测试
php artisan test --profile
覆盖率度量会显著增加测试运行时间。建议在 CI 中拆分到专门的 Job,或仅在 Pull Request 时运行。
相关页面
测试入门 了解 Laravel 中测试的基本写法以及 php artisan test 的用法。