Testing Vue components with Laravel Dusk

Published on by

Testing Vue components with Laravel Dusk image

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) =&gt; {
this.tasks.push(response.data);
this.newTask = '';
}).catch((e) =&gt; console.error(e));
},
completeTask(task) {
let status = ! task.is_completed;
axios.put(\`/api/task/${task.id}\`, {
is_completed: status
}).then((response) =&gt; {
task.is_completed = response.data.is_completed
}).catch((e) =&gt; console.error(e));
},
checked(task) {
return task.is_completed;
},
removeTask(index, task) {
axios.delete(\`/api/task/${task.id}\`)
.then((response) =&gt; {
this.tasks = [
...this.tasks.slice(0, index),
...this.tasks.slice(index + 1)
];
}).catch((e) =&gt; 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.

Jeff photo

I'm a full-stack web developer and a part-time writer.

You can find more of my writing on https://medium.com/@jeffochoa.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate logo

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate

Build your SaaS application in hours. Out-of-the-box multi-tenancy and seamless Stripe integration. Supports subscriptions and one-time purchases, allowing you to focus on building and creating without repetitive setup tasks.

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Asymmetric Property Visibility in PHP 8.4 image

Asymmetric Property Visibility in PHP 8.4

Read article
Access Laravel Pulse Data as a JSON API image

Access Laravel Pulse Data as a JSON API

Read article
Laravel Forge adds Statamic Integration image

Laravel Forge adds Statamic Integration

Read article
Transform Data into Type-safe DTOs with this PHP Package image

Transform Data into Type-safe DTOs with this PHP Package

Read article
PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell image

PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell

Read article
Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3 image

Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3

Read article