学习如何在 Laravel 中使用 PHPUnit 和 PEST 进行简单的测试示例
发布时间:作者: PovilasKorop
谈到任何编程语言中的自动化测试或单元测试,有两类人
- 那些不写自动化测试并认为它们是浪费时间的人
- 那些编写测试,然后无法想象没有它们的工作的人
所以,在这篇文章中,我将尝试说服前者看看另一面,了解它的好处,以及如何在 Laravel 中轻松开始自动化测试。
首先,让我们谈谈“为什么”,然后我会展示几个“如何”的非常基本的例子。
为什么需要自动化测试
自动化测试并不复杂:它们只是为你运行代码的一部分并报告任何错误。这是最简单的描述方式。想象一下,你正在你的应用程序中发布一项新功能,然后一个个人机器人助手会去手动为你测试新功能,同时测试新代码是否破坏了旧功能。
这是主要好处:自动重新测试所有功能。这似乎是额外的工作,但如果你不告诉“机器人”去做,那你应该手动自己去做,对吧?或者你只是在没有太多测试的情况下发布新功能,希望用户会报告错误?我讽刺地称这种方法为“祈祷式开发”。
随着应用程序的每个新功能,自动化测试的回报越来越高。
- 功能 1:节省 X 分钟手动测试
- 功能 2:节省 2X 分钟 - 针对功能 2 和功能 1 再次测试
- 功能 3:节省 3X 分钟...
- 等等。
你明白了吧。想象一下你的应用程序在一年或两年后,团队中有新开发人员,他们甚至不知道“功能 1”是如何工作的,也不知道如何复制它进行测试。因此,你未来的自己会非常感谢你编写了自动化测试。
当然,如果你认为你的项目是一个非常短期的项目,你并不关心它的未来......不,我相信你的好意,所以让我告诉你如何轻松地开始测试。
我们的第一个自动化测试
要在 Laravel 中运行第一个自动化测试,你不需要编写任何代码。是的,你没看错。默认的 Laravel 安装中已经配置并准备好了所有内容,包括第一个真正的基本示例。
你可以尝试安装一个 Laravel 项目,然后立即运行第一个测试
laravel new projectcd projectphp artisan test
这应该是你的控制台中的结果
PASS Tests\Unit\ExampleTest✓ that true is true PASS Tests\Feature\ExampleTest✓ the application returns a successful response Tests: 2 passedTime: 0.10s
如果我们看一下默认的 Laravel /tests
文件夹,我们有两个文件。
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); }}
这看起来有点毫无意义,检查 true 是否为 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 trueFailed 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 responseExpected 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 failedTime: 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', 'password' => 'password', 'password_confirmation' => 'password', ]); $this->assertAuthenticated(); $response->assertRedirect(RouteServiceProvider::HOME); }}
这里我们在一个类中进行两个测试,因为它们都与注册表单有关:一个检查表单是否正确加载,另一个检查提交是否正常工作。
我们熟悉了另外两种检查结果的方法,另外两个断言:$this->assertAuthenticated()
和 $response->assertRedirect()
。你可以在 PHPUnit 和 Laravel 响应 的官方文档中查看所有可用的断言。请记住,一些通用断言发生在 $this
对象上,而另一些则检查来自路由调用的特定 $response
。
另一个重要的事情是 use RefreshDatabase;
语句,以及该特质,在类上方包含。当你的测试操作可能影响数据库时,它就很有必要,例如在这个示例中,注册会在 users
数据库表中添加一个新条目。为此,你需要创建一个单独的测试数据库,每次执行测试时,都会使用 php artisan migrate:fresh
来刷新它。
你有两种选择:物理上创建一个单独的数据库,或者使用内存中的 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', []);
另一个现实生活中的示例:登录表单
让我们看一下 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 passedTime: 0.61s
功能测试 VS 单元测试 VS 其他
你可能已经见过 tests/Feature
和 tests/Unit
子文件夹。它们之间有什么区别?答案有点“哲学”。
在全局范围内,除了 Laravel/PHP 生态系统之外,还有不同类型的自动化测试。你可以找到诸如以下术语:
- 单元测试
- 功能测试
- 集成测试
- 功能测试
- 端到端测试
- 验收测试
- 冒烟测试
- 等等。
听起来很复杂,而且这些测试类型之间的实际差异有时会变得模糊。这就是为什么 Laravel 简化了所有这些令人困惑的术语,并将它们分成两类:单元/功能。
简单来说,功能测试试图运行应用程序的实际功能:获取 URL、调用 API、模仿类似填写表单的精确行为。功能测试通常执行与任何项目用户在现实生活中手动执行的相同或类似的操作。
单元测试有两个含义。通常,你可能会发现任何自动化测试都被称为“单元测试”,整个过程也可能被称为“单元测试”。但在功能与单元的语境下,这个过程指的是测试代码中的某个特定非公共单元,处于隔离状态。例如,你有一些 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
时,它的实际用途是什么?
根据你公司的开发流程,有不同的方法,但通常情况下,你需要确保所有测试都是“绿色的”(意味着没有错误),然后你才能将最新的代码更改推送到代码库中。
因此,你可以在本地进行任务开发,当你觉得完成了时,运行测试以确保你没有破坏任何东西。请记住,你的代码不仅可能导致你的逻辑出现错误,而且还会无意中破坏很久以前写的其他人的代码中的某些行为。
如果更进一步,可以将许多事情自动化。使用各种 CI/CD 工具,你可以指定在有人将更改推送到某个特定 Git 分支时,或者在将代码合并到生产分支之前,执行你的测试。最简单的流程是使用 Github Actions,我有一个单独的视频来演示它。
你应该测试什么?
关于你的所谓的“测试覆盖率”应该有多大,有很多不同的观点:你应该测试每个操作和每个页面上的所有可能情况,还是只将工作限制在最重要的部分?
的确,我同意那些指责自动化测试占用时间超过实际收益的人的观点。如果你是为每一个细节编写测试,这种情况就可能发生。也就是说,这可能是你的项目要求的:主要问题是“潜在错误的代价是多少”。
换句话说,你需要用“如果这段代码失败会发生什么?”这个问题来优先考虑你的测试工作。如果你的支付系统存在错误,它会直接影响业务。然后,如果你的角色/权限功能被破坏,那么这是一个巨大的安全问题。
我喜欢 Matt Stauffer 在一次会议上说的话:“你需要先测试那些如果失败会让你被解雇的东西”。当然,这是一种夸张的说法,但你懂我的意思:先测试重要的东西。如果你有时间,再测试其他功能。
PEST:PHPUnit 的流行替代方案
上面所有示例都是基于 Laravel 的默认测试工具:PHPUnit。但在这些年来,生态系统中出现了其他工具,其中一个最新的流行工具是PEST。它由 Laravel 官方员工Nuno Maduro创建,其目标是简化语法,使编写测试代码变得更加快捷。
在底层,它作为 PHPUnit 的附加层运行在 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;
- 将操作放在单独的行上 - 在 PEST 中,你可以将它们链接起来。
要在 Laravel 中生成 PEST 测试,你需要指定一个额外的标志
php artisan make:test HomepageTest --pest
在撰写本文时,PEST 在 Laravel 开发人员中非常流行,但是否使用这个额外的工具并学习它的语法,取决于你的个人喜好,除了众所周知的 PHPUnit 之外。
所以,这就是你需要了解的关于自动化测试基础知识的所有内容。从这里开始,你就可以选择创建哪些测试以及如何在你的项目中运行它们。
有关更多信息,请参阅Laravel 关于测试的官方文档。