Vue3 - 一些測試筆記

tags: Vue Testing
category: Front-End
description: Vue3 - 一些測試筆記
created_at: 2022/09/04 00:00:00

cover image


前言

最近有專案剛好需要把既有 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.jsapp 去使用他:

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 的都要記得帶入,例如 VuexPinia 之類。

這時你再去跑測試

$ yarn test:unit

應該會先噴掉看到一坨紅紅的,這時你再去 HomeView.vuetitle 改成 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: falseAPI 也可以乾脆去改 msw,讓他模擬回傳失敗傳回的 API,不過整個抽換掉就也可以做其他的測試,看有沒有處理到。

這時測試都過了,理論上瀏覽器應該要可以跑,所以可以試一下,先啟動測試伺服器

$ yarn dev

會發現他噴了 Network Error,這是因為沒有設定 mswbrowser 環境跑,(這時也可以透過上面的方式去測試當網路錯誤要怎麼寫)

總之現在先幫 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 測試

在前言也有提到,如果測試 RWDcypress 之類的 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());

這樣就不用在每個測試檔中寫這一坨了。




最後更新時間: 2022年09月04日.