IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    Vue 系前端测试策略

    usubeni fanstay发表于 2023-04-30 13:49:03
    love 0

    Vitest

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

    作为一个测试框架,那么最关键的元素也就还是 describe、test(it)和 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 的话可以忽略。

    在一些有深层依赖的组件来说,很多基础依赖都会影响测试运行(是的,跟正常运行一样,不只是那个文件的直接依赖,无论间接直接,都会被调用),一一在每个测试文件写 mock 函数是不现实的,针对这个问题,我们可以使用 __mocks__ 文件夹。

    __mocks__ 文件夹

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

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

    使用例子:

    // 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)}`)

    P.S. 如果引入的路径带有 @ 等别名,那么 mock 的时候也需要使用别名,如 vi.mock('@/api/test.ts')

    环境变量与全局变量

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

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

    钩子

    什么时候使用 beforeEach?

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

    断言相关

    • 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 中传入 data、props、plugins 等配置,在得到的结果 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 可以找到,我还没研究出确切原因,但是下面这两个链接可以提供一定参考:

    • https://w3c.github.io/accname
    • https://github.dev/eps1lon/dom-accessibility-api

    总结

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

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



沪ICP备19023445号-2号
友情链接