Jest Vue 测试 #
Vue 测试概述 #
Vue 组件测试使用 Vue Test Utils 提供的 API 来挂载和操作组件,验证组件的行为和输出。
text
┌─────────────────────────────────────────────────────────────┐
│ Vue 测试层次 │
├─────────────────────────────────────────────────────────────┤
│ 1. 渲染测试 - 组件是否正确渲染 │
│ 2. Props 测试 - Props 传递是否正确 │
│ 3. 事件测试 - 事件触发是否正确 │
│ 4. 插槽测试 - 插槽内容是否正确 │
│ 5. 状态测试 - 状态管理是否正确 │
└─────────────────────────────────────────────────────────────┘
环境配置 #
安装依赖 #
bash
npm install --save-dev jest @vue/test-utils @vue/vue3-jest babel-jest @babel/preset-env 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>
<!-- Greeting.test.js -->
import { mount } from '@vue/test-utils';
import Greeting from './Greeting.vue';
test('renders greeting with name', () => {
const wrapper = mount(Greeting, {
props: { name: 'John' },
});
expect(wrapper.text()).toContain('Hello, John!');
});
test('renders with different names', () => {
const wrapper = mount(Greeting, {
props: { name: 'Jane' },
});
expect(wrapper.text()).toContain('Hello, Jane!');
});
条件渲染 #
vue
<!-- UserStatus.vue -->
<template>
<button v-if="!isLoggedIn" @click="$emit('login')">Login</button>
<span v-else>Welcome, {{ user.name }}</span>
</template>
<script>
export default {
props: {
isLoggedIn: Boolean,
user: Object,
},
emits: ['login'],
};
</script>
<!-- UserStatus.test.js -->
import { mount } from '@vue/test-utils';
import UserStatus from './UserStatus.vue';
describe('UserStatus', () => {
test('shows login button when not logged in', () => {
const wrapper = mount(UserStatus, {
props: { isLoggedIn: false },
});
expect(wrapper.find('button').exists()).toBe(true);
expect(wrapper.text()).toContain('Login');
});
test('shows welcome message when logged in', () => {
const wrapper = mount(UserStatus, {
props: {
isLoggedIn: true,
user: { name: 'John' },
},
});
expect(wrapper.text()).toContain('Welcome, John');
expect(wrapper.find('button').exists()).toBe(false);
});
});
列表渲染 #
vue
<!-- TodoList.vue -->
<template>
<p v-if="items.length === 0">No items</p>
<ul v-else>
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
</template>
<script>
export default {
props: {
items: Array,
},
};
</script>
<!-- TodoList.test.js -->
import { mount } from '@vue/test-utils';
import TodoList from './TodoList.vue';
describe('TodoList', () => {
test('shows empty state', () => {
const wrapper = mount(TodoList, {
props: { items: [] },
});
expect(wrapper.text()).toContain('No items');
});
test('renders list items', () => {
const items = [
{ id: 1, text: 'Buy milk' },
{ id: 2, text: 'Walk dog' },
];
const wrapper = mount(TodoList, {
props: { items },
});
const listItems = wrapper.findAll('li');
expect(listItems).toHaveLength(2);
expect(listItems[0].text()).toBe('Buy milk');
expect(listItems[1].text()).toBe('Walk dog');
});
});
Props 测试 #
vue
<!-- Button.vue -->
<template>
<button :class="buttonClass" :disabled="disabled" @click="$emit('click')">
<slot />
</button>
</template>
<script>
export default {
props: {
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger'].includes(value),
},
disabled: Boolean,
},
emits: ['click'],
computed: {
buttonClass() {
return `btn btn-${this.variant}`;
},
},
};
</script>
<!-- Button.test.js -->
import { mount } from '@vue/test-utils';
import Button from './Button.vue';
describe('Button', () => {
test('renders with default variant', () => {
const wrapper = mount(Button);
expect(wrapper.classes()).toContain('btn-primary');
});
test('renders with custom variant', () => {
const wrapper = mount(Button, {
props: { variant: 'secondary' },
});
expect(wrapper.classes()).toContain('btn-secondary');
});
test('can be disabled', () => {
const wrapper = mount(Button, {
props: { disabled: true },
});
expect(wrapper.attributes('disabled')).toBeDefined();
});
test('emits click event', async () => {
const wrapper = mount(Button);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
});
test('renders slot content', () => {
const wrapper = mount(Button, {
slots: { default: 'Click me' },
});
expect(wrapper.text()).toBe('Click me');
});
});
事件测试 #
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>
<!-- Counter.test.js -->
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
describe('Counter', () => {
test('renders initial count', () => {
const wrapper = mount(Counter);
expect(wrapper.find('[data-testid="count"]').text()).toBe('0');
});
test('increments count', async () => {
const wrapper = mount(Counter);
const buttons = wrapper.findAll('button');
await buttons[0].trigger('click');
expect(wrapper.find('[data-testid="count"]').text()).toBe('1');
});
test('decrements count', async () => {
const wrapper = mount(Counter);
const buttons = wrapper.findAll('button');
await buttons[1].trigger('click');
expect(wrapper.find('[data-testid="count"]').text()).toBe('-1');
});
});
表单测试 #
vue
<!-- SearchForm.vue -->
<template>
<form @submit.prevent="handleSubmit">
<input v-model="query" placeholder="Search..." />
<button type="submit">Search</button>
</form>
</template>
<script>
export default {
data() {
return { query: '' };
},
emits: ['search'],
methods: {
handleSubmit() {
this.$emit('search', this.query);
},
},
};
</script>
<!-- SearchForm.test.js -->
import { mount } from '@vue/test-utils';
import SearchForm from './SearchForm.vue';
describe('SearchForm', () => {
test('updates query on input', async () => {
const wrapper = mount(SearchForm);
const input = wrapper.find('input');
await input.setValue('react testing');
expect(wrapper.vm.query).toBe('react testing');
});
test('emits search event on submit', async () => {
const wrapper = mount(SearchForm);
await wrapper.find('input').setValue('react');
await wrapper.find('form').trigger('submit.prevent');
expect(wrapper.emitted('search')).toBeTruthy();
expect(wrapper.emitted('search')[0]).toEqual(['react']);
});
});
插槽测试 #
vue
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default Content</slot>
</div>
<div class="card-footer">
<slot name="footer">Default Footer</slot>
</div>
</div>
</template>
<!-- Card.test.js -->
import { mount } from '@vue/test-utils';
import Card from './Card.vue';
describe('Card', () => {
test('renders default slots', () => {
const wrapper = mount(Card);
expect(wrapper.find('.card-header').text()).toBe('Default Header');
expect(wrapper.find('.card-body').text()).toBe('Default Content');
expect(wrapper.find('.card-footer').text()).toBe('Default Footer');
});
test('renders custom slots', () => {
const wrapper = mount(Card, {
slots: {
header: 'Custom Header',
default: 'Custom Content',
footer: 'Custom Footer',
},
});
expect(wrapper.find('.card-header').text()).toBe('Custom Header');
expect(wrapper.find('.card-body').text()).toBe('Custom Content');
expect(wrapper.find('.card-footer').text()).toBe('Custom Footer');
});
test('renders component in slot', () => {
const wrapper = mount(Card, {
slots: {
default: '<button>Click me</button>',
},
});
expect(wrapper.find('button').exists()).toBe(true);
});
});
Vuex 测试 #
javascript
// store.js
import { createStore } from 'vuex';
export default createStore({
state: {
count: 0,
},
mutations: {
increment(state) {
state.count++;
},
},
actions: {
increment({ commit }) {
commit('increment');
},
},
getters: {
doubleCount: (state) => state.count * 2,
},
});
// Counter.vue
<template>
<div>
<span>{{ count }}</span>
<span>{{ doubleCount }}</span>
<button @click="increment">Increment</button>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
export default {
computed: {
...mapState(['count']),
...mapGetters(['doubleCount']),
},
methods: {
...mapActions(['increment']),
},
};
</script>
// Counter.test.js
import { mount } from '@vue/test-utils';
import { createStore } from 'vuex';
import Counter from './Counter.vue';
describe('Counter with Vuex', () => {
let store;
let wrapper;
beforeEach(() => {
store = createStore({
state: { count: 0 },
mutations: {
increment(state) {
state.count++;
},
},
getters: {
doubleCount: (state) => state.count * 2,
},
});
wrapper = mount(Counter, {
global: {
plugins: [store],
},
});
});
test('renders count from store', () => {
expect(wrapper.text()).toContain('0');
});
test('renders double count', () => {
expect(wrapper.text()).toContain('0');
});
test('increments count on button click', async () => {
await wrapper.find('button').trigger('click');
expect(store.state.count).toBe(1);
});
});
Pinia 测试 #
javascript
// stores/counter.js
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
actions: {
increment() {
this.count++;
},
},
getters: {
doubleCount: (state) => state.count * 2,
},
});
// Counter.vue
<template>
<div>
<span>{{ counter.count }}</span>
<span>{{ counter.doubleCount }}</span>
<button @click="counter.increment">Increment</button>
</div>
</template>
<script>
import { useCounterStore } from '@/stores/counter';
export default {
setup() {
const counter = useCounterStore();
return { counter };
},
};
</script>
// Counter.test.js
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import Counter from './Counter.vue';
describe('Counter with Pinia', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
test('renders initial count', () => {
const wrapper = mount(Counter);
expect(wrapper.text()).toContain('0');
});
test('increments count', async () => {
const wrapper = mount(Counter);
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('1');
});
});
Composition API 测试 #
vue
<!-- useCounter.js -->
import { ref } from 'vue';
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
function increment() {
count.value++;
}
function decrement() {
count.value--;
}
function reset() {
count.value = initialValue;
}
return { count, increment, decrement, reset };
}
<!-- Counter.vue -->
<template>
<div>
<span>{{ count }}</span>
<button @click="increment">Increment</button>
<button @click="decrement">Decrement</button>
</div>
</template>
<script>
import { useCounter } from './useCounter';
export default {
setup() {
return useCounter();
},
};
</script>
// useCounter.test.js
import { useCounter } from './useCounter';
describe('useCounter', () => {
test('initializes with default value', () => {
const { count } = useCounter();
expect(count.value).toBe(0);
});
test('initializes with custom value', () => {
const { count } = useCounter(10);
expect(count.value).toBe(10);
});
test('increments count', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
test('decrements count', () => {
const { count, decrement } = useCounter();
decrement();
expect(count.value).toBe(-1);
});
});
最佳实践 #
1. 测试用户可见的内容 #
javascript
// ❌ 不好的做法
expect(wrapper.vm.count).toBe(1);
// ✅ 好的做法
expect(wrapper.text()).toContain('1');
2. 使用 data-testid 作为后备 #
javascript
// 优先使用语义化查询
wrapper.find('button');
wrapper.find('.submit-button');
// 后备使用 data-testid
wrapper.find('[data-testid="submit-button"]');
3. 隔离测试 #
javascript
// 每个测试独立挂载组件
beforeEach(() => {
wrapper = mount(Component);
});
afterEach(() => {
wrapper.unmount();
});
下一步 #
现在你已经掌握了 Vue 组件测试,接下来学习 Node.js 测试 实战 Node.js 后端测试!
最后更新:2026-03-28