第12章 测试策略

第33节 集成测试与E2E测试

12.33.1 Vue Test Utils进阶使用

模拟依赖

在测试组件时,我们经常需要模拟外部依赖,例如API请求、路由、状态管理等。Vue Test Utils提供了多种方式来模拟这些依赖。

模拟组件

当测试一个包含子组件的组件时,我们可以使用stubs选项来模拟子组件:

// src/components/ParentComponent.vue
<template>
  <div>
    <h2>Parent Component</h2>
    <ChildComponent :message="parentMessage" @update="handleUpdate" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const parentMessage = ref('Hello from Parent')

const handleUpdate = (newMessage) => {
  parentMessage.value = newMessage
}
</script>

测试时模拟子组件:

// src/components/ParentComponent.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ParentComponent from './ParentComponent.vue'

describe('ParentComponent.vue', () => {
  it('should pass message to child component', () => {
    const wrapper = mount(ParentComponent, {
      stubs: {
        ChildComponent: {
          template: '<div><slot></slot></div>',
          props: ['message'],
          emits: ['update']
        }
      }
    })
    
    expect(wrapper.exists()).toBe(true)
  })
  
  it('should handle update event from child component', async () => {
    const wrapper = mount(ParentComponent, {
      stubs: {
        ChildComponent: {
          template: '<button @click="$emit(\'update\', \'Updated from Child\')">Update</button>',
          props: ['message'],
          emits: ['update']
        }
      }
    })
    
    await wrapper.find('button').trigger('click')
    // 验证父组件是否处理了子组件的事件
  })
})
模拟路由

当测试使用Vue Router的组件时,我们需要模拟路由环境:

// src/components/RouteComponent.vue
<template>
  <div>
    <h2>Route Component</h2>
    <p>Current Route: {{ $route.path }}</p>
    <router-link to="/about">About</router-link>
    <button @click="goToHome">Go to Home</button>
  </div>
</template>

<script setup>
import { useRouter } from 'vue-router'

const router = useRouter()

const goToHome = () => {
  router.push('/')
}
</script>

测试时模拟路由:

// src/components/RouteComponent.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import RouteComponent from './RouteComponent.vue'

describe('RouteComponent.vue', () => {
  it('should render current route', () => {
    const wrapper = mount(RouteComponent, {
      global: {
        mocks: {
          $route: { path: '/test' },
          $router: {
            push: vi.fn()
          }
        }
      }
    })
    
    expect(wrapper.find('p').text()).toContain('/test')
  })
  
  it('should call router.push when button is clicked', async () => {
    const pushMock = vi.fn()
    const wrapper = mount(RouteComponent, {
      global: {
        mocks: {
          $route: { path: '/test' },
          $router: {
            push: pushMock
          }
        }
      }
    })
    
    await wrapper.find('button').trigger('click')
    expect(pushMock).toHaveBeenCalledWith('/')
  })
})
模拟状态管理

当测试使用Pinia的组件时,我们需要模拟状态管理:

// src/components/StoreComponent.vue
<template>
  <div>
    <h2>Store Component</h2>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
const count = counterStore.count
const increment = counterStore.increment
</script>

测试时模拟状态管理:

// src/components/StoreComponent.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import StoreComponent from './StoreComponent.vue'

describe('StoreComponent.vue', () => {
  it('should render count from store', () => {
    const wrapper = mount(StoreComponent, {
      global: {
        plugins: [createTestingPinia({
          initialState: {
            counter: {
              count: 5
            }
          }
        })]
      }
    })
    
    expect(wrapper.find('p').text()).toContain('5')
  })
  
  it('should call increment when button is clicked', async () => {
    const wrapper = mount(StoreComponent, {
      global: {
        plugins: [createTestingPinia()]
      }
    })
    
    await wrapper.find('button').trigger('click')
    // 验证increment方法是否被调用
  })
})

测试异步组件

Vue 3支持异步组件,我们可以使用defineAsyncComponent来定义异步组件。测试异步组件时,我们需要处理异步加载的情况。

// src/components/AsyncComponent.vue
<template>
  <div>
    <h2>Async Component</h2>
    <Suspense>
      <template #default>
        <AsyncChildComponent />
      </template>
      <template #fallback>
        <div>Loading...</div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncChildComponent = defineAsyncComponent(() => 
  import('./AsyncChildComponent.vue')
)
</script>

测试异步组件:

// src/components/AsyncComponent.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AsyncComponent from './AsyncComponent.vue'

describe('AsyncComponent.vue', () => {
  it('should render fallback content initially', () => {
    const wrapper = mount(AsyncComponent)
    
    expect(wrapper.find('div').text()).toBe('Loading...')
  })
  
  it('should render async component when loaded', async () => {
    // 使用vi.mock来模拟异步组件
    vi.mock('./AsyncChildComponent.vue', () => ({
      default: {
        template: '<div>Async Child</div>'
      }
    }))
    
    const wrapper = mount(AsyncComponent)
    
    // 等待异步组件加载
    await wrapper.vm.$nextTick()
    await wrapper.vm.$nextTick()
    
    expect(wrapper.text()).toContain('Async Child')
  })
})

12.33.2 Testing Library最佳实践

Testing Library简介

Testing Library是一个用于测试UI组件的库,它强调测试组件的行为而不是实现细节。它提供了一系列查询方法,用于查找DOM元素,以及事件触发方法,用于模拟用户交互。

Vue Testing Library是Testing Library的Vue版本,它与Vue Test Utils集成,提供了更符合用户行为的测试方式。

安装Vue Testing Library
npm install @testing-library/vue @testing-library/jest-dom -D

查询原则

Testing Library强调使用用户可见的内容来查找元素,而不是使用选择器或组件内部实现细节。它提供了以下查询方法:

  • getBy...: 查找匹配的元素,如果没有找到或找到多个则抛出错误
  • findBy...: 异步查找匹配的元素,返回Promise
  • queryBy...: 查找匹配的元素,如果没有找到则返回null
  • getAllBy...: 查找所有匹配的元素,返回数组
  • findAllBy...: 异步查找所有匹配的元素,返回Promise
  • queryAllBy...: 查找所有匹配的元素,返回数组

查询后缀包括:

  • Role: 根据ARIA角色查找
  • LabelText: 根据标签文本查找
  • PlaceholderText: 根据占位符文本查找
  • Text: 根据文本内容查找
  • DisplayValue: 根据显示值查找
  • AltText: 根据alt属性查找
  • Title: 根据title属性查找
  • TestId: 根据data-testid属性查找

编写Testing Library测试

<!-- src/components/LoginForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <label for="username">Username</label>
      <input 
        id="username" 
        v-model="username" 
        placeholder="Enter username" 
        data-testid="username-input"
      >
    </div>
    <div>
      <label for="password">Password</label>
      <input 
        id="password" 
        type="password" 
        v-model="password" 
        placeholder="Enter password"
      >
    </div>
    <button type="submit" :disabled="!isFormValid">Login</button>
    <div v-if="error" class="error">{{ error }}</div>
  </form>
</template>

<script setup>
import { ref, computed } from 'vue'

const emit = defineEmits(['login'])

const username = ref('')
const password = ref('')
const error = ref('')

const isFormValid = computed(() => {
  return username.value && password.value
})

const handleSubmit = () => {
  if (username.value === 'admin' && password.value === 'password') {
    emit('login', { username: username.value })
  } else {
    error.value = 'Invalid credentials'
  }
}
</script>

使用Testing Library测试:

// src/components/LoginForm.test.js
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/vue'
import LoginForm from './LoginForm.vue'

describe('LoginForm.vue', () => {
  it('should render login form', () => {
    render(LoginForm)
    
    expect(screen.getByLabelText('Username')).toBeInTheDocument()
    expect(screen.getByLabelText('Password')).toBeInTheDocument()
    expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
  })
  
  it('should disable submit button when form is invalid', () => {
    render(LoginForm)
    
    const button = screen.getByRole('button', { name: /login/i })
    expect(button).toBeDisabled()
  })
  
  it('should enable submit button when form is valid', async () => {
    render(LoginForm)
    
    await fireEvent.update(screen.getByLabelText('Username'), 'admin')
    await fireEvent.update(screen.getByLabelText('Password'), 'password')
    
    const button = screen.getByRole('button', { name: /login/i })
    expect(button).not.toBeDisabled()
  })
  
  it('should show error message for invalid credentials', async () => {
    render(LoginForm)
    
    await fireEvent.update(screen.getByLabelText('Username'), 'wrong')
    await fireEvent.update(screen.getByLabelText('Password'), 'wrong')
    await fireEvent.click(screen.getByRole('button', { name: /login/i }))
    
    expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
  })
  
  it('should emit login event for valid credentials', async () => {
    const { emitted } = render(LoginForm)
    
    await fireEvent.update(screen.getByLabelText('Username'), 'admin')
    await fireEvent.update(screen.getByLabelText('Password'), 'password')
    await fireEvent.click(screen.getByRole('button', { name: /login/i }))
    
    expect(emitted('login')).toHaveLength(1)
    expect(emitted('login')[0][0]).toEqual({ username: 'admin' })
  })
})

异步测试

Testing Library提供了findBy...方法来测试异步内容:

<!-- src/components/AsyncData.vue -->
<template>
  <div>
    <button @click="fetchData">Fetch Data</button>
    <div v-if="loading">Loading...</div>
    <div v-else-if="data">
      <h3>{{ data.title }}</h3>
      <p>{{ data.description }}</p>
    </div>
    <div v-else-if="error">{{ error }}</div>
  </div>
</template>

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

const loading = ref(false)
const data = ref(null)
const error = ref('')

const fetchData = async () => {
  loading.value = true
  try {
    const response = await axios.get('/api/data')
    data.value = response.data
  } catch (err) {
    error.value = 'Failed to fetch data'
  } finally {
    loading.value = false
  }
}
</script>

测试异步组件:

// src/components/AsyncData.test.js
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/vue'
import axios from 'axios'
import AsyncData from './AsyncData.vue'

// 模拟axios
vi.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>

describe('AsyncData.vue', () => {
  it('should fetch data when button is clicked', async () => {
    // 设置模拟返回值
    mockedAxios.get.mockResolvedValueOnce({
      data: {
        title: 'Test Title',
description: 'Test Description'
      }
    })
    
    render(AsyncData)
    
    await fireEvent.click(screen.getByRole('button', { name: /fetch data/i }))
    
    // 等待加载状态
    expect(screen.getByText('Loading...')).toBeInTheDocument()
    
    // 等待数据加载完成
    await waitFor(() => {
      expect(screen.getByText('Test Title')).toBeInTheDocument()
    })
    
    expect(screen.getByText('Test Description')).toBeInTheDocument()
  })
  
  it('should show error when fetch fails', async () => {
    // 设置模拟返回错误
    mockedAxios.get.mockRejectedValueOnce(new Error('Network Error'))
    
    render(AsyncData)
    
    await fireEvent.click(screen.getByRole('button', { name: /fetch data/i }))
    
    // 等待错误信息显示
    await waitFor(() => {
      expect(screen.getByText('Failed to fetch data')).toBeInTheDocument()
    })
  })
})

12.33.3 Cypress E2E测试配置

Cypress简介

Cypress是一个端到端测试框架,它允许我们在真实浏览器中测试整个应用程序。它具有以下特点:

  • 实时重新加载
  • 自动等待
  • 丰富的调试功能
  • 支持网络请求拦截
  • 支持截图和录屏

安装Cypress

npm install cypress -D

配置Cypress

package.json中添加Cypress脚本:

{
  "scripts": {
    "cypress:open": "cypress open",
    "cypress:run": "cypress run"
  }
}

运行Cypress初始化:

npm run cypress:open

这将创建一个cypress目录,包含以下结构:

cypress/
├── fixtures/          # 测试数据
├── integration/       # 测试文件
├── plugins/           # 插件配置
├── support/           # 支持文件
└── cypress.json       # Cypress配置

配置cypress.json

{
  "baseUrl": "http://localhost:3000",
  "viewportWidth": 1280,
  "viewportHeight": 720,
  "defaultCommandTimeout": 5000,
  "video": true,
  "screenshotOnRunFailure": true
}

编写第一个E2E测试

// cypress/integration/login.spec.js
describe('Login Flow', () => {
  beforeEach(() => {
    // 访问登录页面
    cy.visit('/login')
  })
  
  it('should render login page', () => {
    // 验证页面元素
    cy.contains('Login').should('be.visible')
    cy.get('input#username').should('be.visible')
    cy.get('input#password').should('be.visible')
    cy.get('button[type="submit"]').should('be.visible')
  })
  
  it('should login with valid credentials', () => {
    // 输入用户名和密码
    cy.get('input#username').type('admin')
    cy.get('input#password').type('password')
    
    // 提交表单
    cy.get('button[type="submit"]').click()
    
    // 验证是否跳转到首页
    cy.url().should('eq', 'http://localhost:3000/')
    cy.contains('Welcome, admin').should('be.visible')
  })
  
  it('should show error with invalid credentials', () => {
    // 输入无效的用户名和密码
    cy.get('input#username').type('wrong')
    cy.get('input#password').type('wrong')
    
    // 提交表单
    cy.get('button[type="submit"]').click()
    
    // 验证错误信息
    cy.contains('Invalid credentials').should('be.visible')
  })
  
  it('should disable submit button when form is invalid', () => {
    // 只输入用户名
    cy.get('input#username').type('admin')
    
    // 验证按钮是否禁用
    cy.get('button[type="submit"]').should('be.disabled')
    
    // 输入密码
    cy.get('input#password').type('password')
    
    // 验证按钮是否启用
    cy.get('button[type="submit"]').should('not.be.disabled')
  })
})

拦截网络请求

Cypress允许我们拦截和模拟网络请求:

// cypress/integration/products.spec.js
describe('Products Page', () => {
  beforeEach(() => {
    // 拦截API请求并返回模拟数据
    cy.intercept('GET', '/api/products', {
      statusCode: 200,
      body: [
        {
          id: 1,
          name: 'Product 1',
          price: 100
        },
        {
          id: 2,
          name: 'Product 2',
          price: 200
        }
      ]
    }).as('getProducts')
    
    // 访问产品页面
    cy.visit('/products')
  })
  
  it('should display products', () => {
    // 等待API请求完成
    cy.wait('@getProducts')
    
    // 验证产品列表
    cy.contains('Product 1').should('be.visible')
    cy.contains('Product 2').should('be.visible')
    cy.contains('$100').should('be.visible')
    cy.contains('$200').should('be.visible')
  })
  
  it('should filter products', () => {
    // 等待API请求完成
    cy.wait('@getProducts')
    
    // 输入搜索关键词
    cy.get('input[data-testid="search-input"]').type('Product 1')
    
    // 验证只显示匹配的产品
    cy.contains('Product 1').should('be.visible')
    cy.contains('Product 2').should('not.exist')
  })
})

Cypress最佳实践

  1. 使用数据属性:使用data-testid属性来标识测试元素,避免依赖CSS选择器或文本内容
  2. 保持测试独立:每个测试应该是独立的,不依赖其他测试的状态
  3. 使用beforeEach:使用beforeEach来设置测试环境
  4. 等待异步操作:使用cy.wait()或断言来等待异步操作完成
  5. 避免硬编码URL:使用baseUrl配置和相对路径
  6. 使用拦截请求:使用cy.intercept()来模拟API请求,避免依赖真实后端
  7. 保持测试简单:每个测试应该只测试一个功能点
  8. 使用自定义命令:将重复的测试逻辑提取为自定义命令

12.33.4 测试覆盖率与持续集成

覆盖率报告生成

我们可以使用Vitest生成覆盖率报告:

npm run test:coverage

这将在coverage目录下生成覆盖率报告,包括HTML、JSON和文本格式。

覆盖率报告包含以下指标:

  • 语句覆盖率:执行了多少语句
  • 分支覆盖率:执行了多少分支
  • 函数覆盖率:执行了多少函数
  • 行覆盖率:执行了多少行

持续集成配置

我们可以在CI/CD管道中集成测试,确保每次代码变更都通过测试。

GitHub Actions配置
# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [ 16.x, 18.x ]
    
    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v3
      with:
        node-version: ${{ matrix.node-version }}
    - run: npm ci
    - run: npm run build --if-present
    - run: npm run test:run
    - run: npm run test:coverage
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage/coverage-final.json
GitLab CI配置
# .gitlab-ci.yml
image: node:18

stages:
  - install
  - build
  - test

install:
  stage: install
  script:
    - npm ci
  artifacts:
    paths:
      - node_modules/

build:
  stage: build
  script:
    - npm run build
  artifacts:
    paths:
      - dist/

unit-test:
  stage: test
  script:
    - npm run test:run

coverage:
  stage: test
  script:
    - npm run test:coverage
  artifacts:
    paths:
      - coverage/

cypress-test:
  stage: test
  script:
    - npm run dev &
    - npx wait-on http://localhost:3000
    - npm run cypress:run
  artifacts:
    paths:
      - cypress/videos/
      - cypress/screenshots/
    when: always

测试策略建议

  1. 测试金字塔

    • 单元测试:70%
    • 集成测试:20%
    • E2E测试:10%
  2. 测试重点

    • 核心业务逻辑
    • 用户关键路径
    • 边界情况
    • 错误处理
  3. 测试原则

    • 快速:测试应该快速运行
    • 可靠:测试应该稳定,不应该有随机失败
    • 清晰:测试应该易于理解和维护
    • 全面:测试应该覆盖主要功能
  4. 持续改进

    • 定期审查测试覆盖率
    • 移除过时的测试
    • 重构测试代码
    • 学习新的测试技术

最佳实践

  1. 选择合适的测试工具

    • 单元测试:Vitest + Vue Test Utils
    • 组件测试:Vue Testing Library
    • E2E测试:Cypress
  2. 测试命名规范

    • 测试文件:*.test.js*.spec.js
    • 测试套件:使用描述性名称
    • 测试用例:使用"should"开头
  3. 测试隔离

    • 每个测试应该独立运行
    • 避免测试之间的依赖
    • 使用beforeEach/afterEach来设置和清理测试环境
  4. 模拟与真实测试

    • 单元测试:使用模拟依赖
    • 集成测试:使用部分真实依赖
    • E2E测试:使用真实环境
  5. 持续集成

    • 每次提交都运行测试
    • 生成覆盖率报告
    • 配置测试失败通知

小结

本节我们学习了集成测试与E2E测试的基本概念和实践,包括:

  • Vue Test Utils进阶使用,包括模拟依赖、测试异步组件、测试路由组件和状态管理组件
  • Testing Library最佳实践,包括查询原则、事件触发和异步测试
  • Cypress E2E测试配置,包括安装、配置、编写测试和拦截网络请求
  • 测试覆盖率与持续集成,包括覆盖率报告生成和CI配置

通过合理的测试策略,我们可以确保应用程序的质量和稳定性,同时提高开发效率和代码可维护性。在实际项目中,我们应该根据项目的具体情况来选择合适的测试工具和策略,平衡测试覆盖率和开发效率。

思考与练习

  1. 安装Cypress并配置测试环境。
  2. 编写一个测试登录流程的E2E测试。
  3. 编写一个测试产品列表的E2E测试,包括拦截网络请求。
  4. 配置GitHub Actions或GitLab CI,集成测试和覆盖率报告。
  5. 使用Testing Library测试一个表单组件。
« 上一篇 31-unit-testing 下一篇 » 33-application-performance-optimization