DEV Community

LufunoMaphwanya
LufunoMaphwanya

Posted on

Laravel CRUD example with vue3 composition-api & jest unit tests

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


Enter fullscreen mode Exit fullscreen mode

.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>

...


Enter fullscreen mode Exit fullscreen mode

Run composer install and migrate your database



composer install
php artisan migrate


Enter fullscreen mode Exit fullscreen mode

Set up laravel/ui scaffolding and scaffold ui



composer require Laravel/ui     
php artisan ui bootstrap --auth
npm install && npm run dev 


Enter fullscreen mode Exit fullscreen mode

Run your laravel starter app



php artisan key:generate
php artisan serve


Enter fullscreen mode Exit fullscreen mode

2. Models

Use artisan to generate our model with migration.



php artisan make:model Book -m


Enter fullscreen mode Exit fullscreen mode

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');
}


Enter fullscreen mode Exit fullscreen mode

Book model.



// app/models/book.php
class Book extends Model
{
    use HasFactory;
    protected $fillable = ['title' , 'year', 'genre',                                                   
                           'author','publisher'];
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode


// 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)]
         ]);
   }
}



Enter fullscreen mode Exit fullscreen mode

Call test data seeder in application database seeder



// database/seeders/DatabaseSeeder.php

public function run()
{
   $this->call([
       BookSeeder::class
   ]);
}


Enter fullscreen mode Exit fullscreen mode

Finally, migrate and seed database.



php artisan migrate --seed


Enter fullscreen mode Exit fullscreen mode

3. Controllers and routes

Create the books controller (this will be our API).



php artisan make:controller Api/BookController --resource --api --model=Book


Enter fullscreen mode Exit fullscreen mode

Create resources and leave as is.



php artisan make:resource BookResource 


Enter fullscreen mode Exit fullscreen mode

Create requests and update the rules array as follows



php artisan make:request BookRequest 


Enter fullscreen mode Exit fullscreen mode


// app/Http/Requests/BookRequest.php
public function rules()
{
    return [
        'year' => ['required', 'integer'],
        'title' => ['required', 'string'],
        'genre' => ['required', 'string'],
        'author' => ['required', 'string'],
        'publisher' => ['required', 'string'],
    ];
}



Enter fullscreen mode Exit fullscreen mode

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();
    }
}



Enter fullscreen mode Exit fullscreen mode

Finally let's tie our controller to our routes.



// App/routes/api.php

use App\Http\Controllers\Api\BookController;

// ... 

Route::apiResource('books', BookController::class);


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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"
    }
}


Enter fullscreen mode Exit fullscreen mode

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
 }


Enter fullscreen mode Exit fullscreen mode

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();


Enter fullscreen mode Exit fullscreen mode

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
});


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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')



Enter fullscreen mode Exit fullscreen mode

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', '.*');
...


Enter fullscreen mode Exit fullscreen mode

Add router-view in our home blade file
replace you're logged in with



...
   <router-view />
...



Enter fullscreen mode Exit fullscreen mode

We're all good, now run



npm run dev


Enter fullscreen mode Exit fullscreen mode

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
    }
}


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode


// 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>   



Enter fullscreen mode Exit fullscreen mode

rerun mix and test your application

7. Add vue component tests

We will need the following set up to get our tests running.

  1. Jest
  2. Vue-jest and babel-jest
  3. ts-jest
  4. vue-test-utils@3

install jest and add test cmd



npm install jest --save-dev


Enter fullscreen mode Exit fullscreen mode


// jest.config.js

module.exports = {
  testRegex: 'resources/assets/js/test/.*.spec.js$'
}


Enter fullscreen mode Exit fullscreen mode


// package.json

scripts : {
    ...
    "test": "jest"
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

vue-js-test-utils-3:



npm install --save-dev @vue/test-utils@next


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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"
    },
  }


Enter fullscreen mode Exit fullscreen mode

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);
  });

});


Enter fullscreen mode Exit fullscreen mode


//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);
  });

});



Enter fullscreen mode Exit fullscreen mode

this is all you needed for our application, f corse you can write more tests.



npm run test


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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">
        .....


Enter fullscreen mode Exit fullscreen mode

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,
    },

//...


Enter fullscreen mode Exit fullscreen mode

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
    }
}



Enter fullscreen mode Exit fullscreen mode

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;
        });


Enter fullscreen mode Exit fullscreen mode

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:

  1. 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);
  });


Enter fullscreen mode Exit fullscreen mode

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);
  });
});



Enter fullscreen mode Exit fullscreen mode

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>&nbsp;
 +           <a id="deleteBtn" @click="deleteBook(book)" href="#" role="button">Delete</a>&nbsp;
 +       </div>


       <div class="row">
           <div class="col-12 border">


Enter fullscreen mode Exit fullscreen mode

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
}



Enter fullscreen mode Exit fullscreen mode

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
    },
...


Enter fullscreen mode Exit fullscreen mode

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>



Enter fullscreen mode Exit fullscreen mode

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}`);
  });

});



Enter fullscreen mode Exit fullscreen mode

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}?`)
  });

...



Enter fullscreen mode Exit fullscreen mode

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/**"
      ],
      ...


Enter fullscreen mode Exit fullscreen mode

and run



npm run test

Enter fullscreen mode Exit fullscreen mode




Final working example:

https://github.com/LufunoMaphwanya/laraVue3-typescript-example

References:

https://laraveldaily.com/laravel-8-vue-3-crud-composition-api/

https://next.vue-test-utils.vuejs.org/guide/

Top comments (1)

Collapse
 
rafalg profile image
Rafał

You might want to try going through the tutorial, there are some errors to correct. I've just finished step 7 and there were two problems so far:

  • resources/js/components/BookIndex.vue - this should actually be resources/js/components/books/BookIndex.vue, that was a fairly easy fix
  • const fakeData = Promise.resolve({"data": fakeBook}); - this should be wrapped in additional "data", without it one of the tests fails. That took a while longer to figure out.

Great that there's a git repo with the final result, but we should be able to follow the tutorial and build the result step-by-step.