Vue 系前端测试策略

2023-04-30coding

Vitest

Vitest 是一个测试框架,类似老框架 Jest,用于运行测试。Vitest 最大的优点是可以和 Vite 整合起来,减少配置复杂度,如果你不用 Vite 的话 Vitest 不一定是最好的选择。

作为一个测试框架,那么最关键的元素也就还是 describetestit)和 expect

import { describe, expect, test } from 'vitest'

const person = {
  isActive: true,
  age: 32,
}

describe('person', () => {
  test('person is defined', () => {
    expect(person).toBeDefined()
  })

  test('is active', () => {
    expect(person.isActive).toBeTruthy()
  })

  test('age limit', () => {
    expect(person.age).toBeLessThanOrEqual(32)
  })
})

比较麻烦的是 mocking

mock 就是在测试中制造一些测试数据,对于一些不需要测试的函数,可以用 mock 的方式隐藏细节,直接提供需要的数据。在调用复杂的 useX 或 ajax 请求时,特别需要 mock。

下面是一些常见的 MockInstance Method

vi.spyOn

spyOn 可以用于监听一个函数,计算调用次数,也可以使用 mockReturnValue 等方法伪造函数。

这是一个官方例子:

// some-path.js
export const getter = 'variable'

// some-path.test.ts
import * as exports from './some-path.js'

vi.spyOn(exports, 'getter', 'get').mockReturnValue('mocked')

可见 spyOn 可以监视整个模块,干预模块内某个输出值的行为,包括变量和函数。但是实际上却有一些问题,自己定义的模块是可以的,某些模块却不能正确 spyOn,例如 vue-router:

import { useRoute } from 'vue-router'
vi.spyOn(exports, 'useRoute').mockReturnValue({ path: '/path' } as any)

测试运行失败,返回 TypeError: Cannot redefine property: useRoute,原因未明(后面会提供解决方案)。

vi.fn

vi.fn() 和 vi.spyOn() 共享相同的方法,但是只有 vi.fn() 的返回结果是可调用的。

根据官网的描述,vi.fn() 返回可执行的函数,而 vi.spyOn() 只对目标行为进行修改。在实践中暂时还是觉得 vi.spyOn() 比较实用,一般只会修改目标行为,让页面模拟调用,并不需要在测试文件上调用模拟函数。

vi.mock

使用提供的 path 替换所有导入的模块为另一个模块。对 vi.mock 的调用是提升的,因此您在哪里调用它并不重要。它将始终在所有导入之前执行。

因为 vi.mock 会被提升,所以不需要在 beforeEach 里写 vi.mock,写了也只有一个生效

// ./some-path.js
export function method() {}

// some-path.test.ts
import { method } from './some-path.js'

vi.mock('./some-path.js', () => ({
  method: vi.fn(),
}))

因此上面路由的问题可以如此解决:

import { useRoute } from 'vue-router'

vi.mock('vue-router', async (importOriginal) => {
  const vr = await importOriginal()
  return { ...(vr as any), useRoute: vi.fn() }
})

// mock 方法时
vi.mocked(useRoute).mockReturnValue({ path: '/system-pipeline-template' } as any)

其实测试文件真的有一点反直觉,很难从代码一下反应出 useRoute 用的到底是什么。虽然你看到用 import { useRoute } from 'vue-router',但因为使用了 vi.mock,最终拿到的 useRoute 其实是 vi.fn()

P.S. vi.mocked 其实是一个 Typescript 辅助函数,可以帮助你通过类型检测,如果是使用 JavaScript 的话可以忽略。

环境变量与全局变量

vi.stubGlobal('__VERSION__', '1.0.0')
vi.stubEnv('VITE_ENV', 'staging')

相比函数的测试,用户界面组件测试才是前端的大问题,测试框架 Vitest 本身并不能解决这个问题,我们还需要使用 Vue Test Utils。

钩子

什么时候使用 beforeEach

使用 vi.clearAllMocks 等方法清除 mock,每个测试用例需要使用相同 mock 时可以在这里统一处理。

__mocks__ 文件夹

// increment.test.js
import { vi } from 'vitest'

// axios is a default export from `__mocks__/axios.js`
import axios from 'axios'

// increment is a named export from `src/__mocks__/increment.js`
import { increment } from '../increment.js'

vi.mock('axios')
vi.mock('../increment.js')

axios.get(`/apples/${increment(1)}`)

mock 方法的函数签名是 (path: string, factory?: () => unknown) => void。若不传入 factory,Vitest 会在 __mocks__ 文件夹找同名文件,将其当成要引入的对象,具体目录结构如下:

- __mocks__
  - axios.js
- src
  __mocks__
    - increment.js
  - increment.js
- tests
  - increment.test.js

断言

https://vitest.dev/api/expect.html

https://cs.ssshooter.com/jest

可以通过 jest-dom 扩展断言库。

Vue Test Utils

Vue Test Utils 简称 VTU,用于模拟挂载组件和触发事件。

模拟组件渲染

const wrapper = mount(MyComponent, {
  props: {
    msg: 'world',
  },
  slots: {
    default: 'Default',
    first: h('h1', {}, 'Named Slot'),
    second: Bar,
  },
  global: {
    plugins: [myPlugin],
  },
})

上面的代码就是在测试中用 mount 函数模拟挂载 TodoApp 组件,并且可以在第二个参数 options 中传入 datapropsplugins 等配置,在得到的结果 wrapper 中对元素模拟操作,实现测试的效果。

组件操作

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

test('creates a todo', () => {
  const wrapper = mount(TodoApp, {
    global: {
      plugins: [myPlugin],
    },
  })
  expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(1)

  wrapper.get('[data-test="new-todo"]').setValue('New todo')
  wrapper.get('[data-test="form"]').trigger('submit')

  expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)
})

返回的 wrapper这些方法,可以使用 setData 等方法修改组件值,也可以通过 vm 属性访问 ComponentPublicInstance,这些方法和属性提供了测试组件内部运行细节的方法,不过这只是测试的一个流派,后面说到的 Vue Testing Library 就推荐我们尽量贴近实际操作。

操作之后判断结果是否正确的方法是检查某些元素是否存在、数量是否正确,类似:expect(wrapper.find('p').exists()).toBe(false)expect(wrapper.findAll('[data-test="todo"]')).toHaveLength(2)

Vue Testing Library

Vue Testing Library 是基于 VTU 的一层封装。

隐藏细节的测试

Testing Library encourages you to avoid testing implementation details like the internals of a component you’re testing (though it’s still possible). The Guiding Principles of this library emphasize a focus on tests that closely resemble how your web pages are interacted by the users.

Testing Library 不主张测试组件的实现细节,它会刻意隐藏 instance 等信息,让你难以测试实例上的方法,你需要通过页面交互后,检查页面元素变化是否正确,以此判断测试是否通过。所以主要函数 render(Component, options)options 也是基于 Vue Test Utils 本身的 options,基本上就是作为一个中介把参数传过去,然后返回查询对象。

使用 VTL 与否还得是看你写测试的习惯。不过 VTL 的存在当然证明忽略细节的测试有他的合理之处,这篇文章 Testing Implementation Details 向你解析了为何不要测试实现细节,大家可以了解一下。

因为隐藏了细节,所以 VTL 的测试方式就相对更简单了,举一个经典例子:

import { render, fireEvent, screen } from '@testing-library/vue'
import Component from './Component.vue'

test('increments value on click', async () => {
  render(Component)

  // screen has all queries that you can use in your tests.
  // getByText returns the first matching node for the provided text, and
  // throws an error if no elements match or if more than one match is found.
  screen.getByText('Times clicked: 0')

  const button = screen.getByText('increment')

  // Dispatch a native click event to our button element.
  await fireEvent.click(button)
  await fireEvent.click(button)

  screen.getByText('Times clicked: 2')
})

render 本质就是对 VTU mount 的包装,而 queryAllByText 之类的方法其实就是很粗暴的遍历匹配:

https://github.dev/testing-library/dom-testing-library

const queryAllByText: AllByText = (
  container,
  text,
  {
    selector = '*',
    exact = true,
    collapseWhitespace,
    trim,
    ignore = getConfig().defaultIgnore,
    normalizer,
  } = {},
) => {
  checkContainerType(container)
  const matcher = exact ? matches : fuzzyMatches
  const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
  let baseArray: HTMLElement[] = []
  if (typeof container.matches === 'function' && container.matches(selector)) {
    baseArray = [container]
  }
  return (
    [
      ...baseArray,
      ...Array.from(container.querySelectorAll<HTMLElement>(selector)),
    ]
      // TODO: `matches` according lib.dom.d.ts can get only `string` but according our code it can handle also boolean :)
      .filter(node => !ignore || !node.matches(ignore as string))
      .filter(node => matcher(getNodeText(node), node, text, matchNormalizer))
  )
}

所以 VTL 会比直接用 VTU 慢很多。而且因为无法进行细节操作,一些函数例如 emit、子组件的方法会无法调用,函数测试率略微下降。

另外如果你使用了一些 UI 库,下拉列表选择之类的模拟会比较麻烦,如果直接用 VTU 的话可以用 setValue,但 VTL 必须模拟点击打开下拉再模拟点击选择。

VTL 仓库提供的测试例子

cheatsheet

这一页 cheatsheet 基本告诉了你所有测试方法。

元素查询

简单来说是 3 个动词:

  • get:查找失败抛错
  • find:自带 waitFor,查找失败抛错
  • query:查找失败返回 null

以上 3 个动词加 All 查找全部,注意,如果不加 All 但是查询到多个元素会抛错

8 个目标:

  • LabelText
  • PlaceholderText
  • Text
  • DisplayValue
  • AltText
  • Title
  • Role
  • TestId

如果使用了 UI 库,触发组件可能会有一定困难,可以尝试使用 container.querySelector 寻找元素进行精准操作。

事件分发

在 VTL 中可以通过下面的方法模拟用户操作:

import { render, fireEvent, screen } from '@testing-library/vue'

fireEvent.click(node)
fireEvent.input(node, event)
fireEvent(node, event)

本质上是借助浏览器事件相关 API:

event = document.createEvent(EventType)
const { bubbles, cancelable, detail, ...otherInit } = eventInit
event.initEvent(eventName, bubbles, cancelable, detail)
element.dispatchEvent(event)

事件类型可参考 MDN | Event

实践

新建一个 Vite 项目,然后直接安装 Vitest 和 VTU:

npm install -D vitest @vue/test-utils @testing-library/vue jsdom

并添加两个命令:

{
  "scripts": {
    "test": "vitest",
    "coverage": "vitest run --coverage"
  }
}

tsconfig.json 加点全局类型:

{
  "compilerOptions": {
    // ...
    "types": ["node", "jsdom", "vitest/globals", "@testing-library/jest-dom"]
    // ...
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

在 vite 配置中加入 vitest 配置,确实,vitest 可以直接读 vite 配置,但是如果使用 ts 的话类型校验会报错,结果还是新建文件比较好:

// vitest.config.ts
import { fileURLToPath } from 'node:url'
import { mergeConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      globals: true,
      environment: 'jsdom',
      root: fileURLToPath(new URL('./', import.meta.url)),
      mockReset: false,
      // ...
    },
  })
)

现在直接运行 npm run test 便能进行最简单的测试,试着运行吧:

include: **/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}
exclude:  **/node_modules/**, **/dist/**, **/cypress/**, **/.{idea,git,cache,output,temp}/**, **/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*
watch exclude:  **/node_modules/**, **/dist/**

No test files found, exiting with code 1

虽然没有效果,但是可以从中知道测试文件的范围。在我现在的项目中所有测试全放在 src 下的 __test__ 文件夹,测试文件夹的结构与 src 保持一致,例如:

src
  __test__
    components
      ComponentOne.spec.ts
    views
      HomeView.spec.ts
  components
    ComponentOne.vue
  views
    HomeView.vue

奇葩需求怎么测试

例如拖拽组件,在测试时可以忽略拖拽操作,把拖拽 mock 为其他操作,然后测试运行结果。当然这也是没办法中的办法而已。

选择 checkbox/radio

const checkbox = getByRole('checkbox', { name: 'label' })
await fireEvent.click(checkbox)
const radio = await findByRole('radio', { name: 'Y' })
await fireEvent.click(radio)

至于为什么在找 role 的时候传入 name 可以找到,我还没研究出确切原因,但是下面这两个链接可以提供一定参考:

总结

  • Vitest 负责测试运行
  • 测试核心关键字 1:mock
  • 测试核心关键字 2:断言
  • Vue Test Utils 负责模拟组件挂载,提供组件测试的可能性
  • VTU 的测试方式是用选择器选择元素修改数据,甚至获取 vm 直接操作,然后检查数据是否正确
  • Vue Testing Library 是 VTU 的二次封装,主张接近用户操作的测试
  • VTL 的测试方式是用可以看到文字选择元素,对元素操作,然后检查新元素是否存在

最后的最后还是要说一句,即使忽略细节测试,也很容易测漏,漏了再补,久而久之也是一大堆测试,而且某些需求改动也可能造成测试大规模修改,如果不是工期非常充足,请不要写测试。


暂时没有留言,要抢沙发吗?
留言