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