Skip to content
⚠️ This article was written in 2020. Some content may be outdated.

Vue 組件單元測試實戰

之前寫了一篇測試實踐指南,這篇專門針對 Vue 組件的單元測試深入展開。管理後台項目裏有 200+ 組件,要保證重構時不 break,測試必須跟上。

環境搭建

bash
npm install -D jest @vue/test-utils vue-jest babel-jest @babel/core @babel/preset-env

# Vue 2
npm install -D vue-template-compiler vue@2

# ts 支持
npm install -D ts-jest @types/jest typescript
javascript
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  moduleFileExtensions: ['js', 'ts', 'vue', 'json'],
  transform: {
    '^.+\\.vue$': 'vue-jest',
    '^.+\\.ts$': 'ts-jest',
    '^.+\\.js$': 'babel-jest',
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  // 覆蓋率
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov'],
};

測試純展示組件

vue
<!-- Tag.vue -->
<template>
  <span :class="['tag', `tag--${type}`]" @click="$emit('click', $event)">
    <slot />
  </span>
</template>

<script>
export default {
  name: 'Tag',
  props: {
    type: {
      type: String,
      default: 'default',
      validator: v => ['default', 'success', 'warning', 'danger'].includes(v),
    },
  },
};
</script>
typescript
// __tests__/Tag.test.ts
import { mount } from '@vue/test-utils';
import Tag from '@/components/Tag.vue';

describe('Tag', () => {
  it('渲染插槽內容', () => {
    const wrapper = mount(Tag, {
      slots: { default: '標籤內容' },
    });
    expect(wrapper.text()).toBe('標籤內容');
  });

  it('默認類型是 default', () => {
    const wrapper = mount(Tag);
    expect(wrapper.classes()).toContain('tag--default');
  });

  it('設置 type 後有對應類名', () => {
    const wrapper = mount(Tag, {
      propsData: { type: 'success' },
    });
    expect(wrapper.classes()).toContain('tag--success');
  });

  it('點擊時觸發 click 事件', async () => {
    const wrapper = mount(Tag);
    await wrapper.trigger('click');
    expect(wrapper.emitted('click')).toHaveLength(1);
  });

  it('type 校驗失敗時在控制台警告', () => {
    const warnSpy = jest.spyOn(console, 'error').mockImplementation();
    mount(Tag, {
      propsData: { type: 'unknown' },
    });
    expect(warnSpy).toHaveBeenCalled();
    warnSpy.mockRestore();
  });
});

測試表單組件

typescript
// __tests__/SearchForm.test.ts
import { mount } from '@vue/test-utils';
import SearchForm from '@/components/SearchForm.vue';

describe('SearchForm', () => {
  it('輸入關鍵詞後點搜索觸發 search 事件', async () => {
    const wrapper = mount(SearchForm);

    const input = wrapper.find('input[type="text"]');
    await input.setValue('測試關鍵詞');

    const button = wrapper.find('button.search-btn');
    await button.trigger('click');

    expect(wrapper.emitted('search')).toBeTruthy();
    expect(wrapper.emitted('search')[0][0]).toEqual({
      keyword: '測試關鍵詞',
    });
  });

  it('按回車也能觸發搜索', async () => {
    const wrapper = mount(SearchForm);
    const input = wrapper.find('input[type="text"]');

    await input.setValue('關鍵詞');
    await input.trigger('keyup.enter');

    expect(wrapper.emitted('search')).toHaveLength(1);
  });

  it('點重置清空表單並觸發 reset 事件', async () => {
    const wrapper = mount(SearchForm);

    // 先輸入一些值
    await wrapper.find('input').setValue('測試');

    // 點重置
    await wrapper.find('button.reset-btn').trigger('click');

    expect(wrapper.find('input').element.value).toBe('');
    expect(wrapper.emitted('reset')).toHaveLength(1);
  });
});

測試異步數據組件

typescript
// __tests__/UserList.test.ts
import { mount, flushPromises } from '@vue/test-utils';
import UserList from '@/views/UserList.vue';

// Mock API
jest.mock('@/api/user', () => ({
  getUserList: jest.fn(),
}));

import { getUserList } from '@/api/user';

describe('UserList', () => {
  const mockUsers = [
    { id: 1, name: '張三', role: 'admin' },
    { id: 2, name: '李四', role: 'user' },
  ];

  beforeEach(() => {
    jest.clearAllMocks();
    (getUserList as jest.Mock).mockResolvedValue({
      list: mockUsers,
      total: 2,
    });
  });

  it('加載時顯示 loading', () => {
    // 讓請求不返回
    (getUserList as jest.Mock).mockReturnValue(new Promise(() => {}));

    const wrapper = mount(UserList);
    expect(wrapper.find('.loading').exists()).toBe(true);
  });

  it('請求成功後渲染用户列表', async () => {
    const wrapper = mount(UserList);

    await flushPromises();

    const rows = wrapper.findAll('.user-row');
    expect(rows).toHaveLength(2);
    expect(rows[0].text()).toContain('張三');
    expect(rows[1].text()).toContain('李四');
  });

  it('請求失敗顯示錯誤信息', async () => {
    (getUserList as jest.Mock).mockRejectedValue(new Error('網絡錯誤'));

    const wrapper = mount(UserList);
    await flushPromises();

    expect(wrapper.find('.error-message').exists()).toBe(true);
    expect(wrapper.find('.error-message').text()).toContain('加載失敗');
  });

  it('點擊刷新重新請求', async () => {
    const wrapper = mount(UserList);
    await flushPromises();

    await wrapper.find('.refresh-btn').trigger('click');
    await flushPromises();

    expect(getUserList).toHaveBeenCalledTimes(2);
  });
});

測試 Vuex 集成

typescript
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import UserCard from '@/components/UserCard.vue';

const localVue = createLocalVue();
localVue.use(Vuex);

describe('UserCard 與 Vuex', () => {
  let store;
  let actions;
  let state;

  beforeEach(() => {
    state = {
      user: { id: 1, name: '張三', avatar: '/avatar.png' },
    };
    actions = {
      logout: jest.fn(),
    };
    store = new Vuex.Store({ state, actions });
  });

  it('顯示用户信息', () => {
    const wrapper = mount(UserCard, { store, localVue });
    expect(wrapper.text()).toContain('張三');
    expect(wrapper.find('img').attributes('src')).toBe('/avatar.png');
  });

  it('點退出調用 logout action', async () => {
    const wrapper = mount(UserCard, { store, localVue });
    await wrapper.find('.logout-btn').trigger('click');
    expect(actions.logout).toHaveBeenCalled();
  });
});

測試技巧

typescript
// 1. 測試雙向綁定 v-model
it('支持 v-model', async () => {
  const wrapper = mount(MyInput, {
    propsData: { value: '初始值' },
  });

  await wrapper.find('input').setValue('新值');
  expect(wrapper.emitted('input')[0][0]).toBe('新值');
});

// 2. 測試 ref 訪問
it('通過 ref 調用組件方法', () => {
  const wrapper = mount(MyForm);
  const form = wrapper.vm as any;
  form.validate();
  // 驗證 validate 被調用的效果
});

// 3. 定時器 Mock
jest.useFakeTimers();

it('防抖後才觸發搜索', async () => {
  const wrapper = mount(SearchInput);
  await wrapper.find('input').setValue('a');
  await wrapper.find('input').setValue('ab');

  expect(wrapper.emitted('search')).toBeFalsy();

  jest.advanceTimersByTime(500);

  expect(wrapper.emitted('search')).toHaveLength(1);
  expect(wrapper.emitted('search')[0][0]).toBe('ab');
});

小結

  • Vue 組件測試用 @vue/test-utils,和 Jest 配合很好
  • 測試行為(渲染、事件、交互),不要測試實現細節
  • 異步組件用 flushPromises() 等待更新
  • Mock API 和定時器,讓測試結果確定可控
  • 不需要 100% 覆蓋,核心交互和邊界情況優先

MIT Licensed