您好,亲爱的工匠们,我是Alberto Rosas,我已经欣赏 Laravel 许多年了,我学到的最有用且最有回报的事情之一是为我的应用程序构建合适的测试套件。看到测试在 Laravel 社区中实践的频率越来越高,真是太好了。在这个博客文章中,我们将从 Laravel 中 TDD 的基础开始,并在其他文章中继续探讨高级主题。
以下是我们将要涵盖的内容
介绍
本文的目的是表明 TDD 不必太困难,也不必成为您工作流程或团队的一部分的痛苦之源。您只需要了解从哪里开始以及要测试什么。
我将 TDD 建立为习惯,并找到适合我的正确工作流程并没有花费我多少时间。有一个标准的工作流程很重要;知道您在一个普通项目中的第一件事是什么可以帮助您创建您自己的结构,以您知道应用程序中所有内容的位置等方式来组织事物,并对您个人/客户项目进行一些标准化。
换句话说,TDD 帮助您更快、更自信地编写代码。我之前提到,您需要了解要测试什么,但现实是,测试将引导您到达下一阶段,所以您真正需要真正理解的是您想要测试的特性。
要求
- 了解该框架的基本知识
- 全新Laravel项目
不管怎样,我们还是开始吧。
背景
在这篇文章以及可能会连载的一系列文章中,我将为房地产应用程序创建一个API。
properties
表
- id: 主键
- type: 字符串(我们稍后可能会与'property_types'建立关系。)
- price: 无符号整数
- description: 文本
在处理Laravel时,我们通常遵循一个标准/约定,即将控制器操作组织在5个API方法中
- index
- store
- update
- delete
我们将逐一进行,并解释我们最感兴趣进行测试的事物。
测试索引
索引方法通常用于返回一个特定的集合,用于Model。
我们将测试
- 我们有一个名为API端点用于检索资源集合。
- 测试响应是否以集合的形式返回。
让我们从创建一个测试开始,在你的Laravel项目中打开终端,运行
php artisan make:test Api/PropertiesTest
这将创建一个测试文件在/tests/Features/Api/PropertiesTest.php
在这个文件中,我们将添加我们的第一个测试,该测试将测试我们是否触发了index
API路由并返回一个资源集合,但目前我们还没有,所以让测试做主。
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PropertiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_all_properties()
{
// Create Property so that the response returns it.
$property = Property::factory()->create();
$response = $this->getJson(route('api.properties.index'));
// We will only assert that the response returns a 200 status for now.
$response->assertOk();
}
}
当然会有错误,我们还没有创建Property
模型,因此在我们项目目录中运行./vendor/bin/phpunit --testdox
后,我们得到
Tests\Feature\Api\PropertiesTest::can_get_all_properties
Error: Class 'Tests\Feature\Api\Property' not found
让我们来做吧
php artisan make:model Property -mf
上述命令将
- 在
app/Models/Property.php
中创建一个位于的模型 -
-m
在/database/migrations/
中创建迁移 -
-f
在/database/factories/PropertyFactory
中创建一个工厂类,我们将使用它来模拟"Property"及其属性。
按照TDD方法,我们一步步进行,在创建模型、迁移和工厂后,让我们再次运行测试(如果你在上面的测试中没有导入Property模型定义,你将会得到同样的错误)
Tests\Feature\Api\PropertiesTest::can_get_all_properties
Symfony\Component\Routing\Exception\RouteNotFoundException:
Route [api.properties.index] not defined.
让我们在/routes/api.php
文件中创建所需的端点
use App\Http\Controllers\Api\PropertyController;
Route::get(
'properties',
[PropertyController::class, 'index']
)->name('api.properties.index');
接下来,我们运行它,但会收到错误,即不存在的PropertyController
。
ReflectionException:类PropertyController不存在
让我们打开我们的终端,通过artisan创建我们的控制器
php artisan make:controller Api/PropertyController
该控制器将位于/app/Http/Controllers/Api/PropertyController.php
.
打开最近创建的文件,再次运行测试
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class PropertyController extends Controller
{
}
我们接收到另一个错误,指出不存在方法index
,让我们创建它以最终通过测试
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class PropertyController extends Controller
{
public function index()
{
}
}
运行测试,测试通过,但是...我们没有执行返回集合的实际逻辑进行测试,让我们通过更新测试来断言我们得到JSON。
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PropertiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_all_properties()
{
// Create Property so that the response returns it.
$property = Property::factory()->create();
$response = $this->getJson(route('api.properties.index'));
// We will only assert that the response returns a 200
// status for now.
$response->assertOk();
// Add the assertion that will prove that we receive what we need
// from the response.
$response->assertJson([
'data' => [
[
'id' => $property->id,
'type' => $property->type,
'price' => $property->price,
'description' => $property->description,
]
]
]);
}
}
当然,我们收到了
Invalid JSON was returned from the route.
,嗯...实际上我们从index
方法中没有返回任何内容,所以让我们来做这件事
<?php
namespace App\Http\Controllers\Api;
use App\Models\Property;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class PropertyController extends Controller
{
public function index()
{
return response()->json([
'data' => Property::all()
]);
}
}
我们返回一个包含集合的data
数组和其中包含的原因是在API响应的规范中,内容应该位于该data
数组内。
我们再次收到一个错误,即我们断言从响应中返回了属性的属性,但属性是null
,你能想象为什么吗?
Unable to find JSON:
[{
"data": [
{
"id": 1,
"type": null,
"price": null,
"description": null
}
]
}]
within response JSON:
[{
"data": [
{
"id": 1,
"created_at": "2021-10-15T14:44:21.000000Z",
"updated_at": "2021-10-15T14:44:21.000000Z"
}
]
}]
你猜对了!我们没有更新我们的PropertyFactory类以具有我们计划拥有的属性
use App\Models\Property;
use Illuminate\Database\Eloquent\Factories\Factory;
class PropertyFactory extends Factory
{
/**
* The name of the factory's corresponding model. * * @var string
*/
protected $model = Property::class;
/**
* Define the model's default state.
* @return array
*/
public function definition()
{
return [
'type' => $this->faker->word,
'price' => $this->faker->randomNumber(6),
'description' => $this->faker->paragraph,
];
}
}
因此,我们将开始收到关于"未知列..."的相关错误。因为我们的迁移不包含我们断言拥有的列。
让我们更新迁移来包含必要的列
Schema::create('properties', function (Blueprint $table) {
$table->id();
$table->string('type', 20);
$table->unsignedInteger('price');
$table->text('description');
$table->timestamps();
});
如果我们再次运行测试,我们会注意到它又通过了,而且这次是永久的。
既然我们创建了已测试的 index
方法,让我们继续进行 Store 方法,这个方法需要花更多的时间。
测试 Store 方法
对于这个测试,我们将首先在同一个文件 tests/Feature/Api/PropertiesTest.php
中创建我们的第二个测试。
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PropertiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_all_properties(){...}
/** @test */
/** @test */
public function can_store_a_property()
{
// Build a non-persisted Property factory model.
$newProperty = Property::factory()->make();
$response = $this->postJson(
route('api.properties.store'),
$newProperty->toArray()
);
// We assert that we get back a status 201:
// Resource Created for now.
$response->assertCreated();
// Assert that at least one column gets returned from the response
// in the format we need .
$response->assertJson([
'data' => ['type' => $newProperty->type]
]);
// Assert the table properties contains the factory we made.
$this->assertDatabaseHas(
'properties',
$newProperty->toArray()
);
}
}
让我们对这个测试进行回顾,首先
- 我们使用工厂方法
make
创建一个非持久化的 Property 模型,用作用户的请求。 - 我们通过 API 向
route('api.properties.store')
路由发送一个带有请求数据的 POST 请求。 - 然后断言我们返回一个状态码 201: 资源已创建
- 断言我们至少收到了一个新键来验证它是否符合正确的格式。
- 最后,我们断言表
properties
包含了新的 Property 模型。
运行测试时,我们得到一个错误
Symfony\Component\Routing\Exception\RouteNotFoundException : Route [api.properties.store] not defined.
这意味着确切的是;没有名为上述路由的 API 路由。
在 /routes/api.php
Route::post(
'properties',
[PropertyController::class, 'store']
)->name('api.properties.store');
当然,我们还没有创建 store
方法,我们可以这样做
<?php
namespace App\Http\Controllers\Api;
use App\Models\Property;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
class PropertyController extends Controller
{
public function index() : JsonResponse {...}
public function store(Request $request)
{
return response()->json([
'data' => Property::create($request->all())
], 201);
}
}
这实际上是我们唯一需要做的事情,但我们下一个错误是关于 批量赋值,基本上我们需要创建一个 protected $fillable = [];
属性,该属性包含您希望进行批量赋值的列名,它看起来像这样
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Property extends Model
{
use HasFactory;
protected $fillable = ['type', 'price', 'description'];
}
太好了,现在我们得到了绿色。当然,我们仍然需要在创建时验证这些属性,所以让我们正确地这样做。
我将开始创建一个“单元”测试(或者我叫它),因为我只对断言“创建”或“更新”Property 时接受错误的结果感兴趣;这样你可以验证 表单请求 被注入到控制器中的 store
和 update
方法。
创建表单请求的样子是这样的
php artisan make:request PropertyRequest
这将创建一个文件在 /app/Http/Requests/PropertyRequest.php
中,打开它,你会得到这个类
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PropertyRequest extends FormRequest
{
public function authorize()
{
// Change this to: true
return false;
}
public function rules()
{
return [
//
];
}
}
在这里,你会注意到一个名为 authorize
的方法,它返回 false
,你可以通过返回 true/false 验证访问方法;如果返回 false,控制器中的方法将返回 403 状态码:未授权。
让我们继续创建我们的 PropertyRequestTest
用于验证我们应用给 store
方法的规则,并最终也应用到 update
方法。
php artisan make:test Http/Requests/PropertyRequestTest --unit
我通常将我的验证测试放在 tests/Unit/Http/Requests/
,以模拟控制器被使用的位置。打开新测试
<?php
namespace Tests\Unit\Http\Requests;
use Tests\TestCase;
class PropertyRequestTest extends TestCase
{
}
如果你注意到,在我继续之前,我改变了一个细节,那就是默认导入的 TestCase
定义。在 单元 测试中默认导入的是 PHPUnit\Framework\TestCase
定义,它是 PHPUnit 的默认类,我们需要用位于 tests
目录中的 Laravel 的 TestCase
替换,以便在测试中使用 Laravel 断言和辅助函数。
所以,让我们从第一个测试开始,确保我们执行了 required
规则。
use RefreshDatabase;
private string $routePrefix = 'api.properties.';
/**
* @test
* @throws \Throwable
*/
public function type_is_required()
{
$validatedField = 'type';
$brokenRule = null;
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
}
好的,所以这是我的标准化验证测试的方法。我们创建了一种复制规则的标准方式,我们只需更新 $brokenRule
变量以包含将破坏规则的那个值,这样我们就可以断言 JSON 验证错误包含这个错误,这样我就可以复制粘贴相同的测试,只需更改 $validatedField
和 $brokenRule
,为新的测试。
这就是这里发生的事情
- 我们首先创建一个已验证的字段变量,以便我们在其他测试中重复使用该列名。
- 我们创建了一个包含将会触发表单请求的值的违规规则变量。
- 然后我们使用会破坏验证的值创建了一个非持久模型。
- 我们通过POST请求尝试创建一个新的属性。
- 我们立即断言JSON验证错误包中包含已验证的字段,这证明了存在错误,因此我们的请求正在做它的工作。
运行测试后获得错误
Failed to find a validation error in the response for key: 'type'
这意味着测试没有在POST请求的响应中找到验证错误,显然我们还没有在PropertyController
中实现FormRequest,让我们将Illuminate\Http\Request
替换为我们的PropertyRequest
,以便我们的store
方法看起来像这样
public function store(PropertyRequest $request) : JsonResponse {...}
如果我们运行测试,我们将会得到相同的错误,但这实际上不是正确的错误,我将在接下来的几句话中解释这个。让我们去到我们的type_is_required
测试并在其中添加一个名为withoutExceptionHandling
的方法,如下所示
/**
* @test
* @throws \Throwable
*/
public function type_is_required()
{
$this->withoutExceptionHandling();
...
}
默认情况下,Laravel通过修改响应为“友好”的异常信息来保护我们免受一些异常的侵害。实际上,我们的测试失败是因为我们实现了包含一个返回false
的authorize
方法的PropertyRequest
,这个方法可用于实现权限、角色或另一个条件的验证,如果结果为true
,则允许请求继续到验证规则,如果结果为false
,则抛出403状态码,这在之前的描述中称为unauthorized
。
为了修复这个问题,让我们将false
更改为true
,因为在这里我们并没有检查任何类型的权限或条件。
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PropertyRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request. * * @return bool
*/
public function authorize()
{
return true;
}
...
之后,我们必须移除我们的该方法withoutExceptionHandling
,以便我们收到预期的异常信息,然后让我们开始添加我们在FormRequest中测试的规则。
第一个验证规则是required
,由于我们正在测试的type
列是我们的第一个验证。
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PropertyRequest extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'type' => ['required'],
];
}
}
现在,如果我们运行测试,我们将会得到绿色,并且我们的测试正在通过验证。
store
方法要求请求中存在type
值。- FormRequest正在我们的
store
方法中使用。 - 我们已经稍微标准化了我们的验证测试,因此我们的下一组测试将更容易实现。
现在,我将只是按照顺序添加其他测试,因为实现非常相似;之后,我会展示并解释FormRequest中实现的验证规则,我将在一会儿见到你。
由于我们指定了type
列的长度最大为20个字符,我将接着添加这个验证。
/**
* @test
* @throws \Throwable
*/
public function type_is_required() {...}
/**
* @test
*/
public function type_must_not_exceed_20_characters()
{
$validatedField = 'type';
$brokenRule = Str::random(21);
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
}
正如你所看到的那样,我们复制/粘贴了以前的测试,并将$brokenRule
值更改为将使测试失败的值,因为这就是我们想要的。$brokenRule
变成了一个包含21个字符的随机字母字符串,触发验证错误,因为我们还没有将规则添加到FormRequest中,它还没有通过。
public function rules()
{
return [
'type' => ['required', 'max:20']
];
}
我们添加了规则max
来指定我们期望字段具有的最大值,在本例中为20,我们得到绿色!
接着是下一个测试,即针对price
,我们只需复制以前的测试并继续。
/**
* @test
* @throws \Throwable
*/
public function price_is_required()
{
$validatedField = 'price';
$brokenRule = null;
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
}
我们只是更改了测试的名称和$validatedField
值,它代表我们正在验证的列。正如你所看到的那样,由于我们只是复制/粘贴我们以前的测试,验证变得非常简单。
添加我们的价格规则
public function rules()
{
return [
'type' => ['required', 'max:20'],
'price' => ['required'],
];
}
测试将成功,因为工作已经完成,让我们快速完成我们的缺失测试
/**
* @test
* @throws \Throwable
*/
public function price_must_be_an_integer()
{
$validatedField = 'price';
$brokenRule = 'not-integer';
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
}
验证规则
public function rules()
{
return [
'type' => ['required', 'max:20'],
'price' => ['required', 'integer'],
];
}
这次测试结果显示绿色,我们可以通过将price
列添加为integer
类型的Attribute Casting
来避免这个测试,具体可参考属性转换,但出于当前考虑,我更愿意将验证一起处理。
由于description
字段是text
类型的列,我不确定是否需要对其进行验证,因为一个text
字段可以包含多达65,535
字节的文本,这对于让用户输入他们想要的内容已经足够了。
有了这些,我们已经测试了store
方法及其验证,欢迎在下次测试中再见。
测试更新方法
我们已经完成了一半,现在我们将继续测试更新方法。
如前文测试所示,一切都会变得简单起来,我们首先将测试添加到我们的PropertiesTest
类中。
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PropertiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_all_properties() {...}
/** @test */
public function can_store_a_property() {...}
/** @test */
public function can_update_a_property()
{
$existingProperty = Property::factory()->create();
$newProperty = Property::factory()->make();
$response = $this->putJson(
route($this->routePrefix . 'update', $existingProperty),
$newProperty->toArray()
);
$response->assertJson([
'data' => [
// We keep the ID from the existing Property.
'id' => $existingProperty->id,
// But making sure the title changed.
'title' => $newProperty->title
]
]);
$this->assertDatabaseHas(
'properties',
$newProperty->toArray()
);
}
运行我们的测试,我们知道第一个错误与不存在的路由api.properties.update
有关,让我们快速在我们的routes/api.php
文件中添加它。
use App\Http\Controllers\Api\PropertyController;
Route::put(
'properties/{property}',
[PropertyController::class, 'update']
)->name('api.properties.update');
我们已经知道下一个错误将提醒我们update
方法不存在。
<?php
namespace App\Http\Controllers\Api;
use App\Models\Property;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\PropertyRequest;
class PropertyController extends Controller
{
public function index() : JsonResponse {...}
public function store(PropertyRequest $request): JsonResponse {...}
public function update(Request $request, Property $property)
{
return response()->json([
'data' => tap($property)->update($request->all())
]);
}
update
方法第一个参数是请求(请注意,我没有使用PropertyRequest,因为我们还没有实现测试),我们正在更新的属性,来自端点的Laravel的Route Implicit Binding
。
我们还可以注意到tap方法,它基本返回你传递给它的元素,同时允许你对其进行方法链;最后返回更新后的模型,而不是替代方法
public function update(Request $request, Property $property): JsonResponse
{
$property->update($request->all());
return response()->json([
'data' => $property
]);
}
这同样是有效的,只是需要增加一行代码。
在这个阶段,我们应该得到一个通过测试,我们只是缺少验证测试,让我们回到我们的tests\Unit\Http\Requests\PropertyRequestTest.php
文件,并看看如何将update
操作纳入我们现有的测试中。
use RefreshDatabase;
private string $routePrefix = 'api.properties.';
/**
* @test
* @throws \Throwable
*/
public function type_is_required()
{
$validatedField = 'type';
$brokenRule = null;
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
// Update assertion
$existingProperty = Property::factory()->create();
$newProperty = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->putJson(
route($this->routePrefix . 'update', $existingProperty),
$newProperty->toArray()
)->assertJsonValidationErrors($validatedField);
}
在更新断言块中,我们
- 创建了想要更新的现有属性。
- 创建了一个未持久化的包含验证字段及其会破坏测试的值(在这种情况下为null)的Property工厂。
- 然后我们向
api.properties.update
路由发起PUT
请求,并传递现有属性作为参数,因为我们的路由期望是这样。 - 我们对此次请求进行了断言,以验证我们收到了JSON错误。
唯一缺少的是将测试复制到其余字段,相应地替换$validatedField
和$brokenRule
,并在我们的新update
方法中注入PropertyRequest
而不是Laravel的请求类,如下所示
public function update(PropertyRequest $request, Property $property)
('/')['update'重点项目]'】,我们有了通过测试。
太棒了,接下来的最终测试。
测试销毁方法
这是本文中最短的测试,因为我们只需测试当点击delete
端点时,我们不仅删除了Property模型,还返回了一个带有204: No Content
状态码的响应。
/** @test */
public function can_update_a_property() {...}
/** @test */
public function can_delete_a_property()
{
$existingProperty = Property::factory()->create();
$this->deleteJson(
route($this->routePrefix . 'destroy', $existingProperty)
)->assertNoContent();
// You can also use assertStatus(204) instead of assertNoContent()
// in case you're using a Laravel version that does not have this assertion.
// (I believe it is available from v7.x onwards)
// Finally we just assert the `properties` table does not contain the model that we just deleted.
$this->assertDatabaseMissing(
'properties',
$existingProperty->toArray()
);
}
如果我们运行它,我们会得到预期的错误,即我们没有指定路由,让我们添加它
Route::delete(
'properties',
[PropertyController::class, 'destroy']
)->name('api.properties.destroy');
并再次运行它,我们发现destroy
方法不存在,我会在PropertyController
中在update
方法下方添加它
public function update(PropertyRequest $request, Property $property) {...}
public function destroy(Property $property)
{
$property->delete();
return response([], 204);
}
我打赌这个会通过,对吧?
destroy
方法很简单;我们接收到来自路由的预期属性,由于隐式绑定解析了我们想要的Property模型,我们只需在它上运行delete
()方法,然后返回响应。
- 注意:我对本文中的响应进行了标准化,但你可以返回你需要的内容。
结论
尽管我尽量使这个例子尽可能地接近现实,但如果这是一个真正的应用程序,我会有所不同
与我们在上面所做的那样返回JSON响应不同,我反而会使用API资源,原因是;我可能想对我的 toJson 方法返回的 Collection/Model 进行一些修改,API 资源允许你修改它们。请查阅文档以获取更多示例。
由于我们在向一个动作请求数据并期望得到结果,因此你将需要测试的几乎所有端到端功能都与上面提到的测试具有相似的结构。
根据我的经验,一些团队不愿在应用程序中实现测试,原因之一是认为测试需要花费时间。正如我们所看到的,你可以复制/粘贴大多数测试,并快速调整它们以适用于新场景。如果你的工作流程是标准化的,代码化的话,那么速度将会更快。当然,并不是每个功能都可以复制/粘贴,但一旦你开始认真对待测试,你会发现你的思维会适应它,你会在测试中注意到模式。
我建议从小型功能开始测试,尽可能多地遵循 TDD 方法,不久后你可能会决定你喜欢先编码再测试,但这并不重要,只要你能测试你的功能。
我们刚刚开始介绍 TDD,因此请期待我们将会有更多类似的文章,展示更高级的主题。
希望您觉得这篇文章有用,并告诉我您会做些什么不同。
bcryp7, driesvints, kevinaswind, hassnian, maciejsk, zizu9, neil, davemi, jocelinkisenga, dumitriucristian等人在文章中点过赞