通过测试驱动开发构建待办任务项目(一):后端接口和功能测试篇
本来打算写一篇通过 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
中间件对未认证用户进行判断,我们暂时先实现其中的 store
、update
、delete
三个资源变更方法:
<?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
6 Comments
资源太多,刚上手laravel着实看不过来,也都是业务碰到对应场景拿起来就用=-=
拿起来就用的时候在学院能找到相应的文档和上手教程
@学院君 执行测试用例的时候,表全没了,然后手动执行rollback也提示Nothing to rollback,请问可能是什么原因啊
运行单元测试会清空数据表并重新构建 不然岂不都是脏数据了
哦,我理解错了,不过感觉这种做法有点粗暴, 有没有可以先备份现有数据,然后执行单元测试,测试完再把之前的数据覆盖回来的实现方式啊, 或者如果测试过程中能够复用一个数据库连接的话,就可以先开启事务,然后测试完成后rollback
可以参考: https://www.cnblogs.com/dzkjz/p/12709840.html 测试数据库在.env.testing中修改为sqlite