测试二次封装的 UI 组件

测试二次封装的 UI 组件

Flying
2022-09-08 / 0 评论 / 163 阅读 / 正在检测是否收录...

有了 Testing Library 这个神器之后。编写单元测试变得容易多了。当然,对于基于原生 UI 组件测试来说,这并不是问题。然而在实际开发中,我们会用到第三方的 UI 组件库。比如在国内,开发 Vue 应用一般会选用 Element UI 作为组件库,开发 React 应用一般会选用 antd。这些第三方的 UI 组件库组件很多具有复杂的交互功能,基于它们进行二次封装 UI 组件就比较难测试。原因有如下几个方面。

Vue Testing Library(VTL) 相对于 Vue Test Utils(VTU) 好比 React Testing Library(RTL) 相对于 Enzyme

tl.form.svg

  • UI 组件库测试库太陈旧,缺乏维护。
it('multiple select', done => {
    vm = getSelectVm({ multiple: true });
    const options = vm.$el.querySelectorAll('.el-select-dropdown__item');
    vm.value = ['选项1'];
    setTimeout(() => {
      options[1].click();
      setTimeout(() => {
        options[3].click();
        setTimeout(() => {
          expect(vm.value.indexOf('选项2') > -1 && vm.value.indexOf('选项4') > -1).to.true;
          const tagCloseIcons = vm.$el.querySelectorAll('.el-tag__close');
          tagCloseIcons[0].click();
          setTimeout(() => {
            expect(vm.value.indexOf('选项1')).to.equal(-1);
            done();
          }, 100);
        }, 100);
      }, 100);
    }, 100);
  });

这个用例是基于 Karma 写的,看这层层回调,恐怕很多人会怀疑这测试用例当时是怎么通过测试的😄

  • 使用太多的定时器和内部实现,导致测试二次封装组件时不可控。
  • UI 组件库断层式重构,比如大量的 DOM 层级调整、名称重构——这意味着测试基本上要重写。

本教程主要是对二次开发的输入输入表单组件测试,输入表单组件总是有个输入框来接受输入参数输出表单操作的结果,这些输入输出是用户可以直接看到的,该输入框无疑是交互和测试重点。

选择器组件

这是一个基于 ElSelect 的二次封装基础组件,比较有代表性。改组件只是拥有ElSelect 基础功能:单选多选、本地搜索、支持清空。新增支持数据源参数。因为后端觉得 JSON 数据不好处理,多选时绑定值统一为, 分割的字符串。比如 '1, 2'`。新特性不多,满足项目需求就行。

下面是它的实现源码:

  • 模板
<el-select
  v-model="current"
  :multiple="multiple"
  :placeholder="placeholder"
  filterable
  clearable
  class="base-select"
>
  <el-option
    v-for="item in options"
    :key="item.value"
    :label="item.label"
    :value="item.value"
  />
</el-select>

如你所见,我们通过 filterable 让选择器可搜索,使用 clearable 让选择器支持清空选项。

  • 脚本
const toNumber = (str) => isNaN?.(str) ? str : Number(str)
export default {
  name: 'BaseSelect',
  props: {
    value: {
      type: [Number, String],
      default: undefined
    },
    options: {
      type: Array,
      default: () => []
    },
    multiple: {
      type: Boolean,
      default: false
    },
    placeholder: {
      type: String,
      default: '请选择'
    }
  },
  computed: {
    current: {
      get() {
        const value = this.value
        if (value && typeof value === 'string') {
          if (this.multiple) {
            return value.split(',').map(item => toNumber(item))
          }
          return toNumber(value)
        }
        return value
      },
      set(value) {
        if(this.multiple) {
          value = value.join(',')
        }
        this.$emit('input', value)
      }
    }
  }
}

如你所见,该组件新增了数据源参数 options,有了它就不用实例化选择器时使用 v-for 遍历动态生成选项列表了。这里 options 是个关联数组,我们约定每一项的键名为label,值名为 value

另外,多选时我们将参数 value 值的处理放在了 current 计算属性的 getset 方法中。读取显示时使用字符串 split 方法转换成数组,修改后提交使用数组 join方法拼接成字符串 。

如果需要,可以使用 v-bind="$attrs"v-on="$listeners" 透传所有的 prop 和事件。不过我们这里固定了一些选项功能,也不想兼容原有功能了。如果觉得这个自定义组件远远不能满足你的需求,建议使用原选择器组件。

至于如何自定义 v-model,请参看文章结尾给出的教程链接。

  • 样式
.base-input {
  width: 100%;
}

样式很简单,主要时为了美观统一了让宽度自适应父容器。点击这里查看完整组件代码

选择器组件测试

接下来确定要测试的目标,按照之前我给出的最佳实践,对于这类二次封装的 UI 表单组件,一般要着重从以下几个方面测试:

  • 自定义 v-modelvalue prop 和 input 事件)
  • 新增 prop,如本实例中的 options

下面是基于 BaseSelect.vue 测试代码 BaseSelect.spec.js

Vue 2 版

注意:本实例测试用例基于 Vue 2 + ElementUI + Jest/Vitest + VTL 5.x 编写。
  1. 工具函数

为方便测试这里会用到几个工具函数:

const setUp = (overrides) => {
  return render(BaseSelect, {
    props: {
      options
    }
  })
}

setUp 函数是对 prop 的封装,因为在 BaseSelect.spec.js 中我们的每个测试用例的渲染选项 props 用到的 options 是相同的。

const selectMultiple = (arr: string[] = []) => {
  arr.forEach(tag => {
    expect(screen.getByText(tag, { selector: '.el-select__tags-text' })).toBeInTheDocument()
  })
}

在多选时,选项可以显示为可以关闭的标签(Tag)。getOptionByLabel 函数就是用 Tag 来展示当前选中了那几项。

如果我们有一系列的二次封装组件要测试,可以将自定义渲染器函数、UI 组件库依赖、其他共享的依赖放入一个公共模块,然后统一导出共有函数:

// test-util.ts
import { render, RenderOptions, RenderResult } from '@testing-library/vue'

import ElementPlus from 'element-plus'
import CN from 'element-plus/lib/locale/lang/zh-cn'

export const customRender = (
  ui: any, overrides?: Omit<RenderOptions, 'global'>): RenderResult => {
  return render(ui, {
    global: {
      // (Plugin | [Plugin, ...any[]])[]
      plugins: [[ElementPlus, ...[{ locale: CN }]]]
    },
    ...overrides
  })
}

export { defineComponent, ref, reactive } from 'vue'
export * from '@testing-library/vue'
export { customRender as render }

这个模块自定义了一个渲染器函数,主要全局配置了 Element Plus 插件,并将它与 vue、testing-library/vue 等常用工具函数一起导出。为方便代码维护,原 API 工具函数名不变,自定义函数以原 API 工具函数相同的名称命名以覆盖原函数功能。

然后统一导入 test-util 模块使用:

import {
  fireEvent, screen, render,
  within, defineComponent, reactive
} from '../../test-util'

有没有觉得统一使用一个模块来管理共有函数会方便很多呢?

  1. 测试新增 prop

BaseSelect 组件中,我们只是新增了options prop,这是测试重点:

const options = [{
  value: 1,
  label: '移动'
}, {
  value: 2,
  label: '联通'
}, {
  value: 3,
  label: '电信'
}]

test('renders correctly by default', async () => {
  const { html } = setUp()
  screen.getByPlaceholderText('请选择')
  await fireEvent.click(screen.getByRole('textbox'))
  expect(screen.getAllByRole('listitem'))
    .toHaveLength(options.length)
  expect(html()).toMatchSnapshot()
})

如你所见,这个测试用例主要做了三件事情:

  • placeholder 的初始值是否默认为 请选择
  • options 数组时能正确渲染选项列表。三个数组元素就该渲染出三个选项
  • 使用 toMatchSnapshot() 新建快照
  1. 测试自定义 v-model
test('changes props and emits input event correctly', async () => {
  const placeholder = '请选择营运商'
  const { emitted, updateProps } = setUp()
  const inputEl = screen.getByRole('textbox')
  await updateProps({
    placeholder,
    value: 2
  })
  // change placeholder and select 联通
  screen.getByPlaceholderText(placeholder)
  expect(inputEl).toHaveValue('联通')

  //  trigger an update event by clicking the <option> element.
  await fireEvent.click(inputEl)
  await fireEvent.click(screen.getAllByRole('listitem')[2])
  expect(emitted().input[0]).toEqual([3])
  // select 电信
  await updateProps({ value: 3 })
  expect(inputEl).toHaveValue('电信')
})

如你所见,这个测试用例主要做了三件事情:

  • 初始时使用 updateProps 渲染器方法更改 placeholdervalue 的值,placeholder将修改为 请选择营运商 ,选择器输入框显示为 联通
  • 下拉框出现后,点击 电信 选项,会派发 input 事件并传递 电信的选项值 3`
  • 下拉框隐藏后,选择器输入框显示为 电信
  1. 测试多选
test('Passes and returns a string when select multiple items', async () => {
  const { emitted, updateProps } = setUp({
    multiple: true,
    value: '1,2'
  })

  // current selected 移动, 联通
  selectMultiple(['移动', '联通'])
  // add the last one
  await fireEvent.click(screen.getAllByRole('textbox')[0])
  await fireEvent.click(screen.getAllByRole('listitem')[2])
  expect(emitted().input[0]).toEqual(['1,2,3'])
  // select all
  await updateProps({ value: '1,2,3' })
  selectMultiple(['移动', '联通', '电信'])
})

如你所见,这个测试用例主要做了三件事情:

  • 初始时选中 '1,2',选择器输入框显示为 '移动', '联通' 两个标签
  • 下拉框出现后,点击 电信 选项,会派发 input 事件并传递所有营运商选项值 '1,2,3'
  • 下拉框隐藏后,选择器输入框显示所有营运商的三个标签

点击这里查看完整测试代码

你的测试越像你的软件的使用方式,你就会更有信心

通常情况下,VTL 编写的测试比 VTU 编写的测试更接近操作软件的方式,代码升级重构了,测试也基本不用改动,这也是本文所有实例为啥不使用 VTU 的原因。

Vue 3 版

本实例测试用例基于 Vue 3 + Element Plus + Jest/Vitest + VTL 6.x 编写。

BaseSelect升级到 Vue 3,当然市容选项时 API 时最快的,基本上不用怎样修改代码。这里我们为了检验 VTL 强大的兼容性,决定使用组合式 API。

组件代码我就省去了,源代码可以参考 Vue 3 版 vue3-crud/BaseSelect.vue

现在我们使用 VTL 6.x 来测试这个 Vue 3 组件。测试代码可以参考 Vue 3 版 vue3-crud/BaseSelect.spec.ts

和 Vue 2 版 完整测试代码 相比, Vue 3 测试代码类似,差不多稍稍修改后可以复用:

  • modelValue 对应 valueupdate:modelValue 对应 input 事件名,这是语法 Vue 3 和 Vue 2 v-model 实现的语法差异
  • Vue 3 版测试代码中,对下拉框选项的查询需要加 { hidden: true } 选项,这是 Element UI 升级到 Element Plus 后,选择器组件的下拉框采用了 Teleport 后所做的相应修改。
  • VTL 6.x 中更新 prop 使用 rerender 方法,VTL 5.x 中 则用 updateProps 方法

所以,本质上这两份测试代码时可以复用的。退一步说,就是我们将用 React 来实现该自定义选择器组件,这些测试代码也是可以借鉴的。

React 版

本实例测试用例基于 React + Jest/Vitest + ant + RTL 编写。

组件代码我就省去了,源代码可以参考 Vue 3 版 react-crud/BaseSelect.ts

现在我们使用 RTL 来测试这个 React 组件。测试代码可以参考 React 版 react-crud/BaseSelect.tsx

鉴于 Vue 与 React 语法的不同,它们之间的测试思路类似,但还是有一些差异的:

  • RTL 中没有 html 渲染器属性,这里使用了 container.innerHTML 来代替
  • RTL 中没有 emitted 渲染器方法获取派发事件信息 ,这里使用值 prop + onChange jest.fn() 模拟函数来测试类似 v-model 功能
  • RTL 中没有 fireEvent 事件操作加 await 不起作用,事件操作异步得使用 userEvent
  • UI 组件库 使用 React Elenment 应该比 antd 更接近 Vue 版 测试代码,不过我对 React Elenment 不太熟悉,而且 Element React 已经好几年没更新了。

其他二次封装的基础组件,如 BaseInputBaseDatePickerBasePaginationAreaCascader,测试点测试方法类似,就不一一叙述了。感兴趣的看 github 代码,都有三个版本。

接下来我们讲一个组合了多个基础组件的组件,测试重点和测试方法有些不同。

SaveForm 组件

该组件组合了自定义选择器、输入框、日期,选择器等组件,是添加修改编辑的主界面,包括姓名、市区、地址、手机号这些表单项,如下图所示:

save-form.jpg

以下是 SaveForm 组件的模板:

<el-form ref="form" :model="form" :rules="rules" label-width="80px" class="save-form">
  <el-form-item label="日期" prop="date">
    <base-date-picker v-model="form.date" />
  </el-form-item>
  <el-form-item label="姓名" prop="userName">
    <base-input v-model="form.userName" />
  </el-form-item>
  <el-form-item label="市区" prop="area">
    <area-cascader v-model="form.area" @select="form.areaName = $event.join('')" />
  </el-form-item>
  <el-form-item label="地址" prop="address">
    <base-input v-model="form.address" />
  </el-form-item>
  <el-form-item label="手机号" prop="mobile">
    <base-input v-model="form.mobile" />
  </el-form-item>
  <el-form-item class="footer-item">
    <el-button @click="$emit('cancel')">
      取 消
    </el-button>
    <el-button type="primary" :loading="loading" @click="handleSubmit">
      确 定
    </el-button>
  </el-form-item>
</el-form>    

SaveForm 组件测试

有模板有上下文的组件测试的话,严格来说应该叫住集成测试。没有模板的,就像先前我们的选择器组件的测试应该黑白盒测试,也就是严格意义上的组件测试。集成测试,是把组件放在某个场景下测试。比如,一个输入框组件,如果用来处理 Email和用来处理手机号,是需要测试的验证功能是不同的。这就有了 SaveForm 组件集成测试测试的第一个目标:表单验证。

还有内容应该测试呢?想 SaveForm 组件一般都会绑定数据的,因此修改前组件接收的参数是第二个目标。新建和修改后提交表单触发的事件是第三个目标。

表单填充测试

这是表单填充的 mock 数据:

const initForm = {
  date: '2022-08-15 00:00:00',
  userName: '李四',
  area: 440305,
  areaName: '广东省深圳市南山区',
  address: '望海路33号',
  mobile: '15866666666'
}

以下是表单填充测试代码:

test('fills the form correctly', async () => {
  const {
    getByDisplayValue,
    findByDisplayValue
  } = render(SaveForm, {
    props: {
      value: initForm
    }
  })
  expect(getByDisplayValue(initForm.userName)).toBeInTheDocument()
  expect(getByDisplayValue(initForm.address)).toBeInTheDocument()
  expect(getByDisplayValue(initForm.mobile)).toBeInTheDocument()
  expect(getByDisplayValue(initForm.date.substring(0, 10))).toBeInTheDocument()
  expect(await findByDisplayValue('广东省 / 深圳市 / 南山区')).toBeInTheDocument()
})

当浏览包含已填写值的页面时,确实很有用,我们可以使用 getByDisplayValue 来查询操作元素。

提交事件测试

这是要提交目标 mock 数据:

const newForm = {
  date: '2022-08-16 00:00:00',
  userName: '王五',
  area: 440111,
  areaName: '广东省广州市白云区',
  address: '白云路100号',
  mobile: '15166666666'
}

比较推荐的做法是,基于表单填充进行少量修改再测试提交事件。

test('fills and changes form fields correctly', async () => {
  const { emitted, getByText, getByDisplayValue } = render(SaveForm, {
    props: {
      value: initForm,
      open: true
    }
  })
  const userNameInput = getByDisplayValue(initForm.userName)
  await fireEvent.update(userNameInput, newForm.userName)

  const addressInput = getByDisplayValue(initForm.address)
  await fireEvent.update(addressInput, newForm.address)

  const mobileInput = getByDisplayValue(initForm.mobile)
  await fireEvent.update(mobileInput, newForm.mobile)

  const date = initForm.date.substring(0, 10)
  const gtePicker = getByDisplayValue(date)
  await fireEvent.touch(gtePicker)
  const day = newForm.date.substring(8, 10)
  const beginDay = screen.getAllByRole('cell', { name: day })[0]
  await fireEvent.click(beginDay)

  const areaCascader = await screen.findByDisplayValue('广东省 / 深圳市 / 南山区')
  // actives the dropdown menu by clicking the input
  await fireEvent.click(areaCascader)
  // emits the event by clicking the area
  await fireEvent.click(getByText(/广州市/))
  await fireEvent.click(getByText(/白云区/))

  await fireEvent.click(screen.getByRole('button', { name: /确 定/ }))
  // Assert the right event has been emitted.
  expect(emitted().submit[0]).toEqual([newForm])
})

有几点要注意:

  • 提交事件测试需要表单填充测试查询到的元素。因此可以把这两个步骤合二为一。在项目实践中,我们也是把它合并中合并起来的。
  • 姓名、市区、手机号这些字段都是使用的自定义输入框组。比较简单,fireEvent.update 就可以更新值。
  • 日期、地址字段对应的自定义复杂的选择器组件。相对都比较复杂,需要组合多个 fireEvent 事件才能完成编辑操作。这部分代码大家可以在细化一下。

表单验证测试

代码比较多,这里仅以手机号验证为例:

test('validates phone', async () => {
  const { findByText } = setUp()
  let errMsg = mobile[0].message
  const input = screen.getAllByRole('textbox')[4]
  // required
  await fireEvent.touch(input)
  expect(await findByText(errMsg)).toBeInTheDocument()
  // format
  errMsg = mobile[1].message
  await fireEvent.update(input, '12345678')
  expect(await findByText(errMsg)).toBeInTheDocument()
  // valid
  await fireEvent.update(input, newForm.mobile)
  expect(screen.queryByText(errMsg)).not.toBeInTheDocument()
})

这里我们测试了 mobile 必填以及它的格式合法性。

  • rules 使用了一个模块来管理,方便组件代码和测试代码的同步
  • input 使用了 getAllByRole 查询方法,在不改组件代码的情况下我实在找不到更好的办法了。😓
  • 这里断言错误信息出现是用到了 findByText 方法,消失会用到了 queryByText 方法。可参看之前教程了解更多处理出现消失的信息。

问题

这里两个棘手的问题:

Element UI 表单标签

先前没有赋值时,我们不得不使用 getAllByRole 查询各表单组件,因为在不修改 SaveForm 组件源码的情况下,VTL 中没有更好的查询方法了。照理使用 getByLabelText 方法应该时更好。不过目前 Element UI 表单标签(label)和 input 没有什么联系的。label 找不到对应的 id 的 input,for 属性形同虚设。从测试的角度来说,这是 Element UI 的一个设计缺陷。

- const userNameInput = getAllByRole('textbox')[1]
- const areaCascader = getAllByRole('textbox')[2]
- const addressInput = getAllByRole('textbox')[3]
+ const input = screen.getByLabelText('姓名')
+ const input = screen.getByLabelText('日期')
+ const input = screen.getByLabelText('地址')

要修正这个标签无对应 ID 表单组件的问题,其实不难,我们可以在二次封装 UI 组件添加实现逻辑,以 BadeSelect 组件为例:

mounted() {
  if(this.$parent.label) {
    this.$el.querySelector('input').id = this.$parent.prop
  }
}

inputid 应该是只读的,id 值 = for 属性值 = $parent.prop(父组件表单项的字段名)。

为什么不直接在模板中通过绑定为 input 的 id 赋值呢?那样也可能生成一个空 id

所以通过TDD,我们发现了问题,通过测试指导开发,可以让我们的组件变得更好。

vitest 处理表单验证

这个表单来自 Element Plus 官网文档,用 jest + VTUVTL 进行验证测试,是可以通过测试的。如果使用 vitest,无论是 VTU 还是 VTL,Element Plus 表单的 validate 方法的返回值永远是 true,验证测试就没啥意义了。我向 vitest 官方反映过这个问题,貌似没有解决方案。

链接

1

评论 (0)

取消