编程

怎样在 Laravel 中使用 PHPUnit 和 PEST 进行测试

1383 2022-05-29 10:57:52

当谈到编程语言的自动化测试或者单元测试时,通常会有两种态度:

  • 有些人从不写自动化测试,觉得这纯粹是浪费时间
  • 而那些写测试的,觉得无法想象不写测试如何进行开展工作

本文将尝试说服前一种人,了解其中的好处以及在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()你可以查看官方 PHPUnitLaravel 响应的官方文档中的断言。

请记住有些断言在 $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_CONNECTIONDB_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/Featuretests/Unit 子目录。它们有何不同? 答案是有点“理念上”的。

在 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 开发者中相当流行,不过是否使用这一额外的工具以及学习新的语法,完全是个人偏好。