Search code examples
typescriptvuejs3vue-test-utilsvue-composition-apivue-router4

Vue test utils with Vue-router4 and Vue3 Composition API


I am unable to mount a component during unit testing due to the the route object being undefined in the setup method during mounting. The guides seem aimed at Vue2 and the options API

References:

How to write test that mocks the $route object in vue components
How to unit testing with jest in vue composition api component?
https://vue-test-utils.vuejs.org/guides/#using-with-typescript
https://vue-test-utils.vuejs.org/guides/#using-with-vue-router

Error

● CoachItem.vue › displays alert when item is clicked

    TypeError: Cannot read property 'path' of undefined

      64 |       fullName: computed(() => props.firstName + " " + props.lastName),
      65 |       coachContactLink: computed(
    > 66 |         () => route.path + "/" + props.id + "/contact"

// @/tests/unit/example.spec.ts

import CoachItem from "@/components/coaches/CoachItem.vue"
import router from "@/router"


  describe("CoachItem.vue", () => {
    it("displays alert when item is clicked", async () => {

      //const route = { path: 'http://www.example-path.com' }
      router.push('/')
      await router.isReady()
      const wrapper = mount(CoachItem); //adding this line causes failure
      //await wrapper.trigger('click');
      //const dialog = wrapper.find('dialog');
      //(dialog.exists()).toBeTruthy()
    })
  })
// @/components/UserAlert.vue

<template>
  <div class="backdrop" @click="closeDialog"></div>
  <dialog open>
    <header>
      <h2>{{ title }}</h2>
    </header>
    <div>
      <slot name="content"></slot>
    </div>
    <menu>
      <button @click="closeDialog">Close</button>
    </menu>
  </dialog>
</template>

<script lang="ts>
import { defineComponent } from "vue";

export default defineComponent({
  props: ['title'],
  emits: ['close'],
  setup(_, context) {
    function closeDialog() {
      context.emit('close');
    }

    return { closeDialog };
  },
});
</script>
// @/components/coaches.CoachItem.vue

<template>
<user-alert v-if="alertIsVisible" title="Alert!" @close="hideAlert">
    <template v-slot:content><p>this is a slot</p></template>
  </user-alert>
  <li @click="showAlert">
    <h3>{{ fullName }}</h3>
    <h4>${{ rate }}/hour</h4>
    <div>
      <base-badge
        v-for="area in areas"
        :key="area"
        :type="area"
        :title="area"
      ></base-badge>
    </div>
    <div class="actions">
      <base-button mode="outline" link :to="coachContactLink"
        >Contact</base-button
      >
      <base-button link :to="coachDetailsLink">View Details</base-button>
    </div>
  </li>
</template>

<script lang="ts">
import { computed, defineComponent, PropType, ref } from "vue";
import { useRoute } from "vue-router";
import useAlert from "../../components/hooks/useAlert";
export default defineComponent({
  props: {
    id: {
      type: String,
      required: true,
    },
    firstName: {
      type: String,
      required: true,
    },
    lastName: {
      type: String,
      required: true,
    },
    rate: {
      type: Number,
      required: true,
    },
    areas: {
      type: Object as PropType<Array<string>>,
      required: true,
    },
  },
  setup(props) {
    const route = useRoute();
    const alertTitle = ref("delete user?");
    return {
      fullName: computed(() => props.firstName + " " + props.lastName),
      coachContactLink: computed(
        () => route.path + "/" + props.id + "/contact"
      ),
      coachDetailsLink: computed(() => route.path + "/" + props.id),
      ...useAlert()
    };
  },
});
</script>
// @/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import {store, key }  from "./store";
import UserAlert from "@/components/UserAlert.vue";

createApp(App)
.component('UserAlert', UserAlert)
  .use(store, key)
  .use(router)
  .mount("#app");
// @/router/index.ts

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
});

export default router;

Solution

  • Going via the examples in vitest issue #1918 and the vue-test-utils composition documentation

    The following mock allows a component with useRouter or useRoute to work:

    import { mount } from '@vue/test-utils'
    import { expect, it, vi } from 'vitest'
    import CompWithRoute from './CompWithRoute.vue'
    
    vi.mock('vue-router', () => {
      return {
        useRouter: vi.fn(() => ({
          push: vi.fn(),
        })),
        useRoute: vi.fn(()=> ({
          fullPath: '',
          hash: '',
          matched: [],
          meta: {},
          name: undefined,
          params: {},
          path: '/guppies',
          query: {
            search: 'ireland'
          },
          redirectedFrom: undefined,
        }))
      }
    })
    
    it('should render the route loving component', () => {
      const wrapper = mount(CompWithRoute)
    })
    
    

    If the call needs to be tested, mockImplementationOnce can inject a spy (although typescript doesn't like the lax implementation of the Router mock)

    import { mount } from '@vue/test-utils'
    import { afterEach, expect, it, vi } from 'vitest'
    import * as routerExports from 'vue-router'
    import CompWithRoute from './CompWithRoute.vue'
    
    const useRouter = vi.spyOn(routerExports, 'useRouter')
    
    afterEach(() => {
      vi.clearAllMocks()
    })
    
    it('should push a new route on search', async () => {
      const push = vi.fn()
      useRouter.mockImplementationOnce(() => ({
        push
      }))
      const wrapper = mount(CompWithRoute)
      const search = wrapper.find('#search-input')
      await search.setValue('ireland')
      await search.trigger('keyup.enter')
      expect(push).toHaveBeenCalledWith({ query: { search: 'ireland'} })
    })
    

    It's also possible to inject a global spy into the hoisted mock Router implementation and reference that in your expectations.

    const mock_push = vi.fn()
    vi.mock('vue-router', () => ({
      useRouter: vi.fn(() => ({
        push: mock_push,
      })),
    }))
    
    afterEach(() => {
      vi.clearAllMocks()
    })
    
    it('should push a new route on search', async () => {
      const wrapper = mount(CompWithRoute)
      const search = wrapper.find('#search-input')
      await search.setValue('ireland')
      await search.trigger('keyup.enter')
      expect(mock_push).toHaveBeenCalledWith({ query: { search: 'ireland'} })
    })