[ Laravel從入門到精通 ] 測試系列 —— 通過測試驅動開發構建待辦任務專案(二):前端功能和瀏覽器...
在上篇教程中,學院君已經完成了待辦任務專案後端 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-resetpx-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 專案進行持續整合。