後悔しないためのVueコンポーネント設計
posted with ヨメレバ
中島直博 インプレスR&D 2019年06月
なにをテストするか
テストの対象
筆者は以下をコンポーネントのテスト項目としている。
- templateでv-on, v-bind:class, v-bind:attrsが正しく動作するか
- propsが正しく受け取れるか
- methodsが正しく動作するか
- $emitでイベントが正しく発火するか
- slotが正しく動作するか
テストを書く
vue-test-utils
- テスト実行環境の準備
npx vue create xxxでのプロジェクト作成時にUnit Testing⇒Jestを有効にし、npm run test:unitで実行 - vue-test-utilsではコンポーネントをmount()かshallowMount()でマウントする。
- mount: 子コンポーネントに実際のコンポーネントを使う。
- shallowMount: 子コンポーネントにダミーコンポーネントを使う。
- 基本的にはshallowMountを使う。 子コンポーネントの動作まで検証が必要な場合のみmountを使う。
const wrapper = shallowMount(Hoge, { propsData })
- shallowMountはWrapperオブジェクトを返す。
- Wrapperは以下のメソッドを持っている。
- props(), setProps(): propsのゲッターとセッター。
- emitted(): $emit()されたイベントと渡された引数を返す。
- find(), findAll(): DOMノードを取得する。
- findComonent: コンポーネントを取得。
basicのテスト: SiteTitle
<template>
<div class="SiteTitle">
{{title}}
</div>
</template>
<script>
export default {
name: "SiteTitle",
props: {
title: String,
},
}
</script>
import { shallowMount } from "@vue/test-utils"
import SiteTitle from "@/basics/SiteTitle.vue"
describe("SiteTitle.vue", () => {
const propsData = {
title: "test title",
}
it("props", () => {
const wrapper = shallowMount(SiteTitle, { propsData })
expect(wrapper.props()).toEqual(propsData)
})
describe("template", () => {
it("snapshot", () => {
const wrapper = shallowMount(SiteTitle, { propsData })
expect(wrapper.vm.$el).toMatchSnapshot()
})
})
})
componentのテスト: MenuItem
<template>
<div class="MenuItem">
<span @click="clickMenuItem" >{{ label }}</span>
</div>
</template>
<script>
export default {
name: "MenuItem",
props: {
label: String,
name: String,
},
methods: {
clickMenuItem() {
this.$emit("clickMenuItem", {name: this.name})
},
},
}
</script>
import { shallowMount } from "@vue/test-utils"
import MenuItem from "@/components/Menu/MenuItem.vue"
import menuItems from "../../_mockData/menuItems.json"
describe("MenuItem.vue", () => {
const propsData = menuItems[0]
it("props", () => {
const wrapper = shallowMount(MenuItem, { propsData })
expect(wrapper.props()).toEqual(propsData)
})
describe("methods", () => {
it("clickMenuItem", () => {
const wrapper = shallowMount(MenuItem, { propsData })
wrapper.vm.clickMenuItem()
expect(wrapper.emitted("clickMenuItem")).toBeTruthy()
expect(wrapper.emitted("clickMenuItem")[0][0]).toEqual({
name: propsData.name,
})
})
})
describe("template", () => {
it("snapshot", () => {
const wrapper = shallowMount(MenuItem, { propsData })
expect(wrapper.vm.$el).toMatchSnapshot()
})
})
})
componentのテスト: MainMenu
- MainMenuは子コンポーネントとしてMenuItemを使用しているため、shallowMountではなくmountを使用する。
<template>
<div class="Menu">
<MenuItem class="Menu_Item" v-for="item in items" :key="item.label" v-bind="item" @clickMenuItem="onClickMenuItem" />
</div>
</template>
<script>
import MenuItem from "./Menu/MenuItem.vue"
export default {
name: "MainMenu",
components: {
MenuItem,
},
props: {
items: Array,
},
methods: {
onClickMenuItem({name}) {
this.$emit("clickMenuItem", {name})
},
},
}
</script>
import { mount } from "@vue/test-utils"
import Menu from "@/components/MainMenu.vue"
import MenuItem from "@/components/Menu/MenuItem.vue"
import menuItems from "../_mockData/menuItems.json"
describe("MainMenu.vue", () => {
const propsData = {
items: menuItems,
}
it("props", () => {
const wrapper = shallowMount(Menu, { propsData })
expect(wrapper.props()).toEqual(propsData)
})
describe("methods", () => {
it("clickMenuItem", () => {
const wrapper = mount(Menu, { propsData })
wrapper.vm.onClickMenuItem(menuItems[0])
expect(wrapper.emitted("clickMenuItem")).toBeTruthy()
expect(wrapper.emitted("clickMenuItem")[0][0]).toEqual({
name: menuItems[0].name,
})
})
})
describe("template", () => {
it("snapshot", () => {
const wrapper = mount(Menu, { propsData })
expect(wrapper.vm.$el).toMatchSnapshot()
})
})
// mountによる結合テスト
it("@clickMenuItem=onClickMenuItem", () => {
const wrapper = mount(Menu, { propsData })
const menuItem = wrapper.findComponent(MenuItem)
menuItem.vm.clickMenuItem()
expect(menuItem.emitted("clickMenuItem")).toBeTruthy()
expect(menuItem.emitted("clickMenuItem")[0][0]).toEqual({
name: menuItems[0].name,
})
})
})
containerのテスト: GlobalHeader
- VuexをテストするためにlocalVueを使う。
<template>
<header class="GlobalHeader">
<SiteLogo class="GlobalHeader_Logo"/>
<span class="GlobalHeader_SiteTitle" @click="navigateRoot" >
<SiteTitle :title="siteTitle"/>
</span>
<MainMenu class="GlobalHeader_Menu" :items="menuItems" @clickMenuItem="onClickMenuItem" />
</header>
</template>
<script>
import {mapState} from "vuex"
import SiteLogo from "../basics/SiteLogo.vue"
import SiteTitle from "../basics/SiteTitle.vue"
import MainMenu from "../components/MainMenu.vue"
export default {
name: "GlobalHeader",
components: {
SiteLogo,
SiteTitle,
MainMenu,
},
computed: {
...mapState(["siteTitle", "menuItems"]),
},
methods: {
onClickMenuItem({name}) {
this.$emit("navigate", {name})
},
navigateRoot() {
this.$emit("navigate", {name: "root"})
},
},
}
</script>
import { shallowMount, mount, createLocalVue } from "@vue/test-utils"
import GlobalHeader from "@/containers/GlobalHeader.vue"
import Menu from "@/components/MainMenu.vue"
import Vuex from "vuex"
import menuItems from "../_mockData/menuItems.json"
const localVue = createLocalVue()
localVue.use(Vuex)
describe("GlobalHeader.vue", () => {
let store
// 毎テストケース実行前にstoreを初期化する
beforeAll(() => {
store = new Vuex.Store({
state: {
siteTitle: "test site title",
menuItems,
},
})
})
describe("methods", () => {
it("clickMenuItem", () => {
// Vueインスタンス作成時にstoreとlocalVueを渡す
const wrapper = shallowMount(GlobalHeader, { store, localVue })
wrapper.vm.onClickMenuItem(menuItems[0])
expect(wrapper.emitted("navigate")).toBeTruthy()
expect(wrapper.emitted("navigate")[0][0]).toEqual({
name: menuItems[0].name,
})
})
it("navigateRoot", () => {
const wrapper = shallowMount(GlobalHeader, { store, localVue })
wrapper.vm.navigateRoot()
expect(wrapper.emitted("navigate")).toBeTruthy()
expect(wrapper.emitted("navigate")[0][0]).toEqual({
name: "root",
})
})
})
describe("template", () => {
it("@click=navigateRoot", () => {
const wrapper = shallowMount(GlobalHeader, { store, localVue })
wrapper.find(".GlobalHeader_SiteTitle").trigger("click")
})
it("@clickMenuItem=onClickMenuItem", () => {
const wrapper = shallowMount(GlobalHeader, { store, localVue })
const menu = wrapper.findComponent(Menu)
menu.vm.$emit("clickMenuItem", {})
})
it("snapshot", () => {
const wrapper = mount(GlobalHeader, { store, localVue })
expect(wrapper.vm.$el).toMatchSnapshot()
})
})
})
pageのテスト: GlobalHeader
- Vue RouterをテストするためにlocalVueを使う。
<template>
<div class="Root">
<GlobalHeader @navigate="onNavigate"/>
<SiteLogo class="Root_Logo"/>
</div>
</template>
<script>
import GlobalHeader from "../containers/GlobalHeader.vue"
import SiteLogo from "../basics/SiteLogo.vue"
export default {
name: "RootPage",
components: {
GlobalHeader,
SiteLogo,
},
methods: {
onNavigate({ name }) {
this.$router.push({ name })
},
},
}
</script>
import { createLocalVue, shallowMount } from "@vue/test-utils"
import Vuex from "vuex"
import menuItems from "../_mockData/menuItems.json"
import Root from "@/pages/RootPage.vue"
import GlobalHeader from "@/containers/GlobalHeader.vue"
const localVue = createLocalVue()
localVue.use(Vuex)
describe("RootPage.vue", () => {
let store
let $router
beforeAll(() => {
store = new Vuex.Store({
state: {
siteTitle: "test site title",
menuItems,
},
})
// $routerのモックを作成
$router = {
push: jest.fn(), // $router.pushをモック関数にしておく
}
})
describe("methods", () => {
it("onNavigate", () => {
// Vueインスタンス作成時に$routerプロパティを注入する
const wrapper = shallowMount(Root, { store, localVue, mocks: { $router } })
wrapper.vm.onNavigate({ name: "root" })
// mock化した$router.pushが呼ばれているか
expect($router.push).toHaveBeenCalledWith({
name: "root",
})
})
})
describe("template", () => {
it("snapshot", () => {
const wrapper = shallowMount(Root, { store, localVue, mocks: { $router } })
expect(wrapper.vm.$el).toMatchSnapshot()
})
})
})