Laravel CRUD example with vue3 composition-api & jest unit tests
LufunoMaphwanya
Posted on January 11, 2022
book-store example app
We will be setting up an example laravel app with a vuejs3 frontend as well as typescript and unit tests for our vue components.
1. Set up laravel project
Let's set up new laravel project with new .env file
laravel new laravel-online-books && cd laravel-online-books
cp .env.example .env
.env
APP_NAME="Online books"
...
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=<YOUR_DB>
DB_USERNAME=<YOUR_DB_USER>
DB_PASSWORD=<YOUR_DB_PASSWORD>
...
Run composer install and migrate your database
composer install
php artisan migrate
Set up laravel/ui scaffolding and scaffold ui
composer require Laravel/ui
php artisan ui bootstrap --auth
npm install && npm run dev
Run your laravel starter app
php artisan key:generate
php artisan serve
2. Models
Use artisan to generate our model with migration.
php artisan make:model Book -m
create_book table migration
// database/migrations/202x_xxx_create_books_table.php
public function up()
{
Schema::create('books', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->integer('year');
$table->string('genre');
$table->string('author');
$table->string('publisher');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('books');
}
Book model.
// app/models/book.php
class Book extends Model
{
use HasFactory;
protected $fillable = ['title' , 'year', 'genre',
'author','publisher'];
}
3. Seed the database with test data
you can skip this section but it's nice to have test data to start off.
php artisan make:seeder BookSeeder
// database/seeders/BookSeeder.php
public function run()
{
$authors = ['Terry A', 'Steven Price', 'John Smith'];
$genres = ['Fiction','Non-Fiction','Business','Horror'];
$publishers = ['Publisher A','Publisher B','Publisher C'];
for ($i = 0; $i <= 10; $i++) {
DB::table('books')->insert(
[
'title' => "Book title {$i}",
'year' => rand(1995, 2021),
'genre' => $genres[rand(0, count($genres)-1)],
'author' => $authors[rand(0, count($authors)-1)],
'publisher' => $publishers[rand(0,
count($publishers)-1)]
]);
}
}
Call test data seeder in application database seeder
// database/seeders/DatabaseSeeder.php
public function run()
{
$this->call([
BookSeeder::class
]);
}
Finally, migrate and seed database.
php artisan migrate --seed
3. Controllers and routes
Create the books controller (this will be our API).
php artisan make:controller Api/BookController --resource --api --model=Book
Create resources and leave as is.
php artisan make:resource BookResource
Create requests and update the rules array as follows
php artisan make:request BookRequest
// app/Http/Requests/BookRequest.php
public function rules()
{
return [
'year' => ['required', 'integer'],
'title' => ['required', 'string'],
'genre' => ['required', 'string'],
'author' => ['required', 'string'],
'publisher' => ['required', 'string'],
];
}
Now our controller to support CRUD operations.
App/http/controllers/api/BookController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\BookRequest;
use App\Http\Resources\BookResource;
use App\Models\Book;
class BookController extends Controller
{
/**
* Get all books
**/
public function index()
{
return BookResource::collection(Book::all());
}
/**
* Store a book
**/
public function store(BookRequest $request)
{
$book = Book::create($request->validated());
return new BookResource($book);
}
/**
* Get one book
**/
public function show(Book $book)
{
return new BookResource($book);
}
/**
* Update a book
**/
public function update(BookRequest $request, Book $book)
{
$book->update($request->validated());
return new BookResource($book);
}
/**
* Delete a book
**/
public function destroy(Book $book)
{
$book->delete();
return response()->noContent();
}
}
Finally let's tie our controller to our routes.
// App/routes/api.php
use App\Http\Controllers\Api\BookController;
// ...
Route::apiResource('books', BookController::class);
Now test this by hitting localhost:8000/api/books with your server running.
4. Set up vue3 frontend
I recommend that you use node version 12 as my set up is.
nvm use v12
Install vuejs3, vuejs3-loader, vue-router@next and typescript
npm install --save vue@next vue-router@next vue-loader@next
npm install typescript ts-loader --save-dev
configure typescript as we will be using typescript for our front end modules.
create tsconfig.json
/* tsconfig.json */
{
"compilerOptions":
{
"module": "commonjs",
"strict": true,
"jsx": "preserve",
"moduleResolution": "node"
}
}
Add shims-vue.d.ts file so that typescript can understand our vue files.
// resources/shims-vue.d.ts
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
enable vue loader for our app
// webpack.mix.js
const mix = require('laravel-mix');
mix.ts('resources/js/app.ts', 'public/js')
.vue()
.sass('resources/sass/app.scss', 'public/css')
.sourceMaps();
Now let's configure our vue-router routes
create router/index.ts
// resources/js/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import BookIndex from '../components/books/BookIndex.vue';
import BookShow from '../components/books/BookShow.vue';
const routes = [
{
path: '/home',
name: 'books.index',
component: BookIndex
},
{
path: '/books/:id/show',
name: 'books.show',
component: BookShow,
props: true
},
{ path: "/:pathMatch(.*)", component: { template: "Not found"} }
];
export default createRouter({
history: createWebHistory(),
routes
});
Now let's create our components.
BookIndex.vue - for now we will leave it as empty and focus on set up
// resources/js/components/BookIndex.vue
<template>
<div class="container">
empty
</div>
</template>
BookIndex.vue - for now we will leave it as empty and focus on set up
let's create our components
// resources/js/components/BookIndex.vue
<template>
<div class="container">
empty
</div>
</template>
let's mount our vue app - make sure to have extension as .ts
// resources/js/app.ts
require('./bootstrap');
import { createApp } from "vue";
import router from './router';
import BookIndex from './components/books/BookIndex.vue';
const app = createApp({
components: {
BookIndex,
},
}).use(router).mount('#app')
Lets's tell our laravel routes use use vue-router for url patterns matching /home
// routes/web.php
...
Route::view('/{any}', 'home')
->middleware(['auth'])
->where('any', '.*');
...
Add router-view in our home blade file
replace you're logged in with
...
<router-view />
...
We're all good, now run
npm run dev
and retest your application.
6. Add get components
First we will add our composable books.ts where we will have all our api logic stored.
create resources/js/composables/books.ts
// resources/js/composables/books.ts
import { ref } from 'vue'
import axios from "axios";
export default function useBooks() {
const books = ref([])
const book = ref([])
const getBooks = async () => {
const response = await axios.get('/api/books');
books.value = response.data.data;
}
const getBook = async (id: number) => {
let response = await axios.get('/api/books/' + id)
book.value = response.data.data;
}
return {
books,
book,
getBook,
getBooks
}
}
let's update our components
// resources/js/components/BookIndex.vue
<template>
<div class="container">
<div class="card" style="width: 18rem; float: left; margin: 5px" v-for="book in books" :key="book.id">
<router-link :to="{ name: 'books.show' , params: { id: book.id }}">
<div class="card-body">
<h5 class="card-title text-center">{{ book.title}}</h5>
<h6 class="card-subtitle mb-2 text-muted text-center">{{ book.year}}</h6>
<h6 class="card-subtitle mb-2 text-muted text-center">Author: {{ book.author}}</h6>
<h6 class="card-subtitle mb-2 text-muted text-center">Pblisher: {{ book.publisher}}</h6>
<h6 class="card-subtitle mb-2 text-muted text-center">Genre: {{ book.genre}}</h6>
</div>
</router-link>
</div>
</div>
</template>
<script lang='ts'>
import useBooks from '../../composables/books';
import { onMounted } from 'vue';
export default {
setup() {
const { books, getBooks } = useBooks()
onMounted(getBooks)
return {
books
}
}
}
</script>
// resources/js/components/BookShow.vue
<template>
<div class="container">
<div>
<h2 class="card-subtitle mb-2 text-muted">{{ book.title}}</h2>
<h6 class="card-subtitle mb-2 text-muted">year: {{ book.year}}</h6>
<h6 class="card-subtitle mb-2 text-muted">written by: {{ book.author}}</h6>
<h6 class="card-subtitle mb-2 text-muted">published by: {{ book.publisher}}</h6>
<h6 class="card-subtitle mb-2 text-muted">genre: {{ book.genre}}</h6>
</div>
<div class="row">
<div class="col-12 border">
<div class="card" style="width: 100%; margin: 10px; padding: 10px;" v-for="page in 10" :key="page">
<div class="card-body">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. In varius facilisis dolor,
at porttitor nunc luctus sit amet. In tincidunt orci id mi finibus dapibus. Proin tempus,
lorem eu dapibus luctus, elit ante facilisis nulla, ac tristique augue justo eu turpis.
Donec eu enim a sem malesuada vulputate. In at placerat ex. Nullam tincidunt dolor et magna condimentum,
eu pulvinar lorem dictum. Phasellus venenatis rutrum imperdiet. Aenean eu massa lobortis, condimentum nunc sed,
molestie sem. Integer a interdum libero. Suspendisse mollis vehicula ligula a feugiat. Curabitur non odio sit amet mi
condimentum iaculis. Fusce sed tincidunt sem. Aenean porta viverra neque tristique ultricies.
</div>
</div>
</div>
</div>
</div>
</template>
<script lang='ts'>
import useBooks from '../../composables/books';
import { onMounted } from 'vue';
export default {
props: {
id: {
required: true,
type: String
}
},
setup(props: any) {
const { book, getBook } = useBooks()
onMounted(() => getBook(props.id))
return {
book
}
}
}
</script>
rerun mix and test your application
7. Add vue component tests
We will need the following set up to get our tests running.
- Jest
- Vue-jest and babel-jest
- ts-jest
- vue-test-utils@3
install jest and add test cmd
npm install jest --save-dev
// jest.config.js
module.exports = {
testRegex: 'resources/assets/js/test/.*.spec.js$'
}
// package.json
scripts : {
...
"test": "jest"
}
Add vue-jest and babel-jest:
vue-jest: @vue/vue3-jest for jest 27 and vuejs3
npm install --save-dev @vue/vue3-jest babel-jest
vue-js-test-utils-3:
npm install --save-dev @vue/test-utils@next
Add the following so that we can write our tests in typescript
ts-jest and @types\jest:
npm install --save-dev ts-jest
npm install --save-dev @types/jest
update jest config
// jest.config.js
module.exports = {
"testEnvironment": "jsdom",
testRegex: 'resources/js/tests/.*.spec.ts$',
moduleFileExtensions: [
'js',
'json',
'vue',
'ts'
],
'transform': {
'^.+\\.js$': '<rootDir>/node_modules/babel-jest',
'.*\\.(vue)$': '@vue/vue3-jest',
"^.+\\.tsx?$": "ts-jest"
},
}
Now write your component tests
//resources/js/tests/components/books/BookIndex.spec.ts
import { mount, shallowMount, flushPromises } from "@vue/test-utils";
import BookIndex from "../../../components/books/BookIndex.vue";
import router from "../../../router";
import axios from 'axios';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const fakeBooks = [{ "id": "1", "title": "book1", "subtitle": "hello1", "year": 1938}, { "id": "1", "title": "book1", "subtitle": "hello1", "year": 1938}, { "id": "1", "title": "book1", "subtitle": "hello1", "year": 1938}];
const fakeData = Promise.resolve({"data": {"data": fakeBooks}});
describe("BookIndex.vue", () => {
beforeEach(() => {
})
it("correctly mounts with correct data", async () => {
mockedAxios.get.mockReturnValueOnce(fakeData);
const wrapper = shallowMount(BookIndex, {
global: {
plugins: [router],
}
} as any);
expect(axios.get).toBeCalledWith("/api/books");
await flushPromises();
expect(wrapper.vm.books.length).toBe(3);
});
});
//resources/js/tests/components/books/BookShow.spec.ts
import { mount, shallowMount, flushPromises } from "@vue/test-utils";
import BookShow from "../../../components/books/BookShow.vue";
import router from "../../../router";
import axios from 'axios';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const testId = '3';
const fakeBook = { "id": "3", "title": "book1", "subtitle": "hello1", "year": 1938}
const fakeData = Promise.resolve({"data": fakeBook});
describe("BookShow.vue", () => {
beforeEach(() => {
})
it("correctly mounts with correct data", async () => {
mockedAxios.get.mockReturnValueOnce(fakeData);
const wrapper = shallowMount(BookShow, {
propsData: {
id: testId
},
global: {
plugins: [router],
}
} as any);
expect(axios.get).toBeCalledWith("/api/books/"+testId);
await flushPromises();
expect(wrapper.vm.book.id).toBe(testId);
});
});
this is all you needed for our application, f corse you can write more tests.
npm run test
8. Let's extend our app to support full CRUD ( TDD style )
create component BookCreate
// resources/js/components/books/BookCreate.vue
<template>
<div class="container">
<form @submit.prevent="saveBook">
<div class="form-group">
<label>Title: </label>
<input type="text" class="form-control" placeholder="book title" v-model="form.title">
</div>
<div class="form-group">
<label>Year: </label>
<input type="text" class="form-control" placeholder="book year" v-model="form.year">
</div>
<div class="form-group">
<label>Author: </label>
<select class="form-control" v-model="form.author">
<option v-for="author in authors" :key="author">{{ author }}</option>
</select>
</div>
<div class="form-group">
<label>Publisher: </label>
<select class="form-control" v-model="form.publisher">
<option v-for="publisher in publishers" :key="publisher">{{ publisher }}</option>
</select>
</div>
<div class="form-group">
<label>Genre: </label>
<select class="form-control" v-model="form.genre">
<option v-for="genre in genres" :key="genre">{{ genre }}</option>
</select>
</div>
<div class="form-group"><br/>
<button :disabled="!submittable" type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</template>
<script lang='ts'>
import useBooks from '../../composables/books';
import { reactive, computed } from 'vue';
export default {
setup() {
const { errors, storeBook, authors, publishers, genres } = useBooks();
const form = reactive({
title: '',
author: '',
publisher: '',
genre: '',
year: null
})
const submittable = computed(() => {
return form.title !== '' && form.author !== ''
&& form.publisher !== '' && form.genre !== '' && form.year !== null;
});
const saveBook = async () => {
await storeBook({ ...form })
}
return {
form,
errors,
saveBook,
authors,
publishers,
genres,
submittable
}
}
}
</script>
Let's add a button to take us to this new component
add this to your BookIndex.vue
<template>
<div class="container">
<div class="row">
+ <router-link :to="{ name: 'books.create' }" class="text-sm font-medium nav-link">
+ <button type="button" class="btn btn-primary">Add book</button>
+ </router-link>
</div>
<div class="card" style="width: 18rem; float: left; margin: 5px" v-for="book in books" :key="book.id">
.....
Let's add this to our routes so that we can access it in our app.
// resources/js/router/index.ts
//...
,
{
path: '/books/create',
name: 'books.create',
component: BookCreate,
},
//...
let's now add our create axios call in our composable books.ts and use vue-router to redirect our app to index page on success.
// resources/js/composables/books.ts
import { ref } from 'vue';
import axios from "axios";
import { useRouter } from 'vue-router'; // import vue router
export default function useBooks() {
const books = ref([])
const book = ref([])
const errors = ref('') // stores errors coming from our call so that we can display
const router = useRouter(); // instatiante vue-router
const authors = [ 'Terry A', 'Steven Price', 'John Smith', 'John Kennedy','Bryan Promise', 'Kyle David']; // fake authors options to select from when creating book
const publishers = [ 'Publisher A', 'Publisher B', 'Publisher C', 'Publisher D']; // fake publishers options to select from when creating book
const genres = ['Fiction', 'Non-Fiction', 'Business', 'Horror','Other']; // fake genres options to select from when creating book
const getBooks = async () => {
const response = await axios.get('/api/books');
books.value = response.data.data;
}
const getBook = async (id: number) => {
let response = await axios.get('/api/books/' + id)
book.value = response.data.data;
}
const storeBook = async (data: object) => {
errors.value = ''
try {
await axios.post('/api/books', data)
await router.push({name: 'books.index'}) // redirect app to index page on success
} catch (e: any) {
if (e.response.status === 422) {
errors.value = e.response.data.errors
}
}
}
return {
authors,
publishers,
genres,
errors,
books,
book,
getBook,
getBooks,
storeBook
}
}
Now let's write our component test, you will have noticed our component has a submittable check that checks that all values are filled.
it is implemented as a computed property.
import { ..., computed } from 'vue';
...
const submittable = computed(() => {
return form.title !== '' && form.author !== ''
&& form.publisher !== '' && form.genre !== '' && form.year !== null;
});
anyways this is a good enough feature to write at least 2 test cases - one submittable must be false, and the other vice versa.
jest test cases:
- it allows a user to submit if all values are filled. ```js
it("allows submit when all values are set", async () => {
// set up test component
// fill out all the fields correctly
//assert to be submittable
expect(wrapper.vm.submittable).toBe(true);
});
2. it disallows a user to submit if all values are not filled.
```js
it("disallows submit when all values are not set", async () => {
// set up test component
// fill out all the fields and leave out at least one
//assert to NOT be submittable
expect(wrapper.vm.submittable).toBe(false);
});
Our test
// resources/js/tests/components/books/BookCreate.spec.ts
import { mount, shallowMount, flushPromises } from "@vue/test-utils";
import BookCreate from "../../../components/books/BookCreate.vue";
import router from "../../../router";
describe("BookIndex.vue", () => {
beforeEach(() => {
})
it("allows submit when all values are set", async () => {
const wrapper = shallowMount(BookCreate, {
global: {
plugins: [router],
}
} as any);
await wrapper.find('#title').setValue('test title');
await wrapper.find('#year').setValue(1994);
await wrapper.find('#publisher').setValue('test p');
await wrapper.find('#author').setValue('test a');
await wrapper.find('#genre').setValue('test g');
expect(wrapper.vm.submittable).toBe(true);
});
it("disallows submit when all values are set", async () => {
const wrapper = shallowMount(BookCreate, {
global: {
plugins: [router],
}
} as any);
expect(wrapper.vm.submittable).toBe(false);
});
});
at this point, adding :EDIT and :DELETE functionality to this should be easy enough. but let's go ahead and complete this.
<h6 class="card-subtitle mb-2 text-muted">published by: {{ book.publisher}}</h6>
<h6 class="card-subtitle mb-2 text-muted">genre: {{ book.genre}}</h6>
</div>
+ <div>
+ <router-link id="editBtn" :to="{ name: 'books.edit' , params: { id: `${book.id}` }}">Edit</router-link>
+ <a id="deleteBtn" @click="deleteBook(book)" href="#" role="button">Delete</a>
+ </div>
<div class="row">
<div class="col-12 border">
Lets's add our 2 api calls in our composable
// resources/js/composables/books.ts
/** Edit book **/
const updateBook = async (id: number) => {
errors.value = ''
try {
await axios.put('/api/books/' + id, book.value)
await router.push({name: 'books.index'})
} catch (e: any) {
if (e.response.status === 422) {
errors.value = e.response.data.errors
}
}
}
/** Delete book **/
const removeBook = async (id: number) => {
await axios.delete('/api/books/' + id);
await router.push({name: 'books.index'});
}
...
return {
...
updateBook,
removeBook
}
Update vue router to know about our new route and component
// resources/js/router/index.ts
...
import BookEdit from '../components/books/BookEdit.vue';
...
,
{
path: '/books/:id/edit',
name: 'books.edit',
component: BookEdit,
props: true
},
...
Let's add our new BookEdit component
// resources/js/components/books/BookEdit.vue
<template>
<div class="container">
<form @submit.prevent="editBook">
<div class="form-group">
<label>Title: </label>
<input id="title" type="text" class="form-control" placeholder="book title" v-model="book.title">
</div>
<div class="form-group">
<label>Year: </label>
<input type="text" class="form-control" placeholder="book year" v-model="book.year" id="year">
</div>
<div class="form-group">
<label>Author: </label>
<select class="form-control" v-model="book.author" id="author">
<option v-for="author in authors" :key="author">{{ author }}</option>
</select>
</div>
<div class="form-group">
<label>Publisher: </label>
<select class="form-control" v-model="book.publisher" id="publisher">
<option v-for="publisher in publishers" :key="publisher">{{ publisher }}</option>
</select>
</div>
<div class="form-group">
<label>Genre: </label>
<select class="form-control" v-model="book.genre" id="genre">
<option v-for="genre in genres" :key="genre">{{ genre }}</option>
</select>
</div>
<div class="form-group"><br/>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</template>
<script lang='ts'>
import useBooks from '../../composables/books';
import { onMounted, computed } from 'vue';
export default {
props: {
id: {
required: true,
type: String
}
},
setup(props: any) {
const { errors, authors, publishers, genres, book, getBook, updateBook } = useBooks();
onMounted(() => getBook(props.id))
const editBook = async () => {
await updateBook(props.id);
}
return {
book,
errors,
editBook,
authors,
publishers,
genres
}
}
}
</script>
Let's add an example test for these
BookEdit vue component test
// resources/js/tests/components/books/BookEdit.spec.ts
import { mount, shallowMount, flushPromises } from "@vue/test-utils";
import BookEdit from "../../../components/books/BookEdit.vue";
import router from "../../../router";
import axios from 'axios';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const testId = '3';
const fakeBook = { "id": "3", "title": "book1", "subtitle": "hello1", "year": 1938, 'author': 'A', 'publisher': 'A', 'genre': ''}
const fakeData = Promise.resolve({"data":{"data": fakeBook}});
describe("BookEdit.vue", () => {
beforeEach(() => {
})
it("correctly prepopulates form with correct existing data", async () => {
mockedAxios.get.mockReturnValueOnce(fakeData);
const wrapper = shallowMount(BookEdit, {
propsData: {
id: testId
},
global: {
plugins: [router],
}
} as any);
expect(axios.get).toBeCalledWith("/api/books/"+testId);
await flushPromises();
const titleInputField: HTMLInputElement = wrapper.find('#title').element as HTMLInputElement;
const yearInputField: HTMLInputElement = wrapper.find('#year').element as HTMLInputElement;
const prepopTitle = titleInputField.value;
const prepopYear = yearInputField.value;
expect(prepopTitle).toBe(fakeBook.title);
expect(prepopYear).toBe(`${fakeBook.year}`);
});
});
Now for delete, since it doesn't have it's own component, let's test that we see the delete dialog when we hit delete with the correct book title
Let's append a test case in the BookShow.spec.ts
// resources/js/tests/components/books/BookShow.spec.ts
...
it("shows user delete dialog on delete click.", async () => {
mockedAxios.get.mockReturnValueOnce(fakeData);
window.confirm = jest.fn(); // mock window.confirm implementation
const wrapper = shallowMount(BookShow, {
propsData: {
id: testId
},
global: {
plugins: [router],
}
} as any);
await flushPromises();
const button: HTMLElement = wrapper.find('#deleteBtn').element as HTMLElement;
button.click();
expect(window.confirm).toBeCalledWith(`delete ${fakeBook.title}?`)
});
...
I think this is good enough as a starter, you can assess your test coverage with jest adding the following in your jest config file.
...
collectCoverage: true,
"collectCoverageFrom": [
"resources/js/**/*.{js,jsx}",
"resources/js/**/*.{ts,tsx}",
"resources/js/**/*.vue",
"!resources/js/tests/**/*.*",
"!**/node_modules/**",
"!**/vendor/**"
],
...
and run
npm run test
Final working example:
https://github.com/LufunoMaphwanya/laraVue3-typescript-example
References:
https://laraveldaily.com/laravel-8-vue-3-crud-composition-api/
Posted on January 11, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.