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.
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>
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:
mount
instead of shallowMount
, making this an integration test rather than a unit oneButtonRouterLink
. 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.