6

Laravel

文章转发自专业的Laravel开发者社区,原始链接:https://learnku.com/laravel/t...

这是 TDD 和敏捷开发方法学的先驱之一  James Grenning 的名言

如果您不进行测试驱动的开发,那么您将进行后期调试 - James Grenning

今天我们将进行测试驱动的 Laravel 之旅。我们将创建具有身份验证和 CRUD 功能的 Laravel REST API,而无需打开 Postman 或者浏览器。 😲

注意: 本旅程假定你理解 Laravel 和 PHPUnit 的基础概念。如果你不打算这么做?开车吧。

配置專案

讓我們從建立一個新的 Laravel 專案開始 composer create-project --prefer-dist laravel/laravel tdd-journey

下一步,我們需要運行建構用戶認證的指令,我們將在後面用到它,繼續運行 php artisan make:auth,接著 php artisan migrate

我們不是真的會用到生成的路由及視圖。在這個項目裡,我們會使用 jwt-auth。所以繼續在你的應用裡 配置它

注意: 如果你在使用 JWT 的 generate 指令時碰到錯誤,你可以依照 這裡 的指示來修復,直到它被加入下一個穩定版。

最后,您可以删除 tests/Unittests/Feature 文件夹中的 ExampleTest,确保它不会干扰我们的测试结果,然后我们继续。

编写代码

  1. 首先将您的 auth 配置为默认使用 JWT 为驱动程序:
<?php 
// config/auth.php 文件
'defaults' => [
    'guard' => 'api',
    'passwords' => 'users',
],
'guards' => [
    ...
    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],

然后将下面的代码添加到 routes / api.php 文件中:

<?php
Route::group(['middleware' => 'api', 'prefix' => 'auth'], function () {
    Route::post('authenticate', 'AuthController@authenticate')->name('api.authenticate');
    Route::post('register', 'AuthController@register')->name('api.register');
});
  1. 現在我們的驅動已經配置好了,接著配置你的 User 模型:
<?php
...
class User extends Authenticatable implements JWTSubject
{
    ...
     // 取得會被儲存在 JWT 物件中的 ID
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }
    // 返回一個包含所有客製化參數的鍵值組,此鍵值組會被加入 JWT 中
    public function getJWTCustomClaims()
    {
        return [];
    }
}

我們所做的是實作 JWTSubject 並加入必要的方法。

  1. 接下来,我们需要在控制器中添加我们的身份验证方法。

运行 php artisan make:controller AuthController 并添加以下方法:

<?php
...
class AuthController extends Controller
{
    
    public function authenticate(Request $request){
        //验证字段
        $this->validate($request,['email' => 'required|email','password'=> 'required']);
        //验证登录信息
        $credentials = $request->only(['email','password']);
        if (! $token = auth()->attempt($credentials)) {
            return response()->json(['error' => 'Incorrect credentials'], 401);
        }
        return response()->json(compact('token'));
    }
    public function register(Request $request){
        //验证字段
        $this->validate($request,[
            'email' => 'required|email|max:255|unique:users',
            'name' => 'required|max:255',
            'password' => 'required|min:8|confirmed',
        ]);
        //创建一个新用户,并且返回token令牌
        $user =  User::create([
            'name' => $request->input('name'),
            'email' => $request->input('email'),
            'password' => Hash::make($request->input('password')),
        ]);
        $token = JWTAuth::fromUser($user);
        return response()->json(compact('token'));
    }
}

这一步非常直接,我们所做的只是将 authenticateregister 方法添加到控制器中。在 authenticate 方法中,我们验证输入的字段,然后尝试登录并验证登录信息,如果成功则返回令牌token。在register方法中,我们验证输入的字段,用输入的信息创建一个新用户,并基于该用户生成一个令牌,并给用户返回该令牌。

  1. 接下来,测试我们刚刚写好的部分。使用 php artisan make:test AuthTest命令创建一个测试类。在新的 tests/Feature/AuthTest 文件中添加下面这些代码:
<?php 
/**
 * @test 
 * 测试注册
 */
public function testRegister(){
    //User的数据
    $data = [
        'email' => 'test@gmail.com',
        'name' => 'Test',
        'password' => 'secret1234',
        'password_confirmation' => 'secret1234',
    ];
    //发送 post 请求
    $response = $this->json('POST',route('api.register'),$data);
    //断言他是成功的
    $response->assertStatus(200);
    //断言我们收到了令牌
    $this->assertArrayHasKey('token',$response->json());
    //删除数据
    User::where('email','test@gmail.com')->delete();
}
/**
 * @test
 * 测试成功
 */
public function testLogin()
{
    //创建 user
    User::create([
        'name' => 'test',
        'email'=>'test@gmail.com',
        'password' => bcrypt('secret1234')
    ]);
    //尝试登陆
    $response = $this->json('POST',route('api.authenticate'),[
        'email' => 'test@gmail.com',
        'password' => 'secret1234',
    ]);
    //断言它成功并且收到了令牌
    $response->assertStatus(200);
    $this->assertArrayHasKey('token',$response->json());
    //删除user数据
    User::where('email','test@gmail.com')->delete();
}

通过上面的代码注释我们能很好的理解代码表达的意思。您应该注意的一件事是,我们如何在每个测试中创建和删除用户。我们需要注意的是每次测试都是单独的,丙炔数据库的状态是完美的。

现在让我们运行$vendor/bin/phpunit$phpunit(如果您在全局安装过它)。运行它你会得到成功结果。如果不是这样,您可以查看日志,修复并重新测试。这是TDD的美妙周期。

  1. 现在我们已经可以进行身份验证了,让我们为项目添加CURD(数据库的基本操作增删改查)。 在本教程中,我们将使用食物食谱作为CRUD项目,因为,为什么不呢?

首先让我们运行迁移命令php artisan make:migration create_recipes_table 然后添加以下内容:

<?php 
...
public function up()
{
    Schema::create('recipes', function (Blueprint $table) {
        $table->increments('id');
        $table->string('title');
        $table->text('procedure')->nullable();
        $table->tinyInteger('publisher_id')->nullable();
        $table->timestamps();
    });
}
public function down()
{
    Schema::dropIfExists('recipes');
}

https://gist.github.com/kofow...

然后运行迁移。现在使用命令 php artisan make:model Recipe创建Recipe模型并且添加下面代码到我们的模型中。

<?php 
...
protected $fillable = ['title','procedure'];
/**
 * 建立与User模型的关系
 * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 */
public function publisher(){
    return $this->belongsTo(User::class);
}

然后添加下面代码到 user 模型。

<?php
...
  /**
 * 获取所有的recipes(一对多)
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function recipes(){
    return $this->hasMany(Recipe::class);
}
  1. 现在我们需要使用路由来管理我们的食谱。首先,我们使用命令 php artisan make:controller RecipeController创建RecipeController控制器。 接下来, 修改 routes/api.php 文件并且添加create 路由。
<?php 
...
  Route::group(['middleware' => ['api','auth'],'prefix' => 'recipe'],function (){
    Route::post('create','RecipeController@create')->name('recipe.create');
});

在控制器中,还应该添加create方法

<?php 
...
  public function create(Request $request){
    //Validate
    $this->validate($request,['title' => 'required','procedure' => 'required|min:8']);
    //创建 recipe 并关联到 user
    $user = Auth::user();
    $recipe = Recipe::create($request->only(['title','procedure']));
    $user->recipes()->save($recipe);
    //返回recipe的json数据
    return $recipe->toJson();
}

使用命令 php artisan make:test RecipeTest 生成功能测试文件,并且编辑内容如下:

<?php 
...
class RecipeTest extends TestCase
{
    use RefreshDatabase;
    ...
    //创建用户并验证用户
    protected function authenticate(){
        $user = User::create([
            'name' => 'test',
            'email' => 'test@gmail.com',
            'password' => Hash::make('secret1234'),
        ]);
        $token = JWTAuth::fromUser($user);
        return $token;
    }
  
    public function testCreate()
    {
        //获取 token
        $token = $this->authenticate();
        $response = $this->withHeaders([
            'Authorization' => 'Bearer '. $token,
        ])->json('POST',route('recipe.create'),[
            'title' => 'Jollof Rice',
            'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'
        ]);
        $response->assertStatus(200);
    }
}

这段代码非常具有说明性,我们所做的就是创建一个处理用户注册和token生成的方法,然后我们在testCreate()方法中使用这个token,注意RefreshDatabase trait的使用,这个trait是laravel中非常方便的在你每一个次测试之后重置你的数据库方法,这对于小项目来说,非常好。

好了,到目前为止,我们想要推断的是响应的状态,开始干吧,运行 $ vendor/bin/phpunit
如果所有运行正常,你应该已经接收到了一个错误。

There was 1 failure:1) Tests\Feature\RecipeTest::testCreate
Expected status code 200 but received 500.
Failed asserting that false is true./home/user/sites/tdd-journey/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestResponse.php:133
/home/user/sites/tdd-journey/tests/Feature/RecipeTest.php:49FAILURES!
Tests: 3, Assertions: 5, Failures: 1.

查看日志文件, 我们可以看到罪魁祸首是在“食谱”和“用户”类中的“发布者”和“食谱”的关系。laravel努力在表中寻找user_id列 ,并且使用它作为外键, 但是在我们的迁移中我们设置publisher_id作为外键. 现在我们根据以下内容调整行:

**//Recipe file\
public function** publisher(){\
    **return** $this->belongsTo(User::**class**,'publisher_id');\
}//User file\
**public function** recipes(){\
    **return** $this->hasMany(Recipe::**class**,'publisher_id');\
}

重新运行测试,可以看到通过:

...                                                                 3 / 3 (100%)...OK (3 tests, 5 assertions)

接下来我们测试创建菜单的逻辑,我们可以通过断言用户的 recipes() 总数是否有所增加。更新 testCreate 如下:

<?php
...
//获取Token
$token = $this->authenticate();
$response = $this->withHeaders([
    'Authorization' => 'Bearer '. $token,
])->json('POST',route('recipe.create'),[
    'title' => 'Jollof Rice',
    'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'
]);
$response->assertStatus(200);
//获取数量并断言
$count = User::where('email','test@gmail.com')->first()->recipes()->count();
$this->assertEquals(1,$count);

现在,我们可以继续编写其他的方法,并做一些修改。 首先是routes/api.php

<?php
...
Route::group(['middleware' => ['api','auth'],'prefix' => 'recipe'],function (){
    Route::post('create','RecipeController@create')->name('recipe.create');
    Route::get('all','RecipeController@all')->name('recipe.all');
    Route::post('update/{recipe}','RecipeController@update')->name('recipe.update');
    Route::get('show/{recipe}','RecipeController@show')->name('recipe.show');
    Route::post('delete/{recipe}','RecipeController@delete')->name('recipe.delete');
});

接下来,我们将方法添加到控制器。更新RecipeController类。


<?php 
....
//创建 recipe
public function create(Request $request){
    //Validate
    $this->validate($request,['title' => 'required','procedure' => 'required|min:8']);
    //Create recipe and attach to user
    $user = Auth::user();
    $recipe = Recipe::create($request->only(['title','procedure']));
    $user->recipes()->save($recipe);
    //Return json of recipe
    return $recipe->toJson();
}
//获取所有的recipes
public function all(){
    return Auth::user()->recipes;
}
//更新recipe
public function update(Request $request, Recipe $recipe){
    //检查更新这是否是recipe的所有者
    if($recipe->publisher_id != Auth::id()){
        abort(404);
        return;
    }
    //更新并返回
    $recipe->update($request->only('title','procedure'));
    return $recipe->toJson();
}
//展示recipe的详情
public function show(Recipe $recipe){
    if($recipe->publisher_id != Auth::id()){
        abort(404);
        return;
    }
    return $recipe->toJson();
}
//删除recipe
public function delete(Recipe $recipe){
    if($recipe->publisher_id != Auth::id()){
        abort(404);
        return;
    }
    $recipe->delete();
}

代码和注释已经很好的解释了逻辑。

接下来看我们的 test/Feature/RecipeTest

<?php
...
  use RefreshDatabase;
protected $user;
// 创建一个用户并对其进行身份验证
protected function authenticate(){
    $user = User::create([
        'name' => 'test',
        'email' => 'test@gmail.com',
        'password' => Hash::make('secret1234'),
    ]);
    $this->user = $user;
    $token = JWTAuth::fromUser($user);
    return $token;
}
// 测试创建
public function testCreate()
{
    // 获取Token
    $token = $this->authenticate();
    $response = $this->withHeaders([
        'Authorization' => 'Bearer '. $token,
    ])->json('POST',route('recipe.create'),[
        'title' => 'Jollof Rice',
        'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'
    ]);
    $response->assertStatus(200);
    // 获取账户并断言
    $count = $this->user->recipes()->count();
    $this->assertEquals(1,$count);
}
// 测试显示所有
public function testAll(){
    // 验证用户并将配方附加到用户
    $token = $this->authenticate();
    $recipe = Recipe::create([
        'title' => 'Jollof Rice',
        'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'
    ]);
    $this->user->recipes()->save($recipe);
    // 调用路由并断言响应成功
    $response = $this->withHeaders([
        'Authorization' => 'Bearer '. $token,
    ])->json('GET',route('recipe.all'));
    $response->assertStatus(200);
    // 断言响应内容只有一项,并且第一项的标题是 Jollof Rice
    $this->assertEquals(1,count($response->json()));
    $this->assertEquals('Jollof Rice',$response->json()[0]['title']);
}
// 测试更新
public function testUpdate(){
    $token = $this->authenticate();
    $recipe = Recipe::create([
        'title' => 'Jollof Rice',
        'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'
    ]);
    $this->user->recipes()->save($recipe);
    // 调用路由并断言响应
    $response = $this->withHeaders([
        'Authorization' => 'Bearer '. $token,
    ])->json('POST',route('recipe.update',['recipe' => $recipe->id]),[
        'title' => 'Rice',
    ]);
    $response->assertStatus(200);
    // 断言标题是一个新的标题
    $this->assertEquals('Rice',$this->user->recipes()->first()->title);
}
// 测试显示单个的路由
public function testShow(){
    $token = $this->authenticate();
    $recipe = Recipe::create([
        'title' => 'Jollof Rice',
        'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'
    ]);
    $this->user->recipes()->save($recipe);
    $response = $this->withHeaders([
        'Authorization' => 'Bearer '. $token,
    ])->json('GET',route('recipe.show',['recipe' => $recipe->id]));
    $response->assertStatus(200);
    // 断言标题是正确的
    $this->assertEquals('Jollof Rice',$response->json()['title']);
}
// 测试删除
public function testDelete(){
    $token = $this->authenticate();
    $recipe = Recipe::create([
        'title' => 'Jollof Rice',
        'procedure' => 'Parboil rice, get pepper and mix, and some spice and serve!'
    ]);
    $this->user->recipes()->save($recipe);
    $response = $this->withHeaders([
        'Authorization' => 'Bearer '. $token,
    ])->json('POST',route('recipe.delete',['recipe' => $recipe->id]));
    $response->assertStatus(200);
    // 断言被删除后用户没有食谱
    $this->assertEquals(0,$this->user->recipes()->count());
}

除了附加的测试之外,惟一不同的是添加了一个类范围的用户文件。这样,authenticate 方法不仅生成令牌,而且还为后续操作设置用户文件。

现在运行 $ vendor/bin/phpunit ,如果做的都正确的话,你应该能收到一个绿色的测试通过的提示。

总结

希望这能让您深入了解 TDD 在 Laravel 中是如何工作的。当然,它绝对是一个比这更广泛的概念,不受特定方法的约束。

尽管这种开发方式看起来比通常的 后期调试 过程要长,但它非常适合在早期捕获你代码中的错误。尽管在某些情况下,非 TDD 方法可能更有用,但它仍然是一种需要习惯的可靠技能和素养。

本演练的完整代码可以在Github上找到 here. 你可以随意摆弄它。

谢谢!

[\
](https://medium.com/free-code-...


summerblue
11k 声望15.4k 粉丝

刻意练习,每日精进