通过测试驱动开发构建待办任务项目(二):前端功能和浏览器测试篇
在上篇教程中,学院君已经完成了待办任务项目后端 API 接口的编写和功能测试,现在,我们开始编写 Vue 组件来实现前端的交互界面。
编写前端 Vue 组件
首先在 resources/js/components
目录新增一个 Vue 组件 TasksComponent.vue
,并编写模板代码和脚本代码如下:
<template>
<div class="w-full sm:w-1/2 lg:w-1/3 rounded shadow">
<h2 class="bg-yellow-dark text-sm py-2 px-4 font-hairline font-mono text-yellow-darker">Tasks</h2>
<ul class="list-reset px-4 py-4 font-serif bg-yellow-light h-48 overflow-y-scroll scrolling-touch">
<li v-for="(task, index) in tasks" class="flex">
<label class="flex w-5/6 flex-start py-1 block text-grey-darkest font-bold cursor-pointer">
<input
class="mr-2 cursor-pointer"
type="checkbox"
:dusk="`check-task${task.id}`"
:checked="checked(task)"
@click="completeTask(task)"
>
<span :class="[{'line-through' : task.is_completed}, 'text-sm italic font-normal']">
{{ task.text }}
</span>
</label>
<span
class="flex-1 cursor-pointer text-center rounded-full px-3 text-yellow-light hover:text-yellow-darker text-xs py-1"
@click="removeTask(index, task)"
:dusk="`remove-task${task.id}`"
>✖</span>
</li>
</ul>
<form class="w-full text-sm" @submit.prevent="createTask">
<div class="flex items-center bg-yellow-lighter py-2">
<input class="appearance-none bg-transparent border-none w-3/4 text-yellow-darkest mr-3 py-1 px-2 font-serif italic"
type="text"
placeholder="New Task"
aria-label="New Task"
v-model="newTask"
dusk="task-input"
>
<button
class="flex-no-shrink bg-yellow hover:bg-yellow font-base font-normal text-yellow-darker py-2 px-4 rounded"
type="button"
dusk="task-submit"
@click="createTask"
>
Add
</button>
</div>
</form>
</div>
</template>
<script>
export default {
props: ['initialTasks'],
data() {
return {
newTask: '',
tasks: this.initialTasks
}
},
methods: {
createTask(event) {
if (this.newTask.trim().length === 0) {
return;
}
axios.post('/api/task', {
text: this.newTask
}).then((response) => {
this.tasks.push(response.data);
this.newTask = '';
}).catch((e) => console.error(e));
},
completeTask(task) {
let status = ! task.is_completed;
axios.put(`/api/task/${task.id}`, {
is_completed: status
}).then((response) => {
task.is_completed = response.data.is_completed
}).catch((e) => console.error(e));
},
checked(task) {
return task.is_completed;
},
removeTask(index, task) {
axios.delete(`/api/task/${task.id}`)
.then((response) => {
this.tasks = [
this.tasks.slice(0, index),
this.tasks.slice(index + 1)
];
}).catch((e) => console.error(e));
}
}
}
</script>
在这个 Vue 组件中,我们会通过父组件传入的 initialTasks
属性来完成待办任务列表的渲染,然后我们还可以在组件中通过 Axios 库与后端 API 接口交互实现新增任务,移除任务,以及将任务标记为已完成。
接下来,我们需要将这个 Vue 组件注册到全局 Vue 实例,这个工作在 resources/js/app.js
中完成:
...
Vue.component('tasks-component', require('./components/TasksComponent.vue').default);
...
编写前端视图模板
将 CSS 框架切换为 Tailwind CSS
到这里还没有结束,我们还要将上述 Vue 组件嵌入到视图模板中才能在前端显示出来。为此,我们还要编写相应的前端视图文件和布局文件,Laravel 默认的 CSS 框架是 Bootstrap,这里学院君想换个口味,使用 Tailwind CSS 来替代框架预设的 Bootstrap 样式( Tailwind CSS 对应的中文文档在这里),这可以通过一个 Laravel 扩展包来快速切换,我们通过 Composer 来安装这个扩展包:
composer require laravel-frontend-presets/tailwindcss
然后运行如下 Artisan 命令执行切换:
php artisan preset tailwindcss
该命令会将 package.json
中 Bootstrap 相关扩展包替换成 Tailwind 的,并且删除 resources/sass
目录,将 Tailwind 资源文件发布到 resources/css
及 resources/js
目录,更新 resources/views/welcome.blade.php
视图文件和 webpack.mix.js
文件。
如果你需要更新框架自带的用户认证相关视图脚手架代码,还可以运行如下命令进行切换,建议执行这个命令,因为它会替我们生成后面要用到的认证路由、控制器和视图相关文件(运行这个命令就不必运行上一个 preset
命令了):
php artisan preset tailwindcss-auth
至此,从 Bootstrap 框架切换到 Tailwind CSS 框架的工作就完成了。
编写视图模板文件
我们将任务列表 Vue 组件的渲染放到 resources/views/home.blade.php
视图文件中,修改该视图模板代码如下:
@extends('layouts.app')
@section('content')
<div class="container px-4 sm:px-0 mx-auto py-8">
<tasks-component :initial-tasks="{{ $tasks }}"></tasks-component>
</div>
@endsection
该视图继承自 layouts.app
布局,我们在里面嵌入了前面注册的 tasks-component
组件,并且通过 initial-tasks
属性将控制器传过来的任务列表传入 Vue 组件(就是前面提到的 initialTasks
属性),由于我们把前端逻辑都封装到 Vue 组件中了,所以这个视图模板非常简洁。
编译前端资源
到这里,前端视图和 Vue 组件都编写好了,接下来我们需要编译前端资源,以便让前端视图可以正常渲染和使用,首先需要运行如下命令安装 package.json
中定义的前端资源依赖:
npm install
在编译前端资源前,需要对前端编译编排文件 webpack.mix.js
稍作修改:
const mix = require('laravel-mix');
/*
|--------------------------------------------------------------------------
| Mix Asset Management
|--------------------------------------------------------------------------
|
| Mix provides a clean, fluent API for defining some Webpack build steps
| for your Laravel application. By default, we are compiling the Sass
| file for the application as well as bundling up all the JS files.
|
*/
require('laravel-mix-purgecss')
mix.js('resources/js/app.js', 'public/js')
.postCss('resources/css/app.css', 'public/css')
.options({
postCss: [
require('postcss-import')(),
require('tailwindcss')(),
require('postcss-cssnext')({
// Mix adds autoprefixer already, don't need to run it twice
features: { autoprefixer: false }
}),
]
})
.purgeCss();
这里面有到了两个额外的依赖,需要安装后才能进行编译:
npm install postcss-import postcss-cssnext
做好了以上准备工作,接下来就可以运行如下命令来编译前端资源了:
npm run dev
编写后端代码
为了让前端视图可以正常渲染,页面交互功能可以正常使用,我们最后还要对后端代码做一些调整。
HomeController
我们希望用户登录之后才能访问待办任务列表,用户登录之后默认跳转的路由是 /home
,该路由对应的控制器方法是 HomeController@index
(在 routes/web.php
中可以看到),所以我们在 app/Http/Controllers/HomeController.php
中编写相应的业务逻辑代码如下:
public function index()
{
$tasks = auth()->user()->tasks->all();
return view('home', ['tasks' => json_encode($tasks)]);
}
我们将认证用户名下关联的任务列表作为参数传递给 home
视图,为了让这段代码生效,还要在 User
模型类中新增一个 tasks
关联方法:
public function tasks()
{
return $this->hasMany(Task::class);
}
单页面应用的认证实现
到目前为止,我们编写的这个待办任务项目算得上是个前后端分离的单页面应用,因为任务的增、删、改都是通过前端组件调用后端 API 接口异步实现的,后端 API 接口需要基于 API 进行认证,而我们之前介绍 API 认证时正好介绍过这种场景的认证实现:用户认证与授权系列 —— 通过 Passport 实现 API 请求认证:单页面应用篇,这里我们同样借鉴这个思路来实现基于 Session 的登录认证与基于 Passport 实现的 API 认证的一体化。
首先打开 config/auth.php
,将 guards
配置项中的 api.driver
配置值修改为 passport
:
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'users',
'hash' => false,
],
],
然后在 app/Http/Kernel.php
中,添加 \Laravel\Passport\Http\Middleware\CreateFreshApiToken
中间件到 web
中间件组:
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class
],
...
通过这个中间件可以实现用户通过表单登录后将访问令牌保存到 Cookie 中,以便在 API 认证时使用,这样就完成用户 Session 认证和 API 认证的一体化了。
至此,待办任务项目前后端的功能代码都已经编写好了,下面我们可以基于 Laravel Dusk 编写浏览器测试用例了。
基于 Dusk 实现浏览器测试
初始化 Dusk
使用 Dusk 之前,先通过 Composer 安装 Dusk 扩展包:
composer require --dev laravel/dusk
然后运行如下 Artisan 命令初始化 Dusk(在 tests
命令下创建 Browser
子目录及相关示例文件):
php artisan dusk:install
编写浏览器测试用例
通过如下命令创建一个新的浏览器测试用例:
php artisan dusk:make TasksTest
该命令会在 tests/Browser
目录下创建 TasksTest.php
文件,编写该测试用例文件代码如下:
<?php
namespace Tests\Browser;
use App\Task;
use App\User;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class TasksTest extends DuskTestCase
{
use DatabaseMigrations;
protected $user;
/**
* 通过模型工厂初始化测试用户
*/
protected function setUp(): void
{
parent::setUp();
$this->user = factory(User::class)->create();
}
/**
* 测试创建任务
* @throws \Throwable
*/
public function testCreateTask()
{
$this->browse(function (Browser $browser) {
// 以认证用户身份测试访问待办任务首页
$browser->loginAs($this->user)
->visit('/')
->assertSee('Tasks');
/**
* 测试新增一个待办任务:
* 输入「First Task」-> 点击提交「Add」-> 提交成功后断言列表里出现刚刚新增的任务
*/
$browser
->waitForText('Tasks')
->type('@task-input', 'First Task')
->click('@task-submit')
->waitForText('First Task')
->assertSee('First Task');
/**
* 测试新增第二个任务
*/
$browser->type('@task-input', 'Second Task')
->press('@task-submit')
->waitForText('Second Task')
->assertSee('Second Task');
// 断言数据库是否包含刚刚新增的任务
$this->assertDatabaseHas('tasks', ['text' => 'First Task']);
$this->assertDatabaseHas('tasks', ['text' => 'Second Task']);
});
}
/**
* 测试移除任务
* @throws \Throwable
*/
public function testRemoveTask()
{
// 使用模型工厂创建一个待测试任务「Test Task」
$task = factory(Task::class)->create([
'text' => 'Test Task',
'user_id' => $this->user->id
]);
$this->browse(function (Browser $browser) {
// 以认证用户身份访问首页
$browser
->loginAs($this->user)
->visit('/')
->waitForText('Tasks');
// 点击移除任务按钮,0.5秒后断言任务是否已删除(对应任务不存在)
$browser->click("@remove-task1")
->pause(500)
->assertDontSee('Test Task');
});
// 断言数据库不包含对应任务确认后端删除成功
$this->assertDatabaseMissing('tasks', $task->only(['id', 'text']));
}
/**
* 测试完成任务(修改)
* @throws \Throwable
*/
public function testCompleteTask()
{
// 还是使用模型工厂创建一个测试任务
$task = factory(Task::class)->create(['user_id' => $this->user->id]);
$this->browse(function (Browser $browser) use ($task) {
// 以认证用户身份访问首页并勾选任务已完成,
// 如果 `line-through` 选择器出现则说明操作成功
$browser
->loginAs($this->user)
->visit('/')
->waitForText('Tasks')
->click("@check-task{$task->first()->id}")
->waitFor('.line-through');
});
// 断言数据库已完成任务不为空来确认后端数据库记录已更新
$this->assertNotEmpty($task->fresh()->is_completed);
}
}
在该浏览器测试用例中,我们仍然使用了 DatabaseMigrations
Trait 在测试用例运行前后重构和回滚所有数据库变更,以免产生脏数据,然后我们使用 setUp
方法在测试用例运行之前通过模型工厂创建一个初始测试用户,接下来编写了三个具体的测试用例,分别用于测试任务的创建、移除和修改,在这些测试用例中我们通过 $browser
实例模拟浏览器页面的访问、登录、表单输入、按钮点击等操作,从而完成相应的后端 API 调用,并且根据按钮、元素点击后页面的变化来断言相应的操作结果是否符合预期(更多断言方法与元素交互细节可以参考 Dusk 文档),最后,还通过对数据库记录进行断言来确认前端操作是否生效(数据库断言及测试的更多细节请参考数据库测试文档)。
注:由于后端任务的创建、删除和修改 API 接口都需要认证后才能访问,所以我们通过浏览器实例的
loginAs
方法模拟用户 Web 登录,同时由于在web
中间件组中应用了CreateFreshApiToken
中间件,用户登录后将访问令牌保存到 Cookie 中,这样下次用户访问需要认证的 API 接口时就可以直接通过这个令牌判断用户已经登录了,从而实现了两种渠道认证的无缝对接。
运行浏览器测试用例
至此,浏览器测试用例编写完成,并且覆盖了所有 Vue 组件中涉及到的与后端 API 交互的方法,下面运行这个浏览器测试用例(运行之前先删除系统自带的 tests/Browser/ExampleTest.php
用例文件,因为我们已经调整过首页逻辑,所以该测试用例会运行失败),绿色代表测试通过:
这样一来,说明我们编写的前端视图和 Vue 组件功能无碍,可以进行后续其他功能的迭代了。
项目整体体验
前面所有功能的编写和测试都是通过代码完成的,到目前为止,我们还不知道项目的页面是什么样子,既然前面的测试表明项目的各项功能已经通过验收,下面不妨来看下庐山真面目。
由于我们在测试用例中都使用了 DatabaseMigrations
Trait,测试用例运行完成后,数据库的所有更改都回滚了,所以在体验之前,需要运行 php artisan migrate
创建所有数据表。
然后通过 http://todoapp.test
访问应用首页,经过 Tailwind CSS 渲染的首页长这样:
要访问待办任务页面,需要用户先登录,为此,我们来注册个新用户:
注册成功后,页面跳转到 /home
路由,此时待办任务列表为空:
由于我们的前端功能和后端功能都已经通过测试验收,所以大胆的对任务进行增删改查好了:
到这里,我们的测试驱动开发项目就告一段落,但是这里不是终点,后续介绍广播、缓存、队列、事件时还会基于此项目进行迭代。下一篇,我们将探索如何对 Laravel 项目进行持续集成。
本项目源码已提交到 Github 仓库:https://github.com/nonfu/todoapp
2 Comments
安装完前端依赖run dev 报错误: error in ./resources/css/app.css
Module build failed (from ./node_modules/_css-loader@1.0.1@css-loader/index.js): ModuleBuildError: Module build failed (from ./node_modules/_postcss-loader@3.0.0@postcss-loader/src/index.js): TypeError: Cannot read property 'body' of undefined at C:\lpg\alis\jian\resources\css\app.css:47:1 不知道怎么解决,请指教下
我是这么做的,卸载postcss-cssnext,使用postcss-preset-env 卸载:npm uninstall postcss-cssnext 安装:npm install postcss-preset-env 重新编译:npm run dev https://github.com/MoOx/postcss-cssnext
【 我卸载了:postcss-import(默认安装的是12.0.x,查看报错信息引用的是11.0.0) 卸载:npm uninstall postcss-import 重新指定了旧版本:npm install --save postcss-import@11.0.0 】