{T}

组件测试

使用 @vue/test-utils 和 Vue Testing Library 测试 Vue 2 组件。

概述

组件测试是 Vue 应用测试的核心环节,主要验证:

  • 渲染输出:组件是否正确渲染预期内容
  • 用户交互:点击、输入等事件是否正确响应
  • 状态管理:组件内部状态变化是否符合预期
  • Props/Events:组件间通信是否正确

一、安装与配置

安装依赖

bash
# Vue Test Utils
npm install --save-dev @vue/test-utils@1 vue-template-compiler

# Jest + Vue Jest
npm install --save-dev jest @vue/vue2-jest babel-jest

# 测试工具
npm install --save-dev jsdom jsdom-global

Jest 配置

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

测试环境设置

javascript
// tests/setup.js
import Vue from 'vue'
import { config } from '@vue/test-utils'

// 全局配置
Vue.config.productionTip = false
Vue.config.devtools = false

// Mock 全局组件
config.stubs = {
  RouterLink: true,
  RouterView: true
}

// 全局 Mock
config.mocks = {
  $route: {},
  $router: {}
}

二、挂载组件

mount 与 shallowMount

方法说明适用场景
mount完整挂载,渲染子组件集成测试
shallowMount浅挂载,替换子组件为存根单元测试
javascript
import { mount, shallowMount } from '@vue/test-utils'
import MyComponent from '@/components/MyComponent.vue'

// 完整挂载
const wrapper = mount(MyComponent, {
  propsData: { title: 'Hello' }
})

// 浅挂载(推荐用于单元测试)
const wrapper = shallowMount(MyComponent, {
  propsData: { title: 'Hello' }
})

mount 与 shallowMount 的底层实现

javascript
// mount: 完整渲染子组件
// 内部调用 createInstance → 子组件完整执行 init → render → mount 全流程
const wrapper = mount(ParentComponent)
// wrapper.find(ChildComponent).exists() → true

// shallowMount: 子组件打桩(stub)
// 内部调用 createInstance → 子组件 VNode 替换为 stub 组件
// stub 组件只渲染占位元素,不执行子组件内部逻辑
const wrapper = shallowMount(ParentComponent)
// wrapper.find(ChildComponent).exists() → false(被 stub 替换)
// 但可以通过 wrapper.find('child-component-stub') 找到占位组件

选择指南:shallowMount 适用于单元测试,确保只测试当前组件逻辑;mount 适用于集成测试,需要验证组件间交互。

挂载选项

javascript
const wrapper = mount(MyComponent, {
  // Props
  propsData: {
    title: 'Test Title',
    items: ['a', 'b', 'c']
  },
  
  // 插槽
  slots: {
    default: '<span>Slot Content</span>',
    footer: '<p>Footer</p>'
  },
  
  // 作用域插槽
  scopedSlots: {
    default: '<template #default="{ item }">{{ item.name }}</template>'
  },
  
  // 全局插件
  mocks: {
    $route: { path: '/test' },
    $store: { state: { user: {} } }
  },
  
  // 存根组件
  stubs: {
    RouterLink: true,
    ChildComponent: { template: '<div class="child-stub"></div>' }
  },
  
  // 局部组件
  components: {
    LocalComponent
  },
  
  // 指令
  directives: {
    myDirective: { /* directive definition */ }
  },
  
  // 事件监听器
  listeners: {
    click: jest.fn()
  },
  
  // 提供的属性
  provide: {
    theme: 'dark'
  }
  
  // 挂载到特定容器
  attachTo: document.body,
  
  // 挂载选项
  attachToDocument: true, // 已弃用,使用 attachTo
  sync: false // 禁用同步更新
})

三、测试渲染输出

选择器查询

javascript
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'

const wrapper = mount(Button, {
  propsData: { text: 'Click Me' }
})

// DOM 选择器
wrapper.find('button')              // CSS 选择器
wrapper.find('.btn-primary')        // 类选择器
wrapper.find('#submit-btn')         // ID 选择器
wrapper.find('[data-testid="btn"]') // 属性选择器

// Vue 组件选择器
wrapper.findComponent(ChildComponent) // 查找子组件
wrapper.findComponent({ name: 'ChildComponent' })

// 查询所有
wrapper.findAll('li')
wrapper.findAllComponents(ChildComponent)

// 检查是否存在
wrapper.find('.not-exist').exists() // false
wrapper.find('.btn-primary').exists() // true

访问元素属性

javascript
// 获取 DOM 元素
const button = wrapper.find('button')
const element = button.element

// 元素属性
element.value           // 输入值
element.checked         // 复选框状态
element.disabled        // 禁用状态
element.textContent     // 文本内容
element.innerHTML       // HTML 内容
element.getAttribute('class') // 属性值

// Wrapper 方法
wrapper.text()          // 文本内容
wrapper.html()          // HTML 字符串
wrapper.classes()       // 类名数组 ['btn', 'btn-primary']
wrapper.classes('active') // 是否有该类名
wrapper.attributes()    // 属性对象
wrapper.attributes('disabled') // 特定属性值
wrapper.props()         // 组件 props
wrapper.props('title')  // 特定 prop 值

测试示例

javascript
// src/components/Alert.vue
<template>
  <div 
    :class="['alert', `alert-${type}`]"
    role="alert"
    data-testid="alert"
  >
    <strong v-if="title">{{ title }}</strong>
    <span>{{ message }}</span>
    <button 
      v-if="dismissible" 
      @click="$emit('dismiss')"
      class="close"
    >
      ×
    </button>
  </div>
</template>

<script>
export default {
  name: 'Alert',
  props: {
    type: {
      type: String,
      default: 'info',
      validator: v => ['info', 'success', 'warning', 'danger'].includes(v)
    },
    title: String,
    message: {
      type: String,
      required: true
    },
    dismissible: Boolean
  }
}
</script>

// tests/components/Alert.spec.js
import { mount } from '@vue/test-utils'
import Alert from '@/components/Alert.vue'

describe('Alert.vue', () => {
  it('应该渲染消息内容', () => {
    const wrapper = mount(Alert, {
      propsData: { message: 'Test Message' }
    })
    
    expect(wrapper.text()).toContain('Test Message')
    expect(wrapper.find('[data-testid="alert"]').exists()).toBe(true)
  })

  it('应该根据 type 设置正确的类名', () => {
    const wrapper = mount(Alert, {
      propsData: {
        message: 'Warning!',
        type: 'warning'
      }
    })
    
    expect(wrapper.classes()).toContain('alert')
    expect(wrapper.classes()).toContain('alert-warning')
  })

  it('有 title 时应该渲染标题', () => {
    const wrapper = mount(Alert, {
      propsData: {
        message: 'Content',
        title: 'Title'
      }
    })
    
    expect(wrapper.find('strong').text()).toBe('Title')
  })

  it('dismissible 为 true 时应该显示关闭按钮', () => {
    const wrapper = mount(Alert, {
      propsData: {
        message: 'Content',
        dismissible: true
      }
    })
    
    expect(wrapper.find('button.close').exists()).toBe(true)
  })

  it('点击关闭按钮应该触发 dismiss 事件', async () => {
    const wrapper = mount(Alert, {
      propsData: {
        message: 'Content',
        dismissible: true
      }
    })
    
    await wrapper.find('button.close').trigger('click')
    
    expect(wrapper.emitted().dismiss).toBeTruthy()
    expect(wrapper.emitted().dismiss.length).toBe(1)
  })
})

四、测试用户交互

触发事件

javascript
const wrapper = mount(Component)

// DOM 事件
await wrapper.trigger('click')
await wrapper.trigger('click', { button: 0 }) // 左键
await wrapper.trigger('dblclick')
await wrapper.trigger('mouseenter')
await wrapper.trigger('mouseleave')

// 键盘事件
await wrapper.trigger('keydown')
await wrapper.trigger('keydown.enter')
await wrapper.trigger('keydown.esc')
await wrapper.trigger('keydown', { key: 'Enter', code: 'Enter' })

// 表单事件
await wrapper.trigger('focus')
await wrapper.trigger('blur')
await wrapper.trigger('change')
await wrapper.trigger('input')

表单交互示例

javascript
// src/components/LoginForm.vue
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label for="email">Email</label>
      <input 
        id="email"
        v-model="form.email"
        type="email"
        data-testid="email-input"
      />
    </div>
    
    <div class="form-group">
      <label for="password">Password</label>
      <input 
        id="password"
        v-model="form.password"
        type="password"
        data-testid="password-input"
      />
    </div>
    
    <button 
      type="submit"
      :disabled="!isValid"
      data-testid="submit-btn"
    >
      Login
    </button>
  </form>
</template>

<script>
export default {
  name: 'LoginForm',
  data() {
    return {
      form: {
        email: '',
        password: ''
      }
    }
  },
  computed: {
    isValid() {
      return this.form.email && this.form.password.length >= 6
    }
  },
  methods: {
    handleSubmit() {
      if (this.isValid) {
        this.$emit('submit', this.form)
      }
    }
  }
}
</script>

// tests/components/LoginForm.spec.js
import { mount } from '@vue/test-utils'
import LoginForm from '@/components/LoginForm.vue'

describe('LoginForm.vue', () => {
  let wrapper

  beforeEach(() => {
    wrapper = mount(LoginForm)
  })

  it('输入字段应该正确绑定 v-model', async () => {
    const emailInput = wrapper.find('[data-testid="email-input"]')
    const passwordInput = wrapper.find('[data-testid="password-input"]')

    await emailInput.setValue('test@example.com')
    await passwordInput.setValue('password123')

    expect(wrapper.vm.form.email).toBe('test@example.com')
    expect(wrapper.vm.form.password).toBe('password123')
  })

  it('表单无效时提交按钮应该禁用', () => {
    const submitBtn = wrapper.find('[data-testid="submit-btn"]')
    
    expect(submitBtn.element.disabled).toBe(true)
  })

  it('表单有效时提交按钮应该启用', async () => {
    await wrapper.find('[data-testid="email-input"]').setValue('test@example.com')
    await wrapper.find('[data-testid="password-input"]').setValue('password123')
    
    const submitBtn = wrapper.find('[data-testid="submit-btn"]')
    expect(submitBtn.element.disabled).toBe(false)
  })

  it('提交时应该发出 submit 事件', async () => {
    await wrapper.find('[data-testid="email-input"]').setValue('test@example.com')
    await wrapper.find('[data-testid="password-input"]').setValue('password123')
    await wrapper.find('form').trigger('submit')

    expect(wrapper.emitted().submit).toBeTruthy()
    expect(wrapper.emitted().submit[0]).toEqual([{
      email: 'test@example.com',
      password: 'password123'
    }])
  })
})

测试组件方法

javascript
const wrapper = mount(Component)

// 直接调用方法
wrapper.vm.someMethod(arg1, arg2)

// 设置 data
await wrapper.setData({ count: 10 })
await wrapper.setData({ user: { name: 'John' } })

// 设置 props
await wrapper.setProps({ title: 'New Title' })

// 设置计算属性(通过设置依赖的 data)
await wrapper.setData({ items: [1, 2, 3] })

五、测试异步行为

Vue.nextTick

javascript
import { mount } from '@vue/test-utils'

it('应该等待 Vue 更新', async () => {
  const wrapper = mount(AsyncComponent)
  
  wrapper.vm.fetchData()
  
  // 等待 DOM 更新
  await wrapper.vm.$nextTick()
  
  // 或使用 Vue.nextTick
  await Vue.nextTick()
  
  expect(wrapper.text()).toContain('Loaded')
})

测试 watch 和 computed

javascript
// src/components/SearchInput.vue
export default {
  data() {
    return {
      query: ''
    }
  },
  watch: {
    query(newVal) {
      this.$emit('search', newVal)
    }
  },
  computed: {
    hasQuery() {
      return this.query.length > 0
    }
  }
}

// tests/components/SearchInput.spec.js
it('query 变化时应该发出 search 事件', async () => {
  const wrapper = mount(SearchInput)
  
  await wrapper.setData({ query: 'test' })
  
  expect(wrapper.emitted().search).toBeTruthy()
  expect(wrapper.emitted().search[0]).toEqual(['test'])
})

it('hasQuery 计算属性应该正确计算', async () => {
  const wrapper = mount(SearchInput)
  
  expect(wrapper.vm.hasQuery).toBe(false)
  
  await wrapper.setData({ query: 'test' })
  
  expect(wrapper.vm.hasQuery).toBe(true)
})

测试异步方法

javascript
// src/components/DataLoader.vue
export default {
  data() {
    return {
      loading: false,
      data: null,
      error: null
    }
  },
  methods: {
    async loadData(id) {
      this.loading = true
      try {
        const response = await api.fetch(id)
        this.data = response.data
        this.$emit('loaded', this.data)
      } catch (error) {
        this.error = error.message
        this.$emit('error', error)
      } finally {
        this.loading = false
      }
    }
  }
}

// tests/components/DataLoader.spec.js
import { mount } from '@vue/test-utils'
import api from '@/api'

jest.mock('@/api')

it('loadData 应该正确处理成功响应', async () => {
  const mockData = { id: 1, name: 'Test' }
  api.fetch.mockResolvedValue({ data: mockData })
  
  const wrapper = mount(DataLoader)
  
  await wrapper.vm.loadData(1)
  
  expect(wrapper.vm.loading).toBe(false)
  expect(wrapper.vm.data).toEqual(mockData)
  expect(wrapper.emitted().loaded[0]).toEqual([mockData])
})

it('loadData 应该正确处理错误', async () => {
  const error = new Error('Network Error')
  api.fetch.mockRejectedValue(error)
  
  const wrapper = mount(DataLoader)
  
  await wrapper.vm.loadData(1)
  
  expect(wrapper.vm.loading).toBe(false)
  expect(wrapper.vm.error).toBe('Network Error')
  expect(wrapper.emitted().error[0]).toEqual([error])
})

使用 flush-promises

bash
npm install --save-dev flush-promises
javascript
import flushPromises from 'flush-promises'

it('应该等待所有 Promise 完成', async () => {
  const wrapper = mount(AsyncComponent)
  
  wrapper.vm.fetchData()
  
  await flushPromises()
  
  expect(wrapper.text()).toContain('Loaded')
})

六、测试 Vuex 集成

创建测试 Store

javascript
import Vuex from 'vuex'
import { createLocalVue, mount } from '@vue/test-utils'
import storeConfig from '@/store'
import Component from '@/components/Component.vue'

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

describe('Vuex 集成测试', () => {
  let store
  let wrapper

  beforeEach(() => {
    store = new Vuex.Store(storeConfig)
    wrapper = mount(Component, {
      store,
      localVue
    })
  })
})

简化 Mock Store

javascript
// tests/utils/mock-store.js
export function createMockStore(state = {}, actions = {}, getters = {}) {
  return {
    state,
    getters,
    dispatch: jest.fn(),
    commit: jest.fn(),
    subscribe: jest.fn(),
    ...actions
  }
}

// 使用
const wrapper = mount(Component, {
  mocks: {
    $store: createMockStore(
      { count: 10 },
      {},
      { doubleCount: 20 }
    )
  }
})

测试示例

javascript
// src/components/Counter.vue
<template>
  <div>
    <span data-testid="count">{{ count }}</span>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
  </div>
</template>

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

export default {
  computed: {
    ...mapState(['count'])
  },
  methods: {
    ...mapActions(['increment', 'decrement'])
  }
}
</script>

// tests/components/Counter.spec.js
import Vuex from 'vuex'
import { createLocalVue, mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

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

describe('Counter.vue', () => {
  let store
  let actions
  let wrapper

  beforeEach(() => {
    actions = {
      increment: jest.fn(),
      decrement: jest.fn()
    }
    
    store = new Vuex.Store({
      state: { count: 5 },
      actions
    })
    
    wrapper = mount(Counter, {
      store,
      localVue
    })
  })

  it('应该显示 store 中的 count', () => {
    expect(wrapper.find('[data-testid="count"]').text()).toBe('5')
  })

  it('点击 + 按钮应该调用 increment action', async () => {
    await wrapper.findAll('button')[0].trigger('click')
    expect(actions.increment).toHaveBeenCalled()
  })

  it('点击 - 按钮应该调用 decrement action', async () => {
    await wrapper.findAll('button')[1].trigger('click')
    expect(actions.decrement).toHaveBeenCalled()
  })
})

七、测试 Vue Router

Mock Router

javascript
import { mount } from '@vue/test-utils'

const $route = {
  path: '/user/123',
  params: { id: '123' },
  query: { tab: 'profile' }
}

const wrapper = mount(Component, {
  mocks: {
    $route
  }
})

Mock $router

javascript
const $router = {
  push: jest.fn(),
  replace: jest.fn(),
  go: jest.fn()
}

const wrapper = mount(Component, {
  mocks: {
    $router
  }
})

// 验证
expect($router.push).toHaveBeenCalledWith('/home')

使用真实的 Vue Router

javascript
import VueRouter from 'vue-router'
import { createLocalVue, mount } from '@vue/test-utils'

const localVue = createLocalVue()
localVue.use(VueRouter)

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

const router = new VueRouter({ routes })

const wrapper = mount(Component, {
  router,
  localVue
})

八、快照测试

基本快照

javascript
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'

it('应该匹配快照', () => {
  const wrapper = mount(Button, {
    propsData: {
      text: 'Click Me',
      type: 'primary'
    }
  })
  
  expect(wrapper.html()).toMatchSnapshot()
})

更新快照

bash
# 更新所有快照
jest --updateSnapshot

# 更新特定测试的快照
jest --updateSnapshot --testNamePattern="Button"

内联快照

javascript
it('应该匹配内联快照', () => {
  const wrapper = mount(Button, {
    propsData: { text: 'Submit' }
  })
  
  expect(wrapper.html()).toMatchInlineSnapshot()
})

九、最佳实践

1. 使用 data-testid

Vue SFC
<template>
  <!-- 推荐:使用 data-testid -->
  <button data-testid="submit-btn">Submit</button>
  
  <!-- 不推荐:依赖 CSS 类名 -->
  <button class="btn btn-primary">Submit</button>
</template>
javascript
// 查询元素
const btn = wrapper.find('[data-testid="submit-btn"]')

2. 隔离测试

javascript
// 每个测试都应该独立
beforeEach(() => {
  jest.clearAllMocks()
  wrapper = mount(Component)
})

afterEach(() => {
  wrapper.destroy()
})

3. 测试行为而非实现

javascript
// ❌ 不推荐:测试实现细节
it('应该设置 loading 为 true', async () => {
  await wrapper.vm.loadData()
  expect(wrapper.vm.loading).toBe(false)
})

// ✅ 推荐:测试可见行为
it('应该显示加载状态', async () => {
  wrapper.vm.loadData()
  expect(wrapper.find('.loading').exists()).toBe(true)
  
  await flushPromises()
  expect(wrapper.find('.loading').exists()).toBe(false)
  expect(wrapper.text()).toContain('Data loaded')
})

4. 合理使用 shallowMount

javascript
// 单元测试:使用 shallowMount
const wrapper = shallowMount(ParentComponent)

// 集成测试:使用 mount
const wrapper = mount(ParentComponent)

十、常见问题

Q1: 找不到组件怎么办?

javascript
// 确保正确导入组件
import MyComponent from '@/components/MyComponent.vue'

// 检查组件名称
wrapper.findComponent({ name: 'MyComponent' })

Q2: 如何测试 transition?

javascript
// 在测试中禁用 transition
const wrapper = mount(Component, {
  stubs: {
    transition: false // 或 true
  }
})

Q3: 如何测试 teleport?

javascript
// Vue 2 没有 teleport,但可以测试类似逻辑
const wrapper = mount(PortalComponent, {
  attachTo: document.body
})

// 测试完成后清理
afterEach(() => {
  wrapper.destroy()
})

相关资源


E2E 测试

一、Cypress

安装与配置

bash
# 安装 Cypress
npm install --save-dev cypress

# Vue CLI 插件方式
vue add e2e-cypress

# 初始化
npx cypress open

项目结构

code
project/
├── cypress/
│   ├── fixtures/          # 测试数据
│   │   └── users.json
│   ├── integration/       # 测试文件
│   │   ├── login.spec.js
│   │   └── dashboard.spec.js
│   ├── plugins/           # 插件配置
│   │   └── index.js
│   ├── support/           # 支持文件
│   │   ├── commands.js    # 自定义命令
│   │   └── index.js
│   └── screenshots/       # 截图
├── cypress.json           # Cypress 配置
└── package.json

配置文件

javascript
// cypress.json
{
  "baseUrl": "http://localhost:8080",
  "viewportWidth": 1280,
  "viewportHeight": 720,
  "video": true,
  "videoCompression": 32,
  "screenshotOnRunFailure": true,
  "defaultCommandTimeout": 10000,
  "requestTimeout": 10000,
  "responseTimeout": 30000,
  "fixturesFolder": "cypress/fixtures",
  "integrationFolder": "cypress/integration",
  "supportFile": "cypress/support/index.js",
  "pluginsFile": "cypress/plugins/index.js",
  "testFiles": "**/*.spec.js",
  "ignoreTestFiles": "*.skip.js",
  "env": {
    "apiUrl": "http://localhost:3000/api",
    "userEmail": "test@example.com",
    "userPassword": "password123"
  }
}

基本语法

javascript
// cypress/integration/example.spec.js

describe('测试套件名称', () => {
  beforeEach(() => {
    // 每个测试前执行
    cy.visit('/')
  })

  it('测试用例名称', () => {
    // 断言
    cy.get('h1').should('contain', 'Welcome')
  })

  afterEach(() => {
    // 每个测试后执行
  })
})

选择器

javascript
// CSS 选择器
cy.get('button')
cy.get('.btn-primary')
cy.get('#submit')
cy.get('[data-testid="login-btn"]')

// 文本内容
cy.contains('Submit')
cy.contains('button', 'Submit')

// 表单元素
cy.get('input[name="email"]')
cy.get('input[type="password"]')

// 组合选择器
cy.get('form').find('button')
cy.get('ul').children('li')
cy.get('li').first()
cy.get('li').last()
cy.get('li').eq(2)
cy.get('li').filter('.active')

// 关系选择器
cy.get('input').parent()
cy.get('input').parents('form')
cy.get('li').siblings()
cy.get('p').next()
cy.get('p').prev()

操作命令

javascript
// 页面导航
cy.visit('/login')
cy.visit({ url: '/search', qs: { q: 'vue' } })
cy.go('back')
cy.go('forward')
cy.reload()

// 点击
cy.get('button').click()
cy.get('button').click({ force: true })
cy.get('button').click({ multiple: true })
cy.get('button').dblclick()
cy.get('button').rightclick()

// 输入
cy.get('input').type('Hello')
cy.get('input').type('Hello{enter}')
cy.get('input').type('{selectall}{backspace}')
cy.get('input').clear()
cy.get('input').clear().type('New value')

// 选择
cy.get('select').select('option-value')
cy.get('select').select(['value1', 'value2'])
cy.get('input[type="checkbox"]').check()
cy.get('input[type="radio"]').check('value')
cy.get('input[type="checkbox"]').uncheck()

// 文件上传
cy.get('input[type="file"]').selectFile('cypress/fixtures/file.pdf')
cy.get('input[type="file"]').selectFile({
  contents: 'cypress/fixtures/file.pdf',
  fileName: 'document.pdf',
  mimeType: 'application/pdf'
})

// 滚动
cy.get('.element').scrollIntoView()
cy.scrollTo(0, 500)
cy.scrollTo('bottom')

// 等待
cy.wait(1000)
cy.wait('@apiRequest')

// 焦点
cy.get('input').focus()
cy.get('input').blur()

// 触发事件
cy.get('button').trigger('mouseover')
cy.get('input').trigger('keydown', { key: 'Enter' })

断言

javascript
// 基本断言
cy.get('h1').should('exist')
cy.get('h1').should('not.exist')
cy.get('h1').should('be.visible')
cy.get('h1').should('be.hidden')
cy.get('input').should('be.disabled')
cy.get('input').should('be.enabled')
cy.get('input').should('be.checked')
cy.get('input').should('be.focused')

// 内容断言
cy.get('h1').should('contain', 'Welcome')
cy.get('h1').should('have.text', 'Welcome')
cy.get('h1').should('have.html', '<span>Welcome</span>')
cy.get('input').should('have.value', 'test@example.com')
cy.get('input').should('be.empty')

// 属性断言
cy.get('a').should('have.attr', 'href', '/home')
cy.get('input').should('have.attr', 'placeholder')
cy.get('button').should('have.class', 'active')
cy.get('button').should('not.have.class', 'disabled')

// 数量断言
cy.get('li').should('have.length', 5)

// CSS 断言
cy.get('div').should('have.css', 'color', 'rgb(0, 0, 0)')
cy.get('div').should('have.css', 'display', 'block')

// 链式断言
cy.get('button')
  .should('be.visible')
  .and('have.class', 'btn-primary')
  .and('contain', 'Submit')

// 自定义断言
cy.get('h1').should(($h1) => {
  expect($h1).to.have.length(1)
  expect($h1.text()).to.match(/Welcome/)
})

网络请求

javascript
// 拦截请求
cy.intercept('GET', '/api/users').as('getUsers')
cy.intercept('POST', '/api/users').as('createUser')
cy.intercept('DELETE', '/api/users/*').as('deleteUser')

// 等待请求完成
cy.wait('@getUsers')
cy.wait('@createUser').its('response.statusCode').should('eq', 201)

// Mock 响应
cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [
    { id: 1, name: 'John' },
    { id: 2, name: 'Jane' }
  ]
})

// 动态 Mock
cy.intercept('GET', '/api/users', (req) => {
  req.reply({
    statusCode: 200,
    body: mockUsers
  })
})

// 修改请求
cy.intercept('POST', '/api/users', (req) => {
  req.body.createdAt = new Date().toISOString()
}).as('createUser')

// 模拟错误
cy.intercept('GET', '/api/users', {
  statusCode: 500,
  body: { error: 'Internal Server Error' }
})

// 延迟响应
cy.intercept('GET', '/api/users', {
  delay: 2000,
  body: mockUsers
})

测试示例:登录流程

javascript
// cypress/integration/auth/login.spec.js

describe('登录功能', () => {
  beforeEach(() => {
    cy.visit('/login')
  })

  it('应该显示登录表单', () => {
    cy.get('[data-testid="login-form"]').should('be.visible')
    cy.get('[data-testid="email-input"]').should('exist')
    cy.get('[data-testid="password-input"]').should('exist')
    cy.get('[data-testid="login-btn"]').should('exist')
  })

  it('输入无效邮箱应该显示错误提示', () => {
    cy.get('[data-testid="email-input"]').type('invalid-email')
    cy.get('[data-testid="login-btn"]').click()
    
    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', '请输入有效的邮箱地址')
  })

  it('成功登录应该跳转到首页', () => {
    // Mock 登录请求
    cy.intercept('POST', '/api/auth/login', {
      statusCode: 200,
      body: {
        token: 'mock-token',
        user: { id: 1, name: 'Test User' }
      }
    }).as('login')

    cy.get('[data-testid="email-input"]').type('test@example.com')
    cy.get('[data-testid="password-input"]').type('password123')
    cy.get('[data-testid="login-btn"]').click()

    cy.wait('@login')
    cy.url().should('include', '/dashboard')
    cy.get('[data-testid="welcome-message"]')
      .should('contain', 'Welcome, Test User')
  })

  it('登录失败应该显示错误信息', () => {
    cy.intercept('POST', '/api/auth/login', {
      statusCode: 401,
      body: { error: 'Invalid credentials' }
    }).as('login')

    cy.get('[data-testid="email-input"]').type('test@example.com')
    cy.get('[data-testid="password-input"]').type('wrongpassword')
    cy.get('[data-testid="login-btn"]').click()

    cy.wait('@login')
    cy.get('[data-testid="error-message"]')
      .should('be.visible')
      .and('contain', '用户名或密码错误')
  })
})

测试示例:购物车

javascript
// cypress/integration/shop/cart.spec.js

describe('购物车功能', () => {
  beforeEach(() => {
    cy.intercept('GET', '/api/products', { fixture: 'products.json' })
    cy.visit('/products')
  })

  it('应该能够添加商品到购物车', () => {
    cy.get('[data-testid="product-item"]').first().as('firstProduct')
    
    cy.get('@firstProduct')
      .find('[data-testid="add-to-cart-btn"]')
      .click()

    cy.get('[data-testid="cart-count"]')
      .should('contain', '1')

    cy.get('[data-testid="cart-icon"]').click()
    
    cy.get('[data-testid="cart-item"]').should('have.length', 1)
  })

  it('应该能够修改商品数量', () => {
    // 先添加商品
    cy.get('[data-testid="add-to-cart-btn"]').first().click()
    cy.get('[data-testid="cart-icon"]').click()

    // 增加数量
    cy.get('[data-testid="quantity-increase"]').click()
    cy.get('[data-testid="item-quantity"]').should('contain', '2')

    // 减少数量
    cy.get('[data-testid="quantity-decrease"]').click()
    cy.get('[data-testid="item-quantity"]').should('contain', '1')
  })

  it('应该能够删除购物车商品', () => {
    cy.get('[data-testid="add-to-cart-btn"]').first().click()
    cy.get('[data-testid="cart-icon"]').click()

    cy.get('[data-testid="remove-item-btn"]').click()

    cy.get('[data-testid="cart-empty-message"]')
      .should('be.visible')
      .and('contain', '购物车为空')
  })

  it('应该能够完成结账流程', () => {
    cy.intercept('POST', '/api/orders', {
      statusCode: 201,
      body: { orderId: 'ORD-001' }
    }).as('createOrder')

    // 添加商品
    cy.get('[data-testid="add-to-cart-btn"]').first().click()
    
    // 进入购物车
    cy.get('[data-testid="cart-icon"]').click()
    cy.get('[data-testid="checkout-btn"]').click()

    // 填写表单
    cy.get('[data-testid="address-input"]').type('123 Main St')
    cy.get('[data-testid="city-input"]').type('New York')
    cy.get('[data-testid="phone-input"]').type('1234567890')

    // 提交订单
    cy.get('[data-testid="place-order-btn"]').click()

    cy.wait('@createOrder')
    cy.url().should('include', '/order/success')
    cy.get('[data-testid="order-id"]').should('contain', 'ORD-001')
  })
})

自定义命令

javascript
// cypress/support/commands.js

// 登录命令
Cypress.Commands.add('login', (email, password) => {
  cy.session([email, password], () => {
    cy.intercept('POST', '/api/auth/login').as('login')
    
    cy.visit('/login')
    cy.get('[data-testid="email-input"]').type(email)
    cy.get('[data-testid="password-input"]').type(password)
    cy.get('[data-testid="login-btn"]').click()
    cy.wait('@login')
    
    cy.getCookie('auth_token').should('exist')
  })
})

// 等待元素
Cypress.Commands.add('waitForElement', (selector, timeout = 10000) => {
  cy.get(selector, { timeout }).should('be.visible')
})

// 数据库操作
Cypress.Commands.add('resetDatabase', () => {
  cy.request('POST', '/api/test/reset-database')
})

// 使用
cy.login('test@example.com', 'password123')
cy.waitForElement('[data-testid="dashboard"]')

二、Nightwatch

安装与配置

bash
# 安装
npm install --save-dev nightwatch chromedriver

# Vue CLI 插件方式
vue add e2e-nightwatch

配置文件

javascript
// nightwatch.conf.js
module.exports = {
  src_folders: ['tests/e2e'],
  output_folder: 'tests/e2e/reports',
  
  test_settings: {
    default: {
      launch_url: 'http://localhost:8080',
      screenshots: {
        enabled: true,
        path: 'tests/e2e/screenshots'
      },
      desiredCapabilities: {
        browserName: 'chrome',
        'goog:chromeOptions': {
          args: ['--headless', '--no-sandbox']
        }
      },
      webdriver: {
        start_process: true,
        server_path: require('chromedriver').path
      }
    },
    chrome: {
      desiredCapabilities: {
        browserName: 'chrome',
        'goog:chromeOptions': {
          args: ['--window-size=1280,720']
        }
      }
    },
    firefox: {
      desiredCapabilities: {
        browserName: 'firefox'
      },
      webdriver: {
        start_process: true,
        server_path: require('geckodriver').path
      }
    }
  }
}

基本语法

javascript
// tests/e2e/login.spec.js
module.exports = {
  '登录页面测试': function (browser) {
    const login = browser.page.loginPage()
    
    login.navigate()
      .assert.title('Login')
      .assert.visible('@emailInput')
      .setValue('@emailInput', 'test@example.com')
      .setValue('@passwordInput', 'password123')
      .click('@submitBtn')
      .waitForElementVisible('@welcomeMessage', 5000)
    
    browser.end()
  }
}

Page Object 模式

javascript
// tests/e2e/pages/loginPage.js
module.exports = {
  url: '/login',
  
  elements: {
    emailInput: 'input[name="email"]',
    passwordInput: 'input[name="password"]',
    submitBtn: 'button[type="submit"]',
    errorMessage: '.error-message',
    welcomeMessage: '.welcome-message'
  },
  
  commands: [{
    login(email, password) {
      return this
        .setValue('@emailInput', email)
        .setValue('@passwordInput', password)
        .click('@submitBtn')
    },
    
    expectError(message) {
      return this.assert.containsText('@errorMessage', message)
    }
  }]
}

三、测试策略

测试金字塔

code
            /\
           /E2E\        10% - 关键流程
          /------\
         / 组件测试 \     20% - 核心组件
        /----------\
       /   单元测试  \   70% - 工具/逻辑
      /--------------\

测试优先级

优先级测试内容原因
P0用户注册/登录核心功能入口
P0支付流程关键业务
P0核心业务流程主要收入来源
P1数据展示页面用户主要操作
P1表单提交数据创建
P2次要功能使用频率低

测试数据管理

javascript
// cypress/fixtures/users.json
{
  "validUser": {
    "email": "test@example.com",
    "password": "Test123!"
  },
  "adminUser": {
    "email": "admin@example.com",
    "password": "Admin123!"
  }
}

// 使用 fixture
cy.fixture('users').then((users) => {
  cy.get('[data-testid="email"]').type(users.validUser.email)
  cy.get('[data-testid="password"]').type(users.validUser.password)
})

环境变量

javascript
// cypress.json
{
  "env": {
    "apiUrl": "http://localhost:3000/api"
  }
}

// 使用环境变量
const apiUrl = Cypress.env('apiUrl')
cy.request(`${apiUrl}/users`)

// 命令行传递
// npx cypress run --env apiUrl=http://api.example.com

四、最佳实践

1. 使用 data-testid

Vue SFC
<template>
  <!-- 推荐 -->
  <button data-testid="submit-btn">Submit</button>
  
  <!-- 不推荐 -->
  <button class="btn btn-primary btn-large">Submit</button>
</template>

2. 独立测试

javascript
// 每个测试应该独立运行
beforeEach(() => {
  cy.resetDatabase()
  cy.clearCookies()
  cy.visit('/')
})

3. 明确等待

javascript
// 推荐:等待元素可见
cy.get('[data-testid="loading"]').should('not.exist')
cy.get('[data-testid="data"]').should('be.visible')

// 不推荐:固定等待
cy.wait(5000)

4. 可读性

javascript
// 推荐:清晰的描述
it('用户输入有效邮箱和密码后应该成功登录', () => {})

// 不推荐:模糊的描述
it('测试登录', () => {})

5. 错误处理

javascript
it('应该正确处理网络错误', () => {
  cy.intercept('GET', '/api/data', { forceNetworkError: true })
  
  cy.visit('/dashboard')
  
  cy.get('[data-testid="error-message"]')
    .should('contain', '网络错误,请稍后重试')
})

五、CI/CD 集成

GitHub Actions

yaml
# .github/workflows/e2e.yml
name: E2E Tests

on: [push, pull_request]

jobs:
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '16'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run E2E tests
        uses: cypress-io/github-action@v5
        with:
          start: npm run serve
          wait-on: 'http://localhost:8080'
          wait-on-timeout: 120
      
      - name: Upload screenshots
        uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/screenshots

Docker 集成

dockerfile
# Dockerfile for E2E testing
FROM cypress/included:12.0.0

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

RUN npx cypress verify

六、常见问题

Q1: 测试不稳定怎么办?

  • 使用明确的等待条件
  • 避免 cy.wait() 固定等待
  • Mock 不稳定的外部依赖
  • 使用 cy.retry() 增加重试
javascript
// 配置重试
// cypress.json
{
  "retries": {
    "runMode": 2,
    "openMode": 0
  }
}

Q2: 如何测试文件下载?

javascript
cy.get('[data-testid="download-btn"]').click()
cy.readFile('cypress/downloads/file.pdf').should('exist')

Q3: 如何测试多标签页?

javascript
cy.get('a[target="_blank"]').invoke('removeAttr', 'target').click()
cy.url().should('include', '/new-page')

Q4: 如何在测试中访问 Vue 组件?

javascript
cy.window().then((win) => {
  const app = win.__VUE_APP__
  console.log(app.$store.state)
})

相关资源

下一步