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

上篇教程中,学院君已经完成了待办任务项目后端 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/cssresources/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

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

下一篇: 持续集成的定义和常用 CI 系统对比