Vue3 - 一些測試筆記
tags: Vue
Testing
category: Front-End
description: Vue3 - 一些測試筆記
created_at: 2022/09/04 00:00:00
前言
最近有專案剛好需要把既有 React
翻到 Vue3
,既然本來就是我寫的,然後又有目前的成品在(?),所以打算認真嘗試看看 TDD
,先寫測試在寫產品程式,但是平常也不常寫測試又或者是對測試的一些東西不熟,所以今天就花了一天的時間把我目前想的到的情境都先模擬了一遍。
因為那個專案主要是呈現的部分,雖然有打 API
,但目前只有做 render
的動作,所以我想了以下情境:
- 打了
API
東西還沒回來要顯示什麼 (<Suspense></Suspense>
) - 打完
API
之後要正確顯示 - 到特定頁面要正確更換標題 (
<title></title>
) - 根據不同寬度顯示內容 (
RWD
)
比較特別的是 RWD
的部分,雖然在 e2e
測試可以透過像是 cypress
之類的工具輕鬆達到測試,但我想嘗試單純使用 vitest
達到。
以下的環境都是 vitest
使用 jsdom
的環境。
開新專案
老樣子從開新專案開始:
$ npm init vue@latest
因為需要測試到更換網站標題,所以路由的選項記得改為 yes
,而本篇主題 test
也記得選 yes
。
切進專案之後一樣記得先安裝相依套件,假設用 yarn
$ yarn
修改標題與測試
安裝好之後在安裝這篇改標題要用到的套件
$ yarn add @vueuse/head
這個套件的使用方式是先在 main.js
讓 app
去使用他:
import { createHead } from "@vueuse/head";
// 略...
app.use(createHead());
再來可以像下面這樣修改標題
import { useHead } from "@vueuse/head";
useHead({
title: "New Title",
});
在來看瀏覽器上你的標題應該有被換掉,如果你在正確的頁面的話。
再來看看測試怎麼寫:
首先建立測試文件: /src/views/__test__/HomeView.spec.js
import { mount } from "@vue/test-utils";
import { createHead } from "@vueuse/head";
import { describe, expect, it } from "vitest";
import HomeView from "../HomeView.vue";
const head = createHead(); // 建立一個 head 給測試用
describe("Test HomeView", () => {
it("Should have the correct title.", () => {
mount(HomeView, {
global: {
plugins: [head], // 記得把 plugin 傳過去
},
});
expect(head.headTags.find((t) => t.tag === "title")?.props.children).toBe(
"HomeView"
);
});
});
這邊如果沒把 head
傳進去的話就會看到一個錯誤: You may forget to apply app.use(head)
這是因為測試並沒有經過 main.js
那一坨 use()
,所以未來只要有用到 plugin
的都要記得帶入,例如 Vuex
或 Pinia
之類。
這時你再去跑測試
$ yarn test:unit
應該會先噴掉看到一坨紅紅的,這時你再去 HomeView.vue
把 title
改成 HomeView
就會通過了!
覺得這段很冗長嗎?
head.headTags.find((t) => t.tag === "title")?.props.children
我目前是沒找到有沒有更方便取得的方式,不過上面那段的邏輯是因為 <head>
本來就有多個 tag
在下面,然後找到之後要在從 props
中找到 children
才是標題的內容。
不過有個方式可以輕鬆地取得 title
就不用那麼累了,不過要在裝一個套件
$ yarn add @vueuse/core
這裡面有提供一個 use
叫做 useTitle()
,就可以輕鬆拿到標題的 ref
,所以可以改成這樣:
expect(useTitle().value).toBe("HomeView");
簡潔多了!
打 API
與測試
這時要先假設一個情境,後端 API
根本還沒好,但是他已經先規劃好了介面(interface
),該有的輸入輸出格式,所以我可以先做一個假的,這時需要用到 msw
這個套件,所以老樣子先安裝。
$ yarn add msw
然後因為要打 API
這邊我想用 axios
,也比較方便測試,因為 fetch
是瀏覽器提供的函數,在測試的 node
環境不存在。
$ yarn add axios
這時先來建立假的 API
: src/mocks/handlers.js
import { rest } from "msw";
export const handlers = [
rest.get("http://localhost:8000/test", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
success: true,
data: "test!",
message: "",
})
);
}),
];
這邊都沒有把該抽離的常數或環境變數抽出來,為了 Demo
簡單,所以需要的在自己設定(例如API_PREFIX
)。
這樣就定義了一個假的 API
,他會幫你攔截 request
在做出你要的回應,也可以在瀏覽器上使用,瀏覽器上他是使用 service worker
去攔截的,不過在瀏覽器上測試之前,先來寫測試程式碼。
在測試程式碼中需要執行 mock server
,所以需要加上這段:
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
該引入的也要引入(路徑如果不同要記得修正)
import { handlers } from "../../mocks/handlers";
import { setupServer } from "msw/node";
import { /* ... */, beforeAll, afterEach, afterAll } from "vitest";
之後在加入一個 test case
,假設要檢查 API
回來的資料有沒有被正確渲染。
it("Should render correct response data.", async () => {
const wrapper = mount(HomeView, {
global: {
plugins: [head],
},
});
await flushPromises(); // 等待 promise 完成,因為 axios 是 promise
expect(wrapper.get("#api-data").text()).toBe("test!");
});
主要就是期望產品程式有去打 API
,回來的資料有正確的被放在 id
等於 api-data
的標籤中,而且內容是 test!
。
這時測試會壞掉,在去加上產品程式:
<script setup>
import axios from "axios";
import { ref } from "vue";
// ....
const data = ref(null);
axios.get("http://localhost:8000/test").then((res) => {
data.value = res.data.data;
});
</script>
<template>
<main>
<div id="api-data">{{ data }}</div>
</main>
</template>
也可以處理出錯的情況,這時可以把 axios.get
抽換掉:
it("Should handle failure response.", async () => {
vi.spyOn(axios, "get").mockResolvedValueOnce({
data: {
success: false,
data: "",
message: "Oops!",
},
});
const wrapper = mount(HomeView, {
global: {
plugins: [head],
},
});
await flushPromises();
expect(wrapper.get("#api-data").text()).toBe("Oops!");
});
這樣的話 axios.get
就會回傳一次上面指定的 data
,進而達到模擬失敗(success: false
),看看前端有沒有處理到。
目前因為什麼都還沒寫,一樣會噴錯,所以可以在加入簡單的處理:
<script setup>
// ...
const data = ref({});
axios.get("http://localhost:8000/test").then((res) => {
data.value = res.data;
});
</script>
<template>
<main>
<div id="api-data">
<div v-if="data.success">{{ data.data }}</div>
<div v-else>{{ data.message }}</div>
</div>
</main>
</template>
這裡主要只是點出也可以替換掉函數的回傳值,如果是模擬 success: false
的 API
也可以乾脆去改 msw
,讓他模擬回傳失敗傳回的 API
,不過整個抽換掉就也可以做其他的測試,看有沒有處理到。
這時測試都過了,理論上瀏覽器應該要可以跑,所以可以試一下,先啟動測試伺服器
$ yarn dev
會發現他噴了 Network Error
,這是因為沒有設定 msw
在 browser
環境跑,(這時也可以透過上面的方式去測試當網路錯誤要怎麼寫)
總之現在先幫 msw
設定瀏覽器的環境,首先先 init
一下,讓他幫你產生 service worker
的檔案
$ npx msw init public/ --save
之後建立一個 src/mocks/browser.js
檔案
import { setupWorker } from "msw";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
在來回到你的 src/main.js
,在 development
模式下記得把 worker
啟動。
import { worker } from "./mocks/browser";
if (import.meta.env.MODE === "development") {
worker.start();
}
這時在回到你的瀏覽器就沒問題了。
API
回應之前的測試
這邊就是要測 Suspense
這個組件,有沒有正常顯示我要的 Loading
(?)
假設我先多一個假的 endpoint
rest.get("http://localhost:8000/todos", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
success: true,
data: [
{
id: 1,
text: "Todo 1",
},
{
id: 2,
text: "Todo 2",
},
{
id: 3,
text: "Todo 3",
},
],
message: "",
})
);
}),
所以首先先做一個 TheTodos.vue
組件來顯示這些資料,先建立組件以及測試 src/components/__test__/TheTodos.spec.js
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import TheTodos from "../TheTodos.vue";
// 記得啟動 msw server...
describe("Test TheTodos", () => {
it("Should have render the correct todos.", async () => {
const wrapper = mount(TheTodos);
await flushPromises();
expect(wrapper.get("#todos").findAll(".todo").length).toBe(3);
});
});
然後一樣會噴錯,在去填產品程式(一樣先不做任何例外處理)
<script setup>
import axios from "axios";
import { ref } from "vue";
const todos = ref([]);
axios.get("http://localhost:8000/todos").then((res) => {
todos.value = res.data.data;
});
</script>
<template>
<div id="todos">
<div class="todo" v-for="todo in todos" :key="todo.id">{{ todo.text }}</div>
</div>
</template>
這時測試會過,但這跟上面例子其實沒差多少,因為還沒加入 Loading
,也就是還沒使用 Suspense
組件,所以改一下測試。
it("Should have render the correct todos and loading.", async () => {
const wrapper = mount({
template:
"<Suspense><TheTodos /><template #fallback>Loading...</template></Suspense>",
components: { TheTodos },
});
expect(wrapper.text()).toBe("Loading...");
await flushPromises();
expect(wrapper.get("#todos").findAll(".todo").length).toBe(3);
});
在 Promise
回來之前應該是 Loading...
,而結束應該是原本預期的 TheTodos
內容。
這時只要將產品程式的 setup()
改為async
的版本,透過語法糖可以輕鬆做到。
await axios.get("http://localhost:8000/todos").then((res) => {
todos.value = res.data.data;
});
到目前都是寫死的 Loading
,但至少驗證了他可接受<Suspense>
的包裝,所以我打算做一個組件來包裝 Suspense
以統一 Loading
顯示的畫面。
建立 src/components/AsyncWrapper.vue
,一樣也建立一個測試檔 src/components/__test__/AsyncWrapper.spec.js
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { defineAsyncComponent } from "vue";
import AsyncWrapper from "../AsyncWrapper.vue";
describe("Test AsyncWrapper", () => {
it("Should have render the correct loading and children.", async () => {
const wrapper = mount(AsyncWrapper, {
slots: {
// 定義一個假的非同步組件供測試
default: defineAsyncComponent(
() =>
new Promise((resolve) =>
resolve({
template: "<div>Hi</div>",
})
)
),
},
});
expect(wrapper.text()).toContain("Loading...");
await flushPromises();
expect(wrapper.get("div").text()).toBe("Hi");
});
});
在來去填 AsyncWrapper.vue
<template>
<Suspense>
<slot></slot>
<template #fallback>Loading...</template>
</Suspense>
</template>
基本上就是把 Suspense
多包裝一下,之後可以改 #fallback
的部分,讓所有用到的 Loading
畫面統一。
上面這個測試好了之後,基本上你到其他頁面放下面這段,應該都是可以跑的(要記得引入用到的組件)
<AsyncWrapper>
<TheTodos />
</AsyncWrapper>
也可以寫個測試檢查頁面是不是有掛上你要的組件
it("Should have render todos.", async () => {
const wrapper = mount(AboutView, {
global: {
plugins: [head],
},
});
await flushPromises();
expect(wrapper.findComponent(TheTodos).exists()).toBe(true);
});
RWD
測試
在前言也有提到,如果測試 RWD
用 cypress
之類的 e2e
測試工具可以輕鬆達到,但想在一般的單元或整合測試中可能就要換個形式去寫,以下就簡稱是 vitest
環境好了。
在 vitest
環境中,因為他沒有真的去掛你的 DOM
,所以你的寬度永遠是 0
,下面是測試。
src/views/RWDView.vue
<template>
<main>
<div id="a">A is show in large size.</div>
<div>Default show</div>
</main>
</template>
<style>
#a {
display: none;
}
@media (min-width: 1024px) {
#a {
display: block;
}
}
</style>
加上測試
describe("Test RWDView", () => {
it("Test...", async () => {
const wrapper = mount(RWDView);
console.log(wrapper.get("main").element.clientWidth);
console.log(wrapper.get("#a").isVisible());
});
});
上面的測試可以看到他不只寬度為零,連應該要被隱藏的 #a
都抓的到(visible
),所以他甚至連 style
都沒有跑。
所以我的想法是,那只要抽出一個 javascript
函數去檢查寬度,在 runtime
可以跑,且在測試只要做一個假的回傳值給他,這樣應該就可以測試到了,所以:
一個簡單的 Demo
,只判斷丟進來的寬度是否 >=1024
src/hooks/isLarge.js
export const isLarge = (width) => {
return width >= 1024;
};
然後在使用的地方把寬度丟進去看看 true
還是 false
決定要做什麼處理
src/views/RWDView.vue
<script setup>
import { isLarge } from "@/hooks/isLarge";
import { onMounted, onUnmounted, ref } from "vue";
const width = ref(window.innerWidth);
const resize = (e) => (width.value = e.target.innerWidth);
onMounted(() => window.addEventListener("resize", resize));
onUnmounted(() => window.removeEventListener("resize", resize));
</script>
<template>
<main>
<div v-if="isLarge(width)">A is show in large size.</div>
<div>Default show</div>
</main>
</template>
上面的 Demo
是寫死的,只是傳達個概念。
接下來測試只要 mock
一個 isLarge
函數就好:
vi.mock("@/hooks/isLarge", () => ({
isLarge: () => false,
}));
這樣子在這個測試中, .vue
裡面所執行的 isLarge
函數就會永遠回傳 false
,就可以在針對 false
的情形寫測試。
使用 useMediaQuery
判斷寬度的時候也不用自己寫,可以使用這個工具,他在 @vueuse/core
裡面,所以還沒安裝的話要安裝:
$ yarn add @vueuse/core
這時如果要 mock
他,只要這樣:
vi.mock("@vueuse/core", () => ({
useMediaQuery: () => true,
}));
然後 .vue
就可以這樣使用
<script setup>
import { useMediaQuery } from "@vueuse/core";
const isLarge = useMediaQuery("(min-width: 1024px)");
</script>
<template>
<main>
<div v-if="isLarge" id="a">A is show in large size.</div>
<div>Default show</div>
</main>
</template>
這樣子就可以針對需要的情境去做測試了。
後記
在上面因為有多個測試需要使用到 msw server
,但是如果在每個測試檔都特別寫感覺就很累,尤其要 import
那一坨東西,然後我就想到有沒有像是 react
一樣有個 setupTest.js
這樣的東西,會在測試前都去讀這個檔案。
後來稍微找了一下,可以在 vite.config.js
當中設定
export default defineConfig({
// ...
test: {
setupFiles: ["/src/setupTests.js"],
},
});
再來只要去建立 /src/setupTests.js
import { setupServer } from "msw/node";
import { beforeAll, afterAll, afterEach } from "vitest";
import { handlers } from "./mocks/handlers";
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
這樣就不用在每個測試檔中寫這一坨了。