Jest Vue 测试 #

Vue 测试概述 #

Vue 组件测试使用 Vue Test Utils 作为官方测试工具,支持 Vue 2 和 Vue 3。

text
┌─────────────────────────────────────────────────────────────┐
│                    Vue 测试内容                              │
├─────────────────────────────────────────────────────────────┤
│  1. 组件渲染 - 验证组件正确渲染                              │
│  2. Props 测试 - 测试组件属性                               │
│  3. 事件测试 - 测试组件事件                                  │
│  4. 插槽测试 - 测试插槽内容                                  │
│  5. Vuex 测试 - 测试状态管理                                │
│  6. Router 测试 - 测试路由                                  │
└─────────────────────────────────────────────────────────────┘

环境配置 #

安装依赖 #

bash
# Vue 3
npm install --save-dev @vue/test-utils@next vue-jest@next @vue/vue3-jest

# Vue 2
npm install --save-dev @vue/test-utils vue-jest

配置 Jest #

javascript
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.vue$': '@vue/vue3-jest',
    '^.+\\.js$': 'babel-jest',
  },
  moduleFileExtensions: ['vue', 'js', 'json'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

基本组件测试 #

简单组件 #

vue
<!-- Greeting.vue -->
<template>
  <h1>Hello, {{ name }}!</h1>
</template>

<script>
export default {
  props: {
    name: {
      type: String,
      required: true,
    },
  },
};
</script>
javascript
// Greeting.test.js
import { mount } from '@vue/test-utils';
import Greeting from './Greeting.vue';

test('renders greeting', () => {
  const wrapper = mount(Greeting, {
    props: {
      name: 'John',
    },
  });
  
  expect(wrapper.text()).toContain('Hello, John!');
});

带状态的组件 #

vue
<!-- Counter.vue -->
<template>
  <div>
    <span data-testid="count">{{ count }}</span>
    <button @click="increment">Increment</button>
    <button @click="decrement">Decrement</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
    };
  },
  methods: {
    increment() {
      this.count++;
    },
    decrement() {
      this.count--;
    },
  },
};
</script>
javascript
// Counter.test.js
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';

test('counter functionality', async () => {
  const wrapper = mount(Counter);
  
  expect(wrapper.find('[data-testid="count"]').text()).toBe('0');
  
  await wrapper.find('button').trigger('click');
  expect(wrapper.find('[data-testid="count"]').text()).toBe('1');
  
  await wrapper.findAll('button')[1].trigger('click');
  expect(wrapper.find('[data-testid="count"]').text()).toBe('0');
});

Props 测试 #

vue
<!-- Button.vue -->
<template>
  <button
    :class="['btn', `btn-${variant}`]"
    :disabled="disabled"
    @click="$emit('click')"
  >
    <slot />
  </button>
</template>

<script>
export default {
  props: {
    variant: {
      type: String,
      default: 'primary',
    },
    disabled: {
      type: Boolean,
      default: false,
    },
  },
};
</script>
javascript
// Button.test.js
import { mount } from '@vue/test-utils';
import Button from './Button.vue';

describe('Button', () => {
  test('renders with default props', () => {
    const wrapper = mount(Button, {
      slots: {
        default: 'Click me',
      },
    });
    
    expect(wrapper.text()).toContain('Click me');
    expect(wrapper.classes()).toContain('btn-primary');
  });
  
  test('renders with variant', () => {
    const wrapper = mount(Button, {
      props: {
        variant: 'secondary',
      },
    });
    
    expect(wrapper.classes()).toContain('btn-secondary');
  });
  
  test('renders disabled', () => {
    const wrapper = mount(Button, {
      props: {
        disabled: true,
      },
    });
    
    expect(wrapper.attributes('disabled')).toBe('');
  });
  
  test('emits click event', async () => {
    const wrapper = mount(Button);
    
    await wrapper.trigger('click');
    
    expect(wrapper.emitted()).toHaveProperty('click');
  });
});

事件测试 #

vue
<!-- SearchInput.vue -->
<template>
  <input
    type="text"
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
    @keyup.enter="$emit('search', modelValue)"
  />
</template>

<script>
export default {
  props: ['modelValue'],
  emits: ['update:modelValue', 'search'],
};
</script>
javascript
// SearchInput.test.js
import { mount } from '@vue/test-utils';
import SearchInput from './SearchInput.vue';

test('emits update:modelValue on input', async () => {
  const wrapper = mount(SearchInput, {
    props: {
      modelValue: '',
    },
  });
  
  await wrapper.find('input').setValue('test');
  
  expect(wrapper.emitted('update:modelValue')[0]).toEqual(['test']);
});

test('emits search on enter', async () => {
  const wrapper = mount(SearchInput, {
    props: {
      modelValue: 'search term',
    },
  });
  
  await wrapper.find('input').trigger('keyup.enter');
  
  expect(wrapper.emitted('search')[0]).toEqual(['search term']);
});

插槽测试 #

vue
<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header" />
    </div>
    <div class="card-body">
      <slot />
    </div>
    <div class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>
javascript
// Card.test.js
import { mount } from '@vue/test-utils';
import Card from './Card.vue';

test('renders slots', () => {
  const wrapper = mount(Card, {
    slots: {
      header: '<h1>Header</h1>',
      default: '<p>Content</p>',
      footer: '<span>Footer</span>',
    },
  });
  
  expect(wrapper.find('.card-header').html()).toContain('Header');
  expect(wrapper.find('.card-body').html()).toContain('Content');
  expect(wrapper.find('.card-footer').html()).toContain('Footer');
});

test('renders scoped slots', () => {
  const wrapper = mount(Card, {
    slots: {
      default: {
        template: '<p>{{ props.data }}</p>',
        data: 'test data',
      },
    },
  });
  
  expect(wrapper.text()).toContain('test data');
});

Composition API 测试 #

vue
<!-- useCounter.vue -->
<template>
  <div>
    <span>{{ count }}</span>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(0);

function increment() {
  count.value++;
}

function decrement() {
  count.value--;
}

defineExpose({ count, increment, decrement });
</script>
javascript
// useCounter.test.js
import { mount } from '@vue/test-utils';
import Counter from './useCounter.vue';

test('composition API counter', async () => {
  const wrapper = mount(Counter);
  
  expect(wrapper.text()).toContain('0');
  
  await wrapper.findAll('button')[0].trigger('click');
  expect(wrapper.text()).toContain('1');
  
  await wrapper.findAll('button')[1].trigger('click');
  expect(wrapper.text()).toContain('0');
});

Vuex 测试 #

javascript
// store.js
import { createStore } from 'vuex';

export default createStore({
  state: {
    count: 0,
  },
  mutations: {
    increment(state) {
      state.count++;
    },
  },
  actions: {
    increment({ commit }) {
      commit('increment');
    },
  },
});
vue
<!-- Counter.vue -->
<template>
  <div>
    <span>{{ count }}</span>
    <button @click="increment">Increment</button>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  computed: {
    ...mapState(['count']),
  },
  methods: {
    ...mapActions(['increment']),
  },
};
</script>
javascript
// Counter.test.js
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import Counter from './Counter.vue';

test('counter with Vuex', async () => {
  const store = createStore({
    state: { count: 0 },
    mutations: {
      increment(state) {
        state.count++;
      },
    },
  });
  
  const wrapper = mount(Counter, {
    global: {
      plugins: [store],
    },
  });
  
  expect(wrapper.text()).toContain('0');
  
  await wrapper.find('button').trigger('click');
  
  expect(wrapper.text()).toContain('1');
});

Pinia 测试 #

javascript
// stores/counter.js
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
  }),
  actions: {
    increment() {
      this.count++;
    },
  },
});
javascript
// Counter.test.js
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import Counter from './Counter.vue';

beforeEach(() => {
  setActivePinia(createPinia());
});

test('counter with Pinia', async () => {
  const wrapper = mount(Counter);
  const store = useCounterStore();
  
  expect(wrapper.text()).toContain('0');
  
  await wrapper.find('button').trigger('click');
  
  expect(store.count).toBe(1);
});

Vue Router 测试 #

javascript
// router.js
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
];

export default createRouter({
  history: createWebHistory(),
  routes,
});
javascript
// Navigation.test.js
import { mount } from '@vue/test-utils';
import { createRouter, createWebHistory } from 'vue-router';
import Navigation from './Navigation.vue';

test('navigation links', async () => {
  const router = createRouter({
    history: createWebHistory(),
    routes: [
      { path: '/', component: { template: '<div>Home</div>' } },
      { path: '/about', component: { template: '<div>About</div>' } },
    ],
  });
  
  const wrapper = mount(Navigation, {
    global: {
      plugins: [router],
    },
  });
  
  expect(wrapper.find('a[href="/"]').exists()).toBe(true);
  expect(wrapper.find('a[href="/about"]').exists()).toBe(true);
});

异步组件测试 #

vue
<!-- UserList.vue -->
<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <ul v-else>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

<script>
export default {
  data() {
    return {
      users: [],
      loading: true,
      error: null,
    };
  },
  async mounted() {
    try {
      const response = await fetch('/api/users');
      this.users = await response.json();
    } catch (error) {
      this.error = error.message;
    } finally {
      this.loading = false;
    }
  },
};
</script>
javascript
// UserList.test.js
import { mount, flushPromises } from '@vue/test-utils';
import UserList from './UserList.vue';

beforeEach(() => {
  global.fetch = jest.fn();
});

test('displays user list', async () => {
  fetch.mockResolvedValueOnce({
    json: () => Promise.resolve([
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' },
    ]),
  });
  
  const wrapper = mount(UserList);
  
  expect(wrapper.text()).toContain('Loading...');
  
  await flushPromises();
  
  expect(wrapper.text()).toContain('John');
  expect(wrapper.text()).toContain('Jane');
});

最佳实践 #

1. 使用 data-testid #

vue
<template>
  <button data-testid="submit-button">Submit</button>
</template>
javascript
wrapper.find('[data-testid="submit-button"]');

2. 测试用户行为 #

javascript
// ✅ 好的做法
await wrapper.find('input').setValue('test');
await wrapper.find('form').trigger('submit');

// ❌ 不好的做法
wrapper.vm.inputValue = 'test';
wrapper.vm.submit();

3. 使用全局组件 #

javascript
const wrapper = mount(Component, {
  global: {
    components: {
      MyComponent,
    },
  },
});

下一步 #

现在你已经掌握了 Vue 测试,接下来学习 Node.js 测试 实战后端测试!

最后更新:2026-03-28