Testing Vue components with Laravel Dusk
Published on by Jeff
Adding tests to a project is always beneficial for different aspects, but choosing the right strategy could be a struggle for many developers.
The problem multiplies itself when you are using different tools or frameworks, and although “having as many tests as you can” sounds like a good idea, at least in theory, in practice this can be very different.
The following is an interesting article from the Twitter team about their thoughts on Feature Testing.
Taylor Otwell shared Twitter’s article on his Bi-Weekly Laravel Tips newsletter, subscribe if you haven’t done yet.
Let’s build a simple to-do list using Vue.js and Laravel to illustrate how to add Browser testing using Laravel dusk.
How to start?
We can start building a small API to handle CRUD for a task
resource.
namespace App\Http\Controllers; use Carbon\Carbon;use App\Models\Task;use Illuminate\Http\Request; class TaskController extends Controller{ public function store(Request $request) { $request->validate([ 'text' => 'required' ]); return Task::create([ 'text' => $request->text, 'user_id' => auth()->user()->id, 'is_completed' => false ]); } public function destroy(Task $task) { $task->delete(); return response()->json(['message' => 'Task deleted'], 200); } public function update(Task $task) { return tap($task)->update(request()->only(['is_completed', 'text']))->fresh(); }}
A couple of concepts used on this controller:
Now, let’s add the routes to the routes/api.php
file, to handle each one of these actions.
Route::resource('task', 'TaskController')->only('store', 'destroy', 'update');
And this is how the tasks migration file looks like:
use Illuminate\Support\Facades\Schema;use Illuminate\Database\Schema\Blueprint;use Illuminate\Database\Migrations\Migration; class CreateTasksTable extends Migration{ public function up() { Schema::create('tasks', function (Blueprint $table) { $table->increments('id'); $table->text('text'); $table->timestamp('is_completed')->boolean()->default(false); $table->integer('user_id')->unsigned(); $table->foreign('user_id')->references('id')->on('users'); $table->timestamps(); }); } public function down() { Schema::dropIfExists('tasks'); }}
Endpoint testing
In this case, we are using a lot of things that laravel provide us right out of the box, so, there’s no need to test anything (for now) in isolation. Instead, we can use an “endpoint test” which makes more sense, given the fact that we are trying to build an API.
namespace Tests\Feature\API; use Tests\TestCase;use App\Models\User;use App\Models\Task;use Illuminate\Foundation\Testing\WithFaker;use Illuminate\Foundation\Testing\RefreshDatabase;use Illuminate\Foundation\Testing\DatabaseMigrations; class TasksTest extends TestCase{ use DatabaseMigrations; /** @test */ public function user_can_create_tasks() { $user = factory(User::class)->create(); $task = [ 'text' => 'New task text', 'user_id' => $user->id ]; $response = $this->actingAs($user)->json('POST', 'api/task', $task); $response->assertStatus(201); $this->assertDatabaseHas('tasks', $task); } /** @test */ public function guest_users_can_not_create_tasks() { $task = [ 'text' => 'new text', 'user_id' => 1 ]; $response = $this->json('POST', 'api/task', $task); $response->assertstatus(401); $this->assertDatabaseMissing('tasks', $task); } /** @test */ public function user_can_delete_tasks() { $user = factory(User::class)->create(); $task = factory(Task::class)->create([ 'text' => 'task to delete', 'user_id' => $user->id ]); $response = $this->actingAs($user)->json('DELETE', "api/task/$task->id"); $response->assertstatus(200); $this->assertDatabaseMissing('tasks', ['id' => $task->id]); } /** @test */ public function user_can_complete_tasks() { $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' => true]); $response->assertstatus(200); $this->assertNotNull($task->fresh()->is_completed); }}
You’ll need to create the TaskFactory::class
using artisan:
$ php artisan make:factory TaskFactory
And this is how it looks:
use Faker\Generator as Faker;use Carbon\Carbon; $factory->define(App\Models\Task::class, function (Faker $faker) { return [ 'text' => $faker->sentence(6), 'is_completed' => null ];});
At this point you should be able to run the test suite and get a positive response:
$ phpunit PHPUnit 6.2.4 by Sebastian Bergmann and contributors. .... 4 / 4 (100%) Time: 328 ms, Memory: 24.00MB OK (4 tests, 8 assertions)
Creating a new tasks component using Vue
We can use a single file component here. For readability reasons, I’ll split the code into two different blocks, but be aware that both belong to the same file.
The TasksComponent.vue
file
<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>
I’m using the tailwindcss framework, wait to see the results.
The
dusk=""
attribute works as a custom selector to be used by laravel dusk to interact with the HTML elements on the page.
The script section:
export default { props: ['initial-tasks'], 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)); } }}
The Homepage
You can create a home.blade.php
file in the views/
directory with the following code:
@extends('layouts.app') @section('body') <div class="container px-4 sm:px-0 mx-auto py-8"> </div>@endsection
This page extends a basic layout view
<!doctype html><html lang="{{ app()->getLocale() }}"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="csrf-token" content="{{ csrf_token() }}"> <link rel="stylesheet" href="{{ mix('css/app.css') }}"> <title>{{ config('app.name', 'Laravel') }}</title> </head> <body class="font-sans antialiased text-black leading-tight"> <div id="app"> @yield('body') </div> <a href="http://%20mix('js/app.js')%20">http://%20mix('js/app.js')%20</a> </body></html>
Don’t forget to create a route to return this view:
Route::get('/', function () { $tasks = ; return view('home', [ 'tasks' => auth()->user()->tasks->all() ]);});
Compiling the assets
We can easily compile all the assets using larave mix:
let mix = require('laravel-mix')require('laravel-mix-purgecss') mix.js('resources/assets/js/app.js', 'public/js') .postCss('resources/assets/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();
Don’t forget to run the npm
task to compile the assets:
$ npm run dev
Testing the component
Here is when everything becomes interesting. How can we be sure that everything is working fine?
Well, we have already a couple of tests in place for the API, but that is not very useful on this case because we could have a 500
error on the homepage and those tests are going to pass.
We can’t just use phpunit
to test this component because the test relies on the browser and JavaScript to test asynchronous code.
Once the page loads, Vue is going to render the <template></template>
on the homepage.
In this case, browser testing looks like the right way to go.
Browser testing with Laravel Dusk
Laravel Dusk provides an expressive, easy-to-use browser automation and testing API
The first step should be, to install Larave dusk. You can do it by following the instructions on the official docs.
If you need to use a database within your browser tests with dusk, don’t use an in memory database.
https://twitter.com/StephCoinon/status/962862247612768256
Creating a new browser-test file
You can do this using artisan:
$ php artisan dusk:make TasksTest
Now add the logic to test the task
CRUD, simulating the interaction of a new user in the page:
namespace Tests\Browser; use App\Models\User;use App\Models\Task;use Tests\DuskTestCase;use Laravel\Dusk\Browser;use Laravel\Passport\Passport;use Illuminate\Foundation\Testing\DatabaseMigrations;use Illuminate\Foundation\Testing\DatabaseTransactions; class DashboardTest extends DuskTestCase{ use DatabaseMigrations; protected $user; protected function setUp() { parent::setUp(); $this->user = factory(User::class)->create(); } /** @test */ public function create_tasks() { $this->browse(function (Browser $browser) { $browser->loginAs($this->user) ->visit('/') ->assertSee('Tasks'); $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']); }); } /** @test */ public function remove_tasks() { $task = factory(Task::class)->create(['user_id' => $this->user->id]); $this->browse(function (Browser $browser) { $browser ->loginAs($this->user) ->visit('/') ->waitForText('Tasks'); $browser->click("@remove-task1") ->pause(500) ->assertDontSee('test task'); }); $this->assertDatabaseMissing('tasks', $task->only(['id', 'text'])); } /** @test */ public function complete_tasks() { $task = factory(Task::class)->create(['user_id' => $this->user->id]); $this->browse(function (Browser $browser) use ($task) { $browser ->loginAs($this->user) ->visit('/') ->waitForText('Tasks') ->click("@check-task{$task->first()->id}") ->waitFor('.line-through'); }); $this->assertNotEmpty($task->fresh()->is_completed); }}
Remember that you are dealing with javascript elements and asynchronous request so that you can use methods like waitForText('some text')
, waitFor('.some-selector')
, pause($ms = 500)
to “pause” the execution until the backend response come back, and the DOM get’s updated by the Vue component.
The selector @[selector-name]
makes reference to the dusk=""
attribute on any HTML tag in your Vue component.
Now you can run the browser tests suite.
$php artisan dusk PHPUnit 7.0.0 by Sebastian Bergmann and contributors. ... 3 / 3 (100%) Time: 24.57 seconds, Memory: 16.00MB OK (3 tests, 8 assertions)
Another cool feature that you have with laravel dusk is the possibility to see the last state of the page when an error occurs.
Dusk stores a screenshot of every failure in the Tests/Browser/screenshots
folder.
Final conclussions
Think for a moment how much coverage are you making with these browser tests:
- Testing API endpoint
- Testing Controllers
- Testing Javascript/Vue components and behavior
- Testing authentication
So, how do I recommend Laravel developers get started testing? Start testing the entire request/response cycle for a given feature without mocks. – Taylor Otwell
Of course, this doesn’t mean that you should be using only “browser tests,” the idea is to think and apply the better strategy for you, the one that brings the best results and adds the most value to your application.
The different testing strategies that you can apply are not mutually excluding, if you think that your project needs browser testing, unit testing, feature testing, etc. go for it, this is not a written rule.
In any case, if you are using Laravel, with Vue or any other Javascript framework (even vanilla javascript) and you are not a Javascript expert, but you know PHP, at least in my estimation, this is the easiest way to start.
To learn more about Laravel Dusk, please read the official docs here.
I'm a full-stack web developer and a part-time writer.
You can find more of my writing on https://medium.com/@jeffochoa.