通过测试驱动开发构建待办任务项目(一):后端接口和功能测试篇


本来打算写一篇通过 Laravel Dusk 测试前端 Vue 组件的教程,转念一想不如玩把大的,直接基于 Laravel + Vue 构建一个前后端分离的待办任务列表项目,然后在开发过程中通过 HTTP 功能测试用例测试后端 API 接口,通过浏览器测试用例测试前端 Vue 组件与后端的交互,同时引入数据库测试对增删改查进行测试,从而完成一个简单的、相对完整的测试驱动开发项目。

构建应用

创建新项目

我们创建一个新的 Laravel 应用来完成项目。首先通过如下命令快速初始化一个新的 Laravel 应用,将新项目命名为 todoapp

laravel new todoapp

数据库迁移

新项目创建后,进入 todoapp 目录,修改 .env 中的数据库相关配置以便可以连接到本地开发环境的数据库。然后运行如下 Artisan 命令创建一个新的数据库迁移文件用于创建待办任务表 tasks

php artisan make:migration create_tasks_table

该命令会在 database/migrations 目录下生成一个数据库迁移文件 2019_04_16_054738_create_tasks_table.php,编写该文件代码如下:

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateTasksTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->text('text');
            $table->tinyInteger('is_completed')->unsigned()->default(0);
            $table->integer('user_id')->unsigned();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('tasks');
    }
}

我们在这个数据表中定义三个功能字段,text 用于存放任务名称,is_completed 用于表示任务是否完成,user_id 用于存放对应的用户 ID 以便和用户关联。运行 Artisan 迁移命令在数据库中创建这张数据表:

php artisan migrate

创建模型类

接下来,通过如下 Artisan 命令创建 tasks 对应的模型类:

php artisan make:model Task

该命令会在 app 目录下生成 Task.php,编写该模型类代码如下:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Task extends Model
{
    const NOT_COMPLETED = 0;
    const IS_COMPLETED = 1;

    protected $fillable = ['text', 'is_completed', 'user_id'];

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

我们在模型类中定义了两个常量属性用于标识任务是否完成,通过 $fillable 属性设置了支持批量赋值的属性,最后定义了一个 user 方法用于表示用户与待办任务之间的一对多关联关系

定义资源控制器

为了实现对待办任务的增删改查操作,我们为其创建一个资源控制器 TaskController

php artisan make:controller TaskController --resource

该命令会在 app/Http/Controllers 目录下生成一个资源控制器 TaskController.php。在这个控制器中,我们限制只有认证用户才能对任务进行增、删、改操作,未认证游客只能查看任务,由于我们要构建的是前后端分离应用,所以需要通过 auth:api 中间件对未认证用户进行判断,我们暂时先实现其中的 storeupdatedelete 三个资源变更方法:

<?php

namespace App\Http\Controllers;

use App\Task;
use Illuminate\Http\Request;

class TaskController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth:api')->except(['index', 'show']);
    }

    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        //
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function create()
    {
        //
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        $request->validate([
            'text' => 'required'
        ]);

        return Task::create([
            'text' => $request->text,
            'user_id' => auth('api')->user()->id,
            'is_completed' => Task::NOT_COMPLETED
        ]);
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function show($id)
    {
        //
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function edit($id)
    {
        //
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  Task  $task
     * @return \Illuminate\Http\Response
     */
    public function update(Task $task)
    {
        return tap($task)->update(request()->only(['is_completed', 'text']))->fresh();
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  Task  $task
     * @return \Illuminate\Http\Response
     */
    public function destroy(Task $task)
    {
        $task->delete();

        return response()->json(['message' => 'Task deleted'], 200);
    }
}

在上述代码中,我们用到了 Laravel 框架的控制器中间件隐式路由绑定表单字段验证辅助函数 tap/auth 以及 JSON 响应等功能特性,对这些功能不太熟悉的话,可以点击链接查看相应的文档。

注册路由

定义好控制器后,我们在 API 路由文件 routes/api.php 中定义相应的 API 资源路由指向这个控制器:

Route::resource('task', 'TaskController');

Passport 初始化

由于我们在这个项目中需要用到 API 认证,并且将基于 Passport 扩展包实现 API 认证,所以还需要通过 Composer 安装这个扩展包:

composer require laravel/passport

然后运行如下 Artisan 命令初始化 Passport 数据表和认证相关密钥信息:

php artisan migrate
php artisan passport:install

最后,不要忘了添加 Laravel\Passport\HasApiTokens Trait 到 App\User 模型,以便可以在 User 模型上使用 Passport 进行 API 认证:

use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use Notifiable, HasApiTokens;

    ...

好了,至此,我们就完成了该项目后端功能的初始化工作,下面我们通过编写HTTP 功能测试用例来测试上面定义的三个 API 接口。

编写 HTTP 功能测试用例

编写测试用例 TasksTest

我们还是通过一条 Artisan 命令来生成功能测试用例类:

php artisan make:test TasksTest

该命令会在 tests/Feature 目录下创建一个新的 TasksTest.php 文件,我们在这个文件中定义 HTTP 功能测试代码如下:

<?php

namespace Tests\Feature;

use Tests\TestCase;
use App\Task;
use App\User;
use Laravel\Passport\Passport;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class TasksTest extends TestCase
{
    use DatabaseMigrations;

    /**
     * 测试认证用户可以创建任务
     */
    public function testUserCanCreateTask()
    {
        $user = factory(User::class)->create();
        $task = [
            'text' => 'New task text',
            'user_id' => $user->id
        ];

        Passport::actingAs($user, ['*']);
        $response = $this->json('POST', 'api/task', $task);

        $response->assertStatus(201);
        $this->assertDatabaseHas('tasks', $task);
    }

    /**
     * 测试访客不能创建任务
     */
    public function testGuestCantCreateTask()
    {
        $task = [
            'text' => 'new text',
            'user_id' => 1
        ];

        $response = $this->json('POST', 'api/task', $task);

        $response->assertStatus(401);
        $this->assertDatabaseMissing('tasks', $task);
    }

    /**
     * 测试认证用户可以删除任务
     */
    public function testUseCanDeleteTask()
    {
        $user = factory(User::class)->create();
        $task = factory(Task::class)->create([
            'text' => 'task to delete',
            'user_id' => $user->id
        ]);

        Passport::actingAs($user, ['*']);
        $response = $this->json('DELETE', "api/task/$task->id");

        $response->assertStatus(200);
        $this->assertDatabaseMissing('tasks', ['id' => $task->id]);
    }

    /**
     * 测试认证用户可以完成任务
     */
    public function testUserCanCompleteTask()
    {
        $user = factory(User::class)->create();
        $task = factory(Task::class)->create([
            'text' => 'task to complete',
            'user_id' => $user->id
        ]);

        Passport::actingAs($user, ['*']);
        $response = $this->json('PUT', "api/task/$task->id", ['is_completed' => Task::IS_COMPLETED]);

        $response->assertStatus(200);
        $this->assertNotNull($task->fresh()->is_completed);
    }
}

在上述代码中,我们编写了四个测试用例,分别用于测试创建任务、删除任务和更新任务接口,并且在创建任务的时候,为了测试未登录游客不能创建任务,还编写了额外的一个测试用例 testGuestCantCreateTask

涉及到的相关测试技术

在测试用例中,我们不仅会断言 API 接口的响应状态码,还会断言调用接口后数据库中的对应记录是否存在,以确认更新操作确实生效。有关 HTTP 功能测试和数据库测试的更多断言方法可以参考 HTTP 测试数据库测试文档。

在需要用户认证的场景下,我们使用了 Passport 提供的方法模拟用户进行 API 认证。

另外,你可能注意到,我们还在测试类中使用了 DatabaseMigrations Trait,它的作用是在运行测试用例之前运行 migrate:refresh 命令回滚所有迁移再运行所有迁移,也就是重新构建数据库,然后在应用销毁(测试结束)时运行 migrate:rollback 命令回滚所有已执行的迁移:

<?php

namespace Illuminate\Foundation\Testing;

use Illuminate\Contracts\Console\Kernel;

trait DatabaseMigrations
{
    /**
     * Define hooks to migrate the database before and after each test.
     *
     * @return void
     */
    public function runDatabaseMigrations()
    {
        $this->artisan('migrate:fresh');

        $this->app[Kernel::class]->setArtisan(null);

        $this->beforeApplicationDestroyed(function () {
            $this->artisan('migrate:rollback');

            RefreshDatabaseState::$migrated = false;
        });
    }
}

编写模型工厂

在这段测试代码中,我们还使用了模型工厂模拟创建数据库记录,由于 User 模型对应的模型工厂 Laravel 框架已经开箱提供,所以我们只需要创建 Task 模型对应的模型工厂即可,我们使用如下 Artisan 命令创建模型工厂

php artisan make:factory TaskFactory

该命令会在 database/factories 目录下创建 TaskFactory.php,我们就在这个文件中编写模型工厂

<?php

use Faker\Generator as Faker;

$factory->define(\App\Task::class, function (Faker $faker) {
    return [
        'text' => $faker->text,
        'is_completed' => \App\Task::NOT_COMPLETED
    ];
});

至此,我们的 HTTP 测试用例就编写好了,接下来我们就可以运行这个测试用例来检验代码是否有问题了。

运行 HTTP 测试用例

我们在项目根目下运行如下命令执行测试用例,绿色代表通过:

这就说明我们的后端 API 接口都是 OK 的,如果测试不通过,则需要排查问题,修改代码,直到测试都通过。

下一篇我们将基于 Vue 组件构建前端页面,通过 JavaScript 与后端这些 API 接口交互实现完整的增删改查功能,并通过 Laravel Dusk 编写浏览器测试用例验收整个项目是否合格。

本项目源码已提交到 Github 仓库:https://github.com/nonfu/todoapp


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 在 Laravel 中基于 Dusk 实现浏览器自动化测试快速入门

>> 下一篇: 通过测试驱动开发构建待办任务项目(二):前端功能和浏览器测试篇