Search code examples
vue.jstestingvuejs3vitevitest

Mocking Vue RouterView/RouterLink in Vitest (Composition API)


As per the title, is this possible at all?

I'm trying to test a simple component of a Vue App, which contains a heading and a button that directs user to the next page. I want the test that once the buttons is clicked an event/message is sent to the router, and check that the destination route is correct.

The structure of the app is:

App.Vue - the entry component

<script setup lang="ts">
import { RouterView } from "vue-router";
import Wrapper from "@/components/Wrapper.vue";
import Container from "@/components/Container.vue";
import HomeView from "@/views/HomeView.vue";
</script>

<template>
  <Wrapper>
    <Container>
      <RouterView/>
    </Container>
  </Wrapper>
</template>

<style>

  body{
    @apply bg-slate-50 text-slate-800 dark:bg-slate-800 dark:text-slate-50;
  }

</style>

router/index.ts - Vue Router config file

import {createRouter, createWebHistory} from "vue-router";
import HomeView from "@/views/HomeView.vue";

export enum RouteNames {
    HOME = "home",
    GAME = "game",
}

const routes = [
    {
      path: "/",
      name: RouteNames.HOME,
      component: HomeView,
      alias: "/home"
    },
    {
      path: "/game",
      name: RouteNames.GAME,
      component: () => import("@/views/GameView.vue"),
    },
];

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

export default router;

HomeView.Vue - the entry view for the RouterView in the App component

<template>
  <Header title="My App"/>
  <ButtonRouterLink :to="RouteNames.GAME" text="Start" class="btn-game"/>
</template>

<script setup>
  import Header from "@/components/Header.vue";
  import ButtonRouterLink from "@/components/ButtonRouterLink.vue";
  import {RouteNames} from "@/router/";
</script>

ButtonRouterLink.vue

<template>
<RouterLink v-bind:to="{name: to}" class="btn">
  {{ text }}
</RouterLink>
</template>

<script setup>
import { RouterLink } from "vue-router";

const props = defineProps({
  to: String,
  text: String
})
</script>

Considering it's using CompositionAPI, TypeScript, is there any way to mock the router in Vitest tests?

Here's an example of the test file structure (HomeView.spec.ts):

import {shallowMount} from "@vue/test-utils";
import { describe, test, vi, expect } from "vitest";
import { RouteNames } from "@/router";
import HomeView from "../../views/HomeView.vue";

describe("Home", () => {
    test ('navigates to game route', async () => {
        // Check if the button navigates to the game route

        const wrapper = shallowMount(HomeView);

        await wrapper.find('.btn-game').trigger('click');

        expect(MOCK_ROUTER.push).toHaveBeenCalledWith({ name: RouteNames.GAME });
    });
});

I've tried multiple ways: vi.mock('vue-router'), vi.spyOn(useRoute,'push'), vue-router-mock library and I haven't been able to get the tests to run, always seems like the router just isn't there.

Update (15/04/23)

Following @tao 's advice, I realised that testing the click in HomeView is not a very good test (testing libraries rather than my app), so I added a test for ButtonRouterLink to see if it renders a Vue RouterLink correctly, using the to param:

import {mount, shallowMount} from "@vue/test-utils";
import { describe, test, vi, expect } from "vitest";
import { RouteNames } from "@/router";
import ButtonRouterLink from "../ButtonRouterLink.vue";

vi.mock('vue-router');

describe("ButtonRouterLink", () => {
    test (`correctly transforms 'to' param into a router-link prop`, async () => {
       
        const wrapper = mount(ButtonRouterLink, {
            props: {
                to: RouteNames.GAME
            }
        });

        expect(wrapper.html()).toMatchSnapshot();
    });
});

Which renders an empty HTML string ""

exports[`ButtonRouterLink > correctly transforms 'to' param into a router-link prop 1`] = `""`;

accompanied by a rather unhelpful Vue warning (no expected types specified) :

[Vue warn]: Invalid prop: type check failed for prop "to". Expected , got Object  
  at <RouterLink to= { name: 'game' } class="btn btn-blue" > 
  at <ButtonRouterLink to="game" ref="VTU_COMPONENT" > 
  at <VTUROOT>
[Vue warn]: Invalid prop: type check failed for prop "ariaCurrentValue". Expected , got String with value "page". 
  at <RouterLink to= { name: 'game' } class="btn btn-blue" > 
  at <ButtonRouterLink to="game" ref="VTU_COMPONENT" > 
  at <VTUROOT>
[Vue warn]: Component is missing template or render function. 
  at <RouterLink to= { name: 'game' } class="btn btn-blue" > 
  at <ButtonRouterLink to="game" ref="VTU_COMPONENT" > 
  at <VTUROOT>

Solution

  • It's possible.

    There is one important difference between your example and the official one: instead of using RouterLink (or a button which uses router.push), inside the component being tested, you've placed it in an intermediary component.

    How does this change the unit test?

    The first problem is you're using shallowMount, which replaces child components with empty containers. In your case, this means ButtonRouterLink no longer contains a RouterLink and doesn't do anything with the click. It only receives it.

    The options to get past this are:

    • a) use mount instead of shallowMount, making this an integration test rather than a unit one
    • b) move the test for this functionality inside a test for ButtonRouterLink. Once you test that any :to of a ButtonRouterLink is correctly transformed into a { name: to } and passed to a RouterLink, all you need to test is the :to's are passed correctly to <ButtonRouterLink /> in any component using them.

    The second problem moving this functionality in its own component creates is a bit more subtle. Normally, I was expecting the following ButtonRouterLink test to have worked:

    import { RouterLinkStub } from '@vue/test-utils'
    
    const wrapper = shallowMount(YourComp, {
      stubs: {
        RouterLink: RouterLinkStub
      }
    })
    
    expect(wrapper.findComponent(RouterLinkStub).props('to')).toEqual({
      name: RouterNames.GAME
    })
    

    To my surprise, the test was failing, claiming the received :to prop value in the RouterLinkStub was not { name: RouterNames.GAME }, but 'game', which is what I was passing to ButtonRouterLink. It was like our component never made the conversion from value to { name: value }.

    Quite strange.
    Turns out the problem was that <RouterLink /> happens to be the root element of our component. Which means the component (<BottomRouterLink>) was being replaced in DOM with a <RouterLinkStub>, but the value of :to was read from the first, not from the latter. In short, the test would have worked if the template was:

      <span>
        <RouterLink ... />
      </span>
    

    To get past this, we need to import the actual RouterLink component (from vue-router) and find it instead of finding RouterLinkStub. Why? @vue/test-utils is smart enough to find a stub's component instead of the actual one but, this way, .findComponent() would only match the element after the replacement, not when it was still a ButtonRouterLink (unprocessed). At that point, :to's value has been parsed into what RouterLink is actually receiving.

    Full test looks like this:

    import { shallowMount } from '@vue/test-utils'
    import { describe, it, expect } from 'vitest'
    import ButtonRouterLink from '../src/ButtonRouterLink.vue'
    import { RouterLinkStub } from '@vue/test-utils'
    import { RouterLink } from 'vue-router'
    
    const TEST_STRING = 'this is a test string'
    
    describe('ButtonRouterLink', () => {
      it(`should transform 'to' into '{ name: to }'`, () => {
        const wrapper = shallowMount(ButtonRouterLink, {
          props: {
            to: TEST_STRING
          },
          stubs: {
            RouterLink: RouterLinkStub
          }
        })
    
        expect(wrapper.findComponent(RouterLink).props('to')).toEqual({
          name: TEST_STRING
        })
      })
    })
    

    Tricky one.