
在現代前端開發中,單元測試是確保代碼質量和可靠性的關鍵部分。本文將介紹如何使用 Vitest 和 @vue/test-utils 為 Vue 3 應用程序編寫單元測試。我們將探討如何整合 Pinia store、Zod、Vee-Validate、composables 和 @vueuse/core 等概念到測試中。此外,我們還將討論如何將 Storybook、Playwright 和 happy-dom 與 Vitest 集成,以創建一個全面的測試環境。
首先,我們需要安裝所有必要的依賴。在你的 Vue 3 項目目錄中運行以下命令:
bun add -D vitest @vue/test-utils happy-dom @vitejs/plugin-vue
# 這是手動裝 storybook bun add -D @storybook/vue3 @storybook/addon-essentials @storybook/testing-vue3
bunx storybook@latest init # 使用 storybook 官方提供的方法裝即可
bun add -D @playwright/test
bun add pinia @vueuse/core zod vee-validate @vee-validate/zod
備註: 這裡 @vitejs/plugin-vue 要升級版本,不然會有不相容的問題
創建一個 vitest.config.ts 文件在你的項目根目錄,並添加以下配置:
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: "happy-dom",
    exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
    outputFile: {
      json: "test-results.json"
    },
    coverage: {
      provider: "v8",
      reporter: ["html", "json", "text"],
      exclude: [
        'node_modules',
        'src/main.ts'
      ]
    }
  }
});
並在 package.json 加入 scripts
{
  // ... (省略)
    "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "test": "vitest" // 加入這行
  },
  // ... (省略)
}
import { describe, it, expect } from "vitest";
describe("main", () => {
  it("true", () => {
    expect(true).toBe(true);
  });
});
跑一下指令
bun run test
如果看到以下結果代表基本上安裝成功了

讓我們創建一個簡單的計數器組件和相應的 store 來演示測試。
(檔案:src/stores/useCounterStore.ts)
import { shallowRef , computed } from "vue"
import { defineStore, acceptHMRUpdate } from "pinia";
export const useCounterStore = defineStore("userStore", () => {
  // state::
  const count = shallowRef<number>(0);
  // getter::
  const doubleCount = computed<number>(() => count.value * 2)
  // methods::
  const increment = (): void => {
    count.value++;
  };
  const decrement = (): void => {
    count.value--;
  };
  return {
    // state::
    count,
    // getters::
    doubleCount,
    // methods::
    increment,
    decrement,
  }
})
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot));
}
src/components/Counter.vue:
<script setup lang="ts">
  import { storeToRefs } from 'pinia';
  import { useCounterStore } from '../stores/useCouterStore';
  const counterStore = useCounterStore();
  const { increment, decrement } = counterStore;
  const { count, doubleCount } = storeToRefs(counterStore);
</script>
<template>
  <div>
    <p>count : {{ count }}</p>
    <p>double count : {{ doubleCount }}</p>
    <button data-testid="increment" aria-label="click to increase count" @click="increment" border-none px-3 py-2 rounded-md cursor-pointer box-border text="hover:white" bg="blue-400 hover:blue-800">+</button>
    <button data-testid="decrement" aria-label="click to decrease count" @click="decrement" border-none px-3 py-2 rounded-md cursor-pointer box-border text="hover:white" bg="blue-400 hover:blue-800">-</button>
  </div>
</template>
創建 src/components/Counter.spec.ts 文件:
import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia  } from 'pinia';
import Counter from './Counter.vue';
describe('Counter.vue', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  })
  it('render', () => {
    const wrapper = mount(Counter);
    expect(wrapper.text()).toContain('count : 0')
  });
  it('increments count when button is clicked', async () => {
    const wrapper = mount(Counter);
    await wrapper.find('[data-testid="increment"]').trigger('click');
    expect(wrapper.text()).toContain('count : 1')
  });
  it('decrement count when button is clicked', async () => {
    const wrapper = mount(Counter);
    await wrapper.find('[data-testid="decrement"]').trigger('click');
    expect(wrapper.text()).toContain('count : -1')
  });
});
一樣可以跑跑看,注意這裡我建議用 data-testid 將行測試,這樣比較可以錨定對象,前面組件我盡可能寫得簡單,也同時為了單元測試鋪路
讓我們創建一個使用 Zod 和 Vee-Validate 的表單組件,然後為它編寫測試。
(檔案:src/components/UserForm.vue)
<script setup lang="ts">
  import { useUserForm } from '../composables/useUserForm';
  import CustomInput from './CustomInput.vue';
  const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
  const {
    name,
    email,
    formSubmit,
    isSubmittingDisabled,
    errors
  } = useUserForm(async submitValue => {
    await wait(500);
    console.log(submitValue);
    return true;
  });
</script>
<template>
  <form @submit.prevent="formSubmit" role="user form" w="1/4 2xl:1/6" border="solid 1px gray-100" shadow-lg px-6 py-4 flex="~ col" gap-y-2>
    <CustomInput label="Name" placeholder="name" v-model="name" :error-message="errors.name" :disabled="isSubmittingDisabled" />
    <CustomInput label="Email" placeholder="email" v-model="email" :error-message="errors.email" :disabled="isSubmittingDisabled" />
    <button :disabled="isSubmittingDisabled" type="submit" aria-label="submit user form" border-none px-3 py-2 rounded-md cursor-pointer box-border text="disabled:gray-800 hover:white" bg="blue-400 disabled:gray-400 hover:blue-800">
      {{ isSubmittingDisabled ? 'Submitting...' : 'Submit' }}
    </button>
  </form>
</template>
補充:
(檔案:src/composables/useUserForm.ts)
import * as zod from "zod";
import { shallowRef } from "vue";
import { useThrottleFn } from "@vueuse/core";
import { useForm, useField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
export const userSchema = zod
  .object({
    name: zod.string().min(1, "name is required"),
    email: zod.string().email(),
  });
export type UserSchema = zod.infer<typeof userSchema>;
export const useUserForm = (submitFn: (values: UserSchema) => Promise<boolean>, submitErrorFn?: () => void) => {
  const isSubmittingDisabled = shallowRef<boolean>(false);
  const validationSchema = toTypedSchema(userSchema);
  const initialValues: UserSchema = {
    name: "",
    email: "",
  };
  const { handleSubmit, isSubmitting, resetForm, errors } = useForm<UserSchema>({
    validationSchema,
    initialValues
  });
  const formSubmit = handleSubmit(
    useThrottleFn(async values  => {
      isSubmittingDisabled.value = true;
      const isSuccess = await submitFn(values);
      if (!isSuccess && submitErrorFn) {
        submitErrorFn();
      }
      isSubmittingDisabled.value = false;
    }, 800)
  );
  const { value: name } = useField<string>("name");
  const { value: email } = useField<string>("email");
  return {
    name,
    email,
    formSubmit,
    isSubmitting,
    isSubmittingDisabled,
    resetForm,
    errors
  };
};
export type UseUserForm = typeof useUserForm;
(檔案 : src/components/CustomInput.vue)
<script setup lang="ts">
  import { useId } from 'vue';
  const { id = useId(), isShowLabel = true, placeholder = '', errorMessage = '', disabled = false } = defineProps<{
    label: string;
    id?: string;
    isShowLabel?: boolean;
    placeholder?: string;
    errorMessage?: string;
    disabled?: boolean;
  }>();
  const errorID = useId();
  const modelValue = defineModel<string | number>({ default: '' });
</script>
<template>
  <div>
    <label v-show="isShowLabel" :for="id">{{ label }}</label>
    <input bg="disabled:gray-400" w-full px-2 py-1 rounded-md border="solid 1px gray-500" :placeholder :aria-describedby="errorMessage ? errorID : undefined" :id :disabled v-model="modelValue" />
    <span text="red-500 sm"  v-show="errorMessage" :id="errorID">{{ errorMessage }}</span>
  </div>
</template>

(檔案:src/components/UserForm.spec.ts)
import { describe, it, expect } from "vitest";
import { mount } from '@vue/test-utils';
import UserForm from './UserForm.vue'
import flushPromises from 'flush-promises';
import waitForExpect from 'wait-for-expect';
describe("UserForm.vue", () => {
  it("validate form not valid", async () => {
    const wrapper = mount(UserForm);
    const nameInput = wrapper.find<HTMLInputElement>('input[placeholder="Name"]');
    const emailInput = wrapper.find<HTMLInputElement>('input[placeholder="Email"]');
    await nameInput.setValue('j');
    await emailInput.setValue('1234');
    await wrapper.find('form').trigger('submit');
    await flushPromises();
    await waitForExpect(() => {
      expect(wrapper.text()).toContain('at least 2 characters');
      expect(wrapper.text()).toContain('not email format');
    });
  });
  it("validate form valid", async () => {
    const wrapper = mount(UserForm);
    await wrapper.find<HTMLInputElement>('input[placeholder="Name"]').setValue('hello');
    await wrapper.find<HTMLInputElement>('input[placeholder="Email"]').setValue('1234@gmail.com');
    await wrapper.find('form').trigger('submit');
    await flushPromises();
    await waitForExpect(() => {
      expect(wrapper.text()).not.toContain('at least 2 characters');
      expect(wrapper.text()).not.toContain('not email format');
    });
  });
});
補充 : vee-validate在撰寫測試時因為元件非同步的問題(Promise pending 沒有 resolve) 的問題
解決方法:
bun add -D flush-promises
bun add -D wait-for-expect
並按照我上方的寫法即可解決測試上的問題
讓我們創建一個使用 @vueuse/core 的 composable 並為其編寫測試。
(檔案 : src/composables/useWindowSize.ts)
import { useWindowSize as vueUseWindowSize } from '@vueuse/core'
export function useWindowSize() {
  const { width, height } = vueUseWindowSize()
  
  const isSmallScreen = computed(() => width.value < 640)
  const isMediumScreen = computed(() => width.value >= 640 && width.value < 1024)
  const isLargeScreen = computed(() => width.value >= 1024)
  return {
    width,
    height,
    isSmallScreen,
    isMediumScreen,
    isLargeScreen,
  }
}
現在,讓我們為這個 composable 編寫測試。
src/composables/useWindowSize.spec.ts:
import { describe, it, expect, vi } from 'vitest'
import { useWindowSize } from './useWindowSize'
import { ref } from 'vue'
vi.mock('@vueuse/core', () => ({
  useWindowSize: vi.fn(() => ({
    width: ref(1024),
    height: ref(768),
  })),
}))
describe('useWindowSize', () => {
  it('correctly determines screen sizes', () => {
    const { isSmallScreen, isMediumScreen, isLargeScreen } = useWindowSize()
    expect(isSmallScreen.value).toBe(false)
    expect(isMediumScreen.value).toBe(false)
    expect(isLargeScreen.value).toBe(true)
  })
})

首先,初始化 Storybook:
npx storybook init
然後,為我們的 Counter 組件創建一個 story。
src/stories/Counter.stories.ts:
import type { Meta, StoryObj } from '@storybook/vue3'
import Counter from '../components/Counter.vue'
import { createPinia } from 'pinia'
const meta: Meta<typeof Counter> = {
  title: 'Components/Counter',
  component: Counter,
  decorators: [() => ({ template: '<div><story /></div>', setup: () => {createPinia()} })],
}
export default meta
type Story = StoryObj<typeof Counter>
export const Default: Story = {}

創建一個 Playwright 測試文件 tests/counter.spec.ts:
import { test, expect } from '@playwright/test'
test('counter increments and decrements', async ({ page }) => {
  await page.goto('http://localhost:5173')  // 假設你的 app 運行在這個地址
  await expect(page.locator('text=Count: 0')).toBeVisible()
  await page.click('text=Increment')
  await expect(page.locator('text=Count: 1')).toBeVisible()
  await page.click('text=Decrement')
  await expect(page.locator('text=Count: 0')).toBeVisible()
})
在 package.json 中添加以下腳本:
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test"
  }
}
現在你可以運行以下命令來執行測試:
bun run test: 運行單元測試bun run test:ui: 在 UI 模式下運行單元測試bun run test:coverage: 運行單元測試並生成覆蓋率報告bun run test:e2e: 運行 Playwright e2e 測試在本文中,我們學習了如何使用 Vitest 和 @vue/test-utils 為 Vue 3 應用程序編寫單元測試。我們成功整合了 Pinia store、Zod、Vee-Validate、@vueuse/core 等工具,並展示了如何測試使用這些工具的組件和 composables。
此外,我們還介紹了如何將 Storybook 用於組件開發,以及如何使用 Playwright 進行端到端測試。通過使用 happy-dom,我們能夠在 Node.js 環境中模擬 DOM,從而加速了測試的執行。
記住,編寫好的單元測試不僅可以幫助你捕獲錯誤,還可以提高代碼質量,並為重構提供信心。隨著你的應用程序變得越來越複雜,擁有一個強大的測試套件將變得越來越重要。
希望這個指南能夠幫助你開始在 Vue 3 項目中使用 Vitest 進行測試。隨著你的經驗增加,你可以探索更多高級的測試技術和策略。