怎样在 Laravel 中使用 PHPUnit 和 PEST 进行测试
当谈到编程语言的自动化测试或者单元测试时,通常会有两种态度:
- 有些人从不写自动化测试,觉得这纯粹是浪费时间
- 而那些写测试的,觉得无法想象不写测试如何进行开展工作
本文将尝试说服前一种人,了解其中的好处以及在 Laravel 中使用自动化测试有多容易。
我们先来谈谈“为什么”,之后我会举些基础的例子展示“怎么做”。
为什么你需要自动化测试
自动化测试并不复杂:它仅仅是运行部分代码并报告错误。这是最简单的描述方法。假设你在应用中启用了一些新特性,然后有个私人机器人助理会手动帮你测试这些新功能,同时测试新代码是否破坏了旧功能。
这就是测试主要好处:自动对所有功能重测试。或许看起来像增加了额外的工作量,但是如果你不让“机器人”来做这些事,最终还是要由你自己来手动测试,不是吗?还是说你未经太多测试就启用了新功能,期望用来来报告漏洞?我将这种方式称之为“祈祷式驱动开发”。
伴随着越来越多的新特性发布,自动测试回馈越来越多。
- 特性1:节省了 X 分钟的手动测试时间
- 特性2:为特性 2 的测试以及特性 1 的再测节约了 2X 时间
- 特性3:以此类推,节约了3X 时间…
- …
想必你已经了解了这一概念。想象一下,你的应用经历了一两年,团队引入了新的开发人员,他甚至不清楚“特性1”怎么运作的,怎么去复现测试。因此,将来的你将会感谢自己写了这个自动化测试。
当然,如果你觉得你的项目是短期的,你并不在乎未来如何…我也愿意相信你的善意,那么让我来展示一下开始测试是多么简单。
第一个自动化测试
要在 Laravel 中运行第一个自动化测试,你不需要写任何代码。在 Laravel 默认的安装时,已经预先配置准备好了,包括第一个真实的基础测试用例。
你可以在安装好 Laravel 项目后,立即运行第一个测试:
laravel new project
cd project
php artisan test
控制台中将会显示如下结果:
PASS Tests\Unit\ExampleTest
✓ that true is true
PASS Tests\Feature\ExampleTest
✓ the application returns a successful response
Tests: 2 passed
Time: 0.10s
默认的 Laravel 测试目录下,有两个文件。
tests/Feature/ExampleTest.php:
class ExampleTest extends TestCase
{
public function test_the_application_returns_a_successful_response()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
我们不需要了解任何语法,就能知道这里做了什么:加载主页,然后检查 HTTP 状态码是否等于 ”200 OK“。
同时需要注意的是,方法名 test_the_application_returns_a_successful_response()
在查看测试结果时,是怎么变成可读性文本的。只需将下划线转换成空格即可。
tests/Unit/ExampleTest.php:
class ExampleTest extends TestCase
{
public function test_that_true_is_true()
{
$this->assertTrue(true);
}
}
外观不太重要,检测 that true is true? 之后我们会主要谈谈单元测试。此刻,你需要明白每个测试中一般做了些什么。
在 tests/
文件夹中的每个测试文件都是 PHP 类,继承了 PHPUnit 的 TestCase
类。
在每个类中,你可以创建多个方法,通常每个方法对应于一个测试情景
- 每个测试方法中,通常有三个行为:情景的准备,然后是操作,再然后是检测(断言)结果是否如预期
- 就结构而言,这就是你所有你需要了解。其他则取决于你需要测试的具体是什么。
要生成一个空的测试类,你只需运行这一命令:
php artisan make:test HomepageTest
它会生成文件 tests/Feature/HomepageTest.php
:
class HomepageTest extends TestCase
{
// Replace this method with your own ones
public function test_example()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
如果测试失败呢?
我们先来看看如果测试断言返回的不是预期的结果会是怎样。
修改测试用例如下:
class ExampleTest extends TestCase
{
public function test_the_application_returns_a_successful_response()
{
$response = $this->get('/non-existing-url');
$response->assertStatus(200);
}
}
class ExampleTest extends TestCase
{
public function test_that_true_is_false()
{
$this->assertTrue(false);
}
}
现在我们再次运行 php artisan test
:
FAIL Tests\Unit\ExampleTest
⨯ that true is true
FAIL Tests\Feature\ExampleTest
⨯ the application returns a successful response
---
• Tests\Unit\ExampleTest > that true is true
Failed asserting that false is true.
at tests/Unit/ExampleTest.php:16
12▕ * @return void
13▕ */
14▕ public function test_that_true_is_true()
15▕ {
➜ 16▕ $this->assertTrue(false);
17▕ }
18▕ }
19▕
• Tests\Feature\ExampleTest > the application returns a successful response
Expected response status code [200] but received 404.
Failed asserting that 200 is identical to 404.
at tests/Feature/ExampleTest.php:19
15▕ public function test_the_application_returns_a_successful_response()
16▕ {
17▕ $response = $this->get('/non-existing-url');
18▕
➜ 19▕ $response->assertStatus(200);
20▕ }
21▕ }
22▕
Tests: 2 failed
Time: 0.11s
如上,有两个状态被标成 FAIL,之后随带着解释以及箭头指向了断言失败的具体测试行。这就是测试失败显示的结果。
很方便,是吧?
简单的真实案例:注册表单
接下来,让我们回到真实的情景,做些练习。假设你有一个表单,需要测试几种情况:检测如果填充了无效数据是否返回失败,检测输入正确是否返回成功,等等。
官方的 Laravel Breeze 入门套机内部自带了功能测试,我们就先从中看一些例子吧:
tests/Feature/RegistrationTest.php
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered()
{
$response = $this->get('/register');
$response->assertStatus(200);
}
public function test_new_users_can_register()
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}
}
此处我们在一个类中有两个测试,它们都是和注册表单相关的:一个检测表单是否正确加载,另一个则检测表单提交是否正常工作。
我们了解了检测结果的其他两个方法,另外两个断言:$this->assertAuthenticated()
和 $response->assertRedirect()
。你可以查看官方 PHPUnit 和 Laravel 响应的官方文档中的断言。
请记住有些断言在 $this
对象上进行,而有一些则检测来自路由的调用的指定的 $response
中。
另一个重要的事情是,use RefreshDatabase;
语句,在上例的类中使用这个 trait。当你的测试行为会影响数据库结果时需要用到他,如上例,注册动作在用户 users
数据库表格中增加了新的记录。因此,你徐奥创建单独的数据库测试,每次测试执行的时候都会调用 php artisan migrate:refresh
刷新数据库。
你有两种选择,物理上创建一个单独的数据库,或使用基于内存的 SQLite 数据库。两者都在 Laravel 默认自带的文件 phpunit.xml
中配置。你尤其需要这一部分:
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
DB_CONNECTION
和 DB_DATABASE
,哪个更推荐呢?如果你在服务器上使用 SQLite, 最简单的办法是注释掉这几行,你的测试会在内存数据库中运行。
此例中,我们断言用户授权成功,重定向到了正确的页面,不过我们还需要在数据库中测试一下实际数据。
除了这些代码:
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
我们也可以使用数据库测试断言,做这样一些事情:
$this->assertDatabaseCount('users', 1);
// Or...
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
]);
另一个真实的案例:登录表单
我们再来看看 Laravel Breeze 的另一个测试案例。
tests/Feature/AuthenticationTest.php:
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered()
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function test_users_can_authenticate_using_the_login_screen()
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
}
public function test_users_can_not_authenticate_with_invalid_password()
{
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
}
这是登录表单测试,逻辑与注册测试相似,是吧?不过这里使用了三个方法而非两个,因此此处同时测试了好的情景和坏的情景。因此,通用的逻辑是,你应该兼顾测试两种情况:进展顺利或者失败的情形。
同时,此例测试中你也能看到数据库工厂的使用:Laravel 创建了一个假用户(在刷新的测试数据库中),然后使用正确的和错误的凭证进行登录。
再次说明一下,Laravel 使用 User 模型开箱即用的默认工厂生成了假数据。
database/factories/UserFactory.php:
class UserFactory extends Factory
{
public function definition()
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
];
}
}
看,Laravel 自己准备了多少东西,让我们的测试变得简单。
因此,如果我们在安装 Laravel Breeze 后运行 php artisan test
,我们能可以看到这样的情况:
PASS Tests\Unit\ExampleTest
✓ that true is true
PASS Tests\Feature\Auth\AuthenticationTest
✓ login screen can be rendered
✓ users can authenticate using the login screen
✓ users can not authenticate with invalid password
PASS Tests\Feature\Auth\EmailVerificationTest
✓ email verification screen can be rendered
✓ email can be verified
✓ email is not verified with invalid hash
PASS Tests\Feature\Auth\PasswordConfirmationTest
✓ confirm password screen can be rendered
✓ password can be confirmed
✓ password is not confirmed with invalid password
PASS Tests\Feature\Auth\PasswordResetTest
✓ reset password link screen can be rendered
✓ reset password link can be requested
✓ reset password screen can be rendered
✓ password can be reset with valid token
PASS Tests\Feature\Auth\RegistrationTest
✓ registration screen can be rendered
✓ new users can register
PASS Tests\Feature\ExampleTest
✓ the application returns a successful response
Tests: 17 passed
Time: 0.61s
特性测试 VS 单元测试 VS 其他
你已经看过 test/Feature
、tests/Uni
t 子目录。它们有何不同? 答案是有点“理念上”的。
在 Laravel/PHP 生态之外,有许多中自动化测试。你可以发现类似这样的术语:
- 单元测试
- 特性测试
- 集成测试
- 功能测试
- 端到端测试
- 验收测试
- 冒烟测试
- 等
听起来很复杂,而这些测试之间的差别有时候是很模糊了。这就是为什么 Laravel 简化了这些容易混淆的概念,将它们分成两种:单元测试和特性测试。
简单来说,特性测试尝试执行应用的实际特性:获取 URL, 调用 API,模仿如填充表单的具体行为。特性测试执行的是用户在现实情景中通常会手动去做的操作。
单元测试有两个意思。一般来说,你会发现所有自动化的测试都被叫做“单元测试”,整个流程也可能被称为“单元测试”。不过在特性vs单元的语境下,流程是指单独测试指定的代码非公共单元。比如,你有一个 Laravel 类有一个方法用来传参计算像订单总价这样的事。这样,你的单元测试将断言,那个方法是否使用不同参数返回正确的结果。
生成单元测试,需要带上一个参数:
php artisan make:test OrderPriceTest --unit
生成的代码和 Laravel 默认的单元测试一样:
class OrderPriceTest extends TestCase
{
public function test_example()
{
$this->assertTrue(true);
}
}
你可以看到,没有 RefreshDatabase
, 因为这是单元测试的最常见的一个定义:不触碰数据库,它像“黑匣子”那样工作,与应用隔离。
前面提到的例子,假设我们能有个服务类 OrderPrice
app/Services/OrderPriceService.php:
class OrderPriceService
{
public function calculatePrice($productId, $quantity, $tax = 0.0)
{
// Some kind of calculation logic
}
}
接着,单元测试可以这样实现:
class OrderPriceTest extends TestCase
{
public function test_single_product_no_taxes()
{
$product = Product::factory()->create(); // generate a fake product
$price = (new OrderPriceService())->calculatePrice($product->id, 1);
$this->assertEquals(1, $price);
}
public function test_single_product_with_taxes()
{
$price = (new OrderPriceService())->calculatePrice($product->id, 1, 20);
$this->assertEquals(1.2, $price);
}
// More cases with more parameters
}
我个人的 Laravel 项目经验,重点的测试是特性测试,而非单元测试。首先,你需要测试你的应用是否按照真实用户的使用场景运行。
其次如果你有特殊的计算或者逻辑,你可以定义成单元,带上参数为其创建专门的单元测试。
有时,写测试用例需要修改代码本身,重构会让其更易测试:将单元分成特定的类或方法。
什么时候/怎么运行测试代码?
你什么时候该运行 php artisan test
呢?
有很多不同的方式,取决于你公司的工作流程。不过一般来说,你需要保证最新的代码修改在推送到仓库前,所有的测试都是绿色的(意味着,没有错误)。
所以,你在本地执行任务,当你觉得完成的时候,运行测试确保没有破坏任何东西。记住,你的代码可能会产生 bug,不只是在你自己的业务逻辑中,也可能无意中在其他人很久以前写的代码中造成破坏。
如果我们更进一步,可能可以自动完成很多事情。随着很多的 CI/CD 工具的使用,你可以指定在有人推送到指定的 Git 分支之时,或者合并代码到生产分支时,就去执行测试。最简单的是使用 Github Action。
你应该测试什么?
有很多关于你的“测试覆盖率”应该有多大的不同观点:你该不该测试所有页面的所有操作,还是说只是影响你工作的最重要部分。
确实,有人指出自动化测试耗费的时间比带来的实际收益大。如果为每一个具体的细节都写测试确实会如此。尽管如此,你的项目需要了解的主要问题在于“潜在的错误会有多大的代价”。
换句话说,你需要带着这样的问题,“如果代码运行失败会发生什么事情”,对你测试的结果进行优先级排序。如果支付系统有BUG,就会直接影响生意。然后,如果角色/权限功能出错,会有巨大的安全问题。
我喜欢 Matt Stauffer 在一次会议中提到的一段话:“你需要先测试的这些东西是,一旦它们运行失败,你会丢掉你的工作”。当然,这有点言过其实,不过你懂其中含义:先测试重要的东西。然后如果有时间,再去测试其他功能。
PEST: 新的流行测试方案
所有以上例子都是基于 Laravel 默认的特殊工具: PHPUnit。不过这些年,在此生态中出现了其他工具,其中最为流行的一个是PEST。由 Laravel 官方雇员 Nuno Maduro 开发,它的目标是简化语法,使得编写测试用例更快。
在底层,它是基于 PHPUnit, 作为附加层,尽量减少一些 PHPUnit 代码的默认重复部分。
我们来看看例子。记得 Laravel 默认的特性测试类吗?看这:
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
public function test_the_application_returns_a_successful_response()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
用 PEST 怎样完成同样的测试呢?
test('the application returns a successful response')->get('/')->assertStatus(200);
是的,只需一行代码。因此,PEST的目标是消除这些开销:
- 创建类和方法
- 继承
TestCase
- 在另外的行中执行 action - 在 PEST 中,你可以链式调用
要在 Laravel 中生成 PEST 测试,你需要指定额外参数:
php artisan make:test HomepageTest --pest
本文撰写之时,PEST 在 Laravel 开发者中相当流行,不过是否使用这一额外的工具以及学习新的语法,完全是个人偏好。