手把手教你写一个 headless 无头组件的单元测试、集成测试、E2E 测试

作者:易师傅github

声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

在上文,我们已经知道了如何去实现一个 headless ui 无头组件,我们都知道要想让自己的组件经受得起千锤百炼,那么一个合格的组件测试那是必不可少的;

接下来咱们就开始去实现一个 headless ui 无头组件的测试用例吧!


为了体现咱们的库的多样化,这篇文章的主要基于 Hover Card 无头组件 来实现;

其实 Hover Card 无头组件 功能和上一篇文章所讲的 Popover 无头组件 的功能类似,只能把触发事件由 click 变成了 hover 事件,其它内容几乎大差不大。

一、分析 Hover Card 无头组件功能

1)效果展示

直接上图:

那么我们能看到其实这其中也是包括了这几个关键的子组件:

  • HoverCardRoot:根组件
  • HoverCardTrigger:触发器
  • HoverCardContent:触发内容
  • HoverCardArrow:箭头

看到这里是不是和上一篇文章所讲的 Popover 无头组件 很相似,是的,的确很相似。

2)全部组件展示

那么既然我们知道了基本的效果,那么我们看下完整的组件使用案例:

ts 复制代码
<script setup>
import { HoverCardArrow, HoverCardContent, HoverCardPortal, HoverCardRoot, HoverCardTrigger } from '@yi-ui/vue'
</script>

<template>
  <HoverCardRoot>
    <!-- 触发器 -->
    <HoverCardTrigger />
    <!-- 触发内容 -->
    <HoverCardPortal>
      <HoverCardContent>
        <div>
            render content
        </div>
        <!-- 触发内容的箭头 -->
        <HoverCardArrow />
      </HoverCardContent>
    </HoverCardPortal>
  </HoverCardRoot>
</template>

能看到这就是一个完整的的Hover Card 无头组件使用;

我再介绍一下 HoverCardPortal 是干嘛的:

一个基于 Vue 内置组件 Teleport 封装的子组件,主要作用与 Vue 内置的 Teleport 作用一致

3)代码实现

因为这篇重点不在这里,如果大家硬是要了解如何实现,可以看看我上一篇文章如何实现 Popover 无头组件,相似度可以达到 80%;

我们这里主要看下代码分布即可:

接下来我们就是主要去实现 __test__ 中的代码逻辑.

二、单元测试

1)测试前准备

为了更好的测试到每一个功能点,我们需要安装一些必须的工具库:

  • vitest:这个就不解释了
  • @vitest/coverage-istanbul:检测代码覆盖率
  • @vue/test-utils :官方的底层组件测试库,用来提供给用户访问 Vue 特有的 API。先在测试文件中导入需要测试的 Vue.js 组件,再使用 mount() 方法创建一个组件实例,并可传入 props、数据等参数。
  • vitest-axe:测试库,检查程序的可访问性;可访问性测试检查用户界面是否可供每个用户轻松使用,并使我们的应用程序可用于残障人士。
  • jsdom:模拟浏览器的 Dom。
  • @testing-library/jest-dom :提供了一组自定义 jest 匹配器,可以使用它们来扩展 vitest 的常用 API(例如 toBeInTheDocument),可以使测试用例更具声明性且更易于阅读和维护。
  • @testing-library/user-event:尝试模拟触发用户与浏览器交互时在浏览器中发生的真实事件
  • @testing-library/vue:与 @vue/test-utils 类似,一个非常轻量级的专注于测试组件而不依赖于实现细节的 Vue 测试库,且包含了 @vue/test-utils 的功能。

我们推荐在应用中使用 @vue/test-utils 测试组件。@testing-library/vue 在测试带有 Suspense 的异步组件时存在问题,在使用时需要谨慎。

以上的工具库就是编写测试用例时所必须的了,下面就开始手把手的开干了~

2)vitest 使用

安装与配置

这个在之前的文章手动实现一个无头组件库就有介绍了,就不重复赘述了

进入 __test__ 目录下新建 index.test.ts 文件:因为一般情况下,执行测试的文件名中必须包含 .test..spec.

常用 API 介绍:

  • test:别名又是 it,定义了一组相关的测试期望, 它接收测试名称和保存测试期望的函数。
  • describe:在当前上下文中定义一个新的测试用例,作为一组相关测试、基准以及其他嵌套的测试用例。
  • beforeEach:注册一个回调函数,在当前上下文中的每个测试运行前调用。
  • afterEach:注册一个回调函数,在当前上下文中的每个测试完成后调用。
  • beforeAll:注册一个回调函数,在开始运行当前上下文中的所有测试之前调用一次。
  • afterAll:注册一个回调函数,以便在当前上下文中所有测试运行完毕后调用一次。
  • expect:主要用于创建断言,也就是判断你写的玩意正不正确。

了解了相关介绍,我们接着往下看;

简单的案例使用:

ts 复制代码
import { beforeEach, afterEach, beforeAll, afterAll, describe, expect, test, it } from 'vitest'
import { ref } from 'vue'

// 因为有三个测试用例,所以 beforeEach 会执行三次
const a = ref(0)
beforeEach(async () => {
    a.value = 2
    console.log('a 赋值为:', a.value);
})

// 只会执行一次
const b = ref(0)
beforeAll(async () => {
    b.value = 3
    console.log('b 赋值为:', b.value);
})

describe('一个默认的悬浮组件', () => {
    it('测试两个数值相加', () => {
        expect.soft(1 + 2).toBe(3) // 期望 1 + 2 等于 3,实际等于 3
    })

    test('测试两个数值相减', () => {
        expect(2 - 1).toBe(1) // 期望 2 - 1 等于 1,实际等于 1
    })

    it('测试一个数值是否等于 3', () => {
        expect(a.value).toBe(3) // 期望 a 等于 3,实际等于 2
    })
})

// 因为有三个测试用例,所以 afterEach 会执行三次
afterEach(async () => {
    a.value = 0
    console.log('初始化 a 的值为:', a.value);
})

// 只会执行一次
afterAll(async () => {
    b.value = 0
    console.log('初始化 b 的值为:', b.value);
})

执行结果:

到这里我们就已经学会使用了 Vitest 的最最最基本的使用了;

3)Vue3 组件测试(@vue/test-utils)

因为上面我们要测试的是一个成熟的 Vue 组件,那么上面的案例很明显还不达标,所以 @vue/test-utils 库就必不可少了。

而且我们上面也简单的介绍了 @vue/test-utils 库的概念:

Vue 官方的底层组件测试库,用来提供给用户访问 Vue 特有的 API。

3.1 安装

bash 复制代码
pnpm i @vue/test-utils -Dw

3.2 常用 API 介绍与使用

@vue/test-utils 是 Vue.js 官方提供的用于编写单元测试和集成测试的工具库。下面是一些 @vue/test-utils 中常用的 API 介绍:

  1. mount: 一个常用的方法,用于挂载 Vue 组件到一个虚拟 DOM 中,并返回一个包装器。可以通过这个包装器来访问和操作组件。

  2. shallowMount:mount 类似,但是它会挂载组件,但不会渲染其子组件。适用于测试一个组件而不涉及其子组件。

    ts 复制代码
    import { shallowMount, mount } from '@vue/test-utils';
    import MyComponent from '@/components/MyComponent.vue';
    
    // 浅挂载
    const wrapperShallow = shallowMount(MyComponent);
    
    // 完全挂载
    const wrapperFull = mount(MyComponent);
  3. find: 用于查找组件中的子元素或子组件。

    ts 复制代码
    const wrapper = mount(MyComponent);
    const button = wrapper.find('button');
  4. findAll:find 类似,但会返回所有匹配的元素或组件。

    ts 复制代码
    // 查找所有段落
    const wrapper = mount(MyComponent);
    const all_p = wrapper.findAll('p');
  5. setData: 用于设置组件的 Data 数据。

    ts 复制代码
    const wrapper = mount(MyComponent);
    wrapper.setData({ count: 10 });
  6. setProps: 用于设置组件的 props。

    ts 复制代码
    const wrapper = mount(MyComponent);
    wrapper.setProps({ message: 'Hello' });
  7. attributesclasses

    attributes 用于获取元素的属性,classes 用于获取元素的类名。

    ts 复制代码
    const wrapper = mount(MyComponent);
    // 获取按钮的属性
    const buttonAttributes = wrapper.find('button').attributes();
    
    // 获取段落的类名
    const paragraphClasses = wrapper.find('p').classes();
  8. emittedemittedByOrder

    emitted 用于检查事件是否被触发,emittedByOrder 用于获取按顺序触发的事件。

    ts 复制代码
    const wrapper = mount(MyComponent);
    // 检查事件是否触发
    expect(wrapper.emitted().submit).toBeTruthy();
    
    // 获取按顺序触发的事件
    const events = wrapper.emittedByOrder();
  9. trigger: 用于触发组件的事件。

    ts 复制代码
    const wrapper = mount(MyComponent);
    wrapper.find('button').trigger('click');
  10. vm: 可以访问 Vue 组件实例。

    ts 复制代码
    const wrapper = mount(MyComponent);
    const vm = wrapper.vm;
  11. destroy:用于卸载组件。

    ts 复制代码
    const wrapper = mount(MyComponent);
    wrapper.destroy();

这些是 @vue/test-utils 中一些常用的 API,用于编写 Vue.js 组件的单元测试和集成测试。

当然还有更加高级的功能,如 contains, isVueInstance 等。

通过这些 API,我们可以很方便的编写测试用例,模拟用户操作和验证组件O不OK。

4)模拟用户行为测试(@testing-library/user-event

我们都知道,headless ui 组件库主要是一个交互逻辑行为,所以在测试这块,我们必须要对此下重手;

其实上面的 @vue/test-utils 库能通过 trigger 触发一些基本的用户事件,例如点击鼠标移入移出等等;

但是 @vue/test-utils 是专为Vue.js设计,深度集成 Vue 特性,提供的是 Vue 组件测试一站式解决方案。


所以我们为了遵循以用户为中心的测试原则,且提供的 API 能很好模拟用户视角下的操作,鼓励测试代码与实际用户交互保持一致。

我们还需要引入@testing-library/user-event 来更逼真地模拟用户交互。

4.1安装&介绍

bash 复制代码
pnpm i @testing-library/user-event -Dw

@testing-library/user-event 是一个 JavaScript 库,专为模拟用户与网页界面之间的真实交互行为而设计。

它与 @testing-library/dom 和特定框架的 Testing Library(如@testing-library/vue)紧密结合,提供了高级的、符合浏览器行为的事件触发机制。

使用@testing-library/user-event的主要目的是确保测试代码能够精确地模拟用户在浏览器中的操作,如鼠标点击、键盘输入、拖放等,从而更有效地测试组件的交互逻辑和响应。

4.2 常见方法示例

  1. 文本输入:

    • userEvent.type(element: Element, text: string, options?: TypeOptions) 模拟用户在指定元素上逐字符输入文本。可以传递选项(如delay)来控制输入速度。
  2. 鼠标点击与悬停:

    • userEvent.click(element: Element, options?: ClickOptions) 模拟用户在指定元素上单击。可以传递选项(如buttonctrlKey等)来模拟不同的鼠标按键和修饰键状态。
    • userEvent.hover(element: Element) 模拟用户将鼠标光标悬停在指定元素上。
  3. 键盘交互:

    • userEvent.keyboard(text: string, init?: KeyboardEventInit) 模拟用户按下一系列键盘键。可以传递KeyboardEventInit对象来设置修饰键和其他属性。
  4. 选择与复选:

    • userEvent.selectOptions(select: Element, values: Array<string | Element>) 模拟用户在下拉选择框(<select>元素)中选择指定的选项。
    • userEvent.check(element: Element, init?: MouseEventInit) 模拟用户勾选一个复选框(<input type="checkbox">元素)。
    • userEvent.uncheck(element: Element, init?: MouseEventInit) 模拟用户取消勾选一个复选框。
  5. 拖放操作:

    • userEvent.dragAndDrop(source: Element, target: Element, init?: DragEventInit) 模拟用户将一个元素拖放到另一个元素上。

此外,@testing-library/user-event还提供了其他方法来模拟用户交互,如dblClick(双击)、clear(清除输入框内容)、tab(按Tab键切换焦点)等。

这些方法力求尽可能地模拟真实用户的交互过程,包括触发相关的事件序列和浏览器默认行为,使得测试更加贴近实际使用场景。

4.3 使用

新建 HoverCard.vue:

ts 复制代码
<template>
  <div class="wrapper">测试 axe</div>

  <button @click="onHandler">按钮点击</button>

    <div class="hover-card" v-if="visible">
        显示的内容    
    </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const visible = ref(false)

const onHandler = () => {
  console.log('点击了按钮')
  visible.value = !visible.value
}
</script>

编辑 index.test.ts:

ts 复制代码
import { beforeEach, afterEach, beforeAll, afterAll, describe, expect, test, it } from 'vitest'
// import { axe } from 'vitest-axe'
import HoverCard from './HoverCard.vue'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import userEvent from '@testing-library/user-event'


describe('一个默认的悬浮组件', () => {
    let wrapper: VueWrapper<InstanceType<typeof HoverCard>>
    beforeEach(() => {
        wrapper = mount(HoverCard, { attachTo: document.body })
    })

    it('测试 HoverCard 组件中的 click 事件', async () => {
        const user = userEvent.setup()
        const button = wrapper.find('button')
        await user.click(button.element)
        expect(wrapper.find('.hover-card').isVisible()).toBe(true)
    })
})

运行结果:

5)无障碍可访问性测试(vitest-axe)

根据之前的文章介绍,我们了解到了,Headless UI 还有一个很重要的概念,那就是可访问性,所谓可访问性那就是要符合

5.1 介绍

vitest-axe 是一个用于 Vue.js 应用程序的可访问性测试工具,它基于 Axe Core 来研发的,同时是 fork 了 jest-axe 来进行二次开发,可以帮助开发人员检测应用程序中的无障碍问题。

介绍:Axe-Core 是一个流行的开源库,用于自动化检测网页的可访问性问题,确保网站符合 WAI-ARIA 和 WCAG(Web Content Accessibility Guidelines)等无障碍标准,提升残障用户使用体验。

5.2 常用 API 介绍

  1. axe:参数为待测元素(通常是DOM节点),执行 Axe Core 的检查,并返回包含检查结果的对象;然后使用Vitest 的 expect 断言 api 来验证检查结果中存在的可访问性违规。

  2. configureAxe:这个函数用于配置 Axe Core,可以设置一些选项,例如忽略某些规则等。

5.3 使用

第一步:安装

bash 复制代码
pnpm i vitest-axe -Dw

第二步:配置 vitest.setup.ts 启用无障碍测试支持

ts 复制代码
import { expect } from 'vitest'
import type { AxeMatchers } from 'vitest-axe/matchers'
import * as matchers from 'vitest-axe/matchers'
import { configureAxe } from 'vitest-axe'
import "vitest-axe/extend-expect";

expect.extend(matchers)

// 拓展 .d.ts 的属性,例如 matchers 中的 toHaveNoViolations,如果你的项目没有用到 ts,可以忽略
declare module 'vitest' {
  export interface Assertion extends AxeMatchers {}
  export interface AsymmetricMatchersContaining extends AxeMatchers {}
}

然后在 vitest.config.ts 中添加 setupFiles: './vitest.setup.ts', 即可。

然后在 tsconfig.json 中添加 include: ['./vitest.setup.ts'], 即可。


第三步:测试文件 index.test.ts 中正式编写

首先创建一个组件 HoverCard.vue:

ts 复制代码
<template>
  <div class="wrapper">测试 axe</div>
</template>

<script setup lang="ts">

</script>

然后再 index.test.ts 中引入编写:

ts 复制代码
import { beforeEach, afterEach, beforeAll, afterAll, describe, expect, test, it } from 'vitest'
import { axe } from 'vitest-axe'
import HoverCard from './HoverCard.vue'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'

describe('一个默认的悬浮组件', () => {
    let wrapper: VueWrapper<InstanceType<typeof HoverCard>>
    beforeEach(() => {
        // 挂载一个 vue 组件
        wrapper = mount(HoverCard, { attachTo: document.body })
    })

    it('测试 HoverCard 组件是否渲染成功', () => {
        expect(wrapper.exists()).toBe(true)
    })

    test('测试 HoverCard 组件是否通过了可访问性测试', async () => {
        expect(await axe(wrapper.element)).toHaveNoViolations()
    })
})

第四步:渲染结果

咱们会看到可访问性测试没通过,这是咋回事呀,那当然是因为我们写的 HoverCard.vue 不符合要求啦,所以接下来我们再重新改造一下。

三、编写测试组件代码

1)前提

在上面,我们介绍了 HoverCard 无头组件的基本使用,代码如下:

ts 复制代码
<script setup>
import { HoverCardArrow, HoverCardContent, HoverCardPortal, HoverCardRoot, HoverCardTrigger } from '@yi-ui/vue'
</script>

<template>
  <HoverCardRoot>
    <!-- 触发器 -->
    <HoverCardTrigger />
    <!-- 触发内容 -->
    <HoverCardPortal>
      <HoverCardContent>
        <div>
            render content
        </div>
        <!-- 触发内容的箭头 -->
        <HoverCardArrow />
      </HoverCardContent>
    </HoverCardPortal>
  </HoverCardRoot>
</template>

所以接下来,我们就根据这个来进行自定义拓展了

我们来实现一个如下的组件功能:

2)代码实现

ts 复制代码
<script setup lang="ts">
import { ref } from 'vue';
import {
  HoverCardArrow,
  HoverCardContent,
  HoverCardPortal,
  HoverCardRoot,
  HoverCardTrigger,
} from '@/HoverCard';

const hoverState = ref(false);
</script>

<template>
  <HoverCardRoot v-model:open="hoverState">
    <HoverCardTrigger
      class="inline-block cursor-pointer rounded-full shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] outline-none focus:shadow-[0_0_0_2px_white]"
    >
      悬浮鼠标
    </HoverCardTrigger>
    <HoverCardPortal>
      <HoverCardContent
        class="data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] rounded-md bg-white p-5 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] data-[state=open]:transition-all"
        :side-offset="5"
      >
        <div class="flex flex-col gap-[7px]">
          <div class="flex flex-col gap-[15px]">悬浮内容1</div>
          <div class="flex flex-col gap-[15px]">悬浮内容2</div>
          <div class="flex flex-col gap-[15px]">悬浮内容3</div>
          <div class="flex flex-col gap-[15px]">悬浮内容4</div>
        </div>
        <HoverCardArrow class="fill-white" :width="8" />
      </HoverCardContent>
    </HoverCardPortal>
  </HoverCardRoot>
</template>

很明显我这里是使用了 tailwindcss 风格来写的样式,如果你写的话,可以不用写样式,直接使用即可;

那么接下来我们就要对这个文件来进行单元测试用例的编写了。

3)单元测试用例关联

3.1 测试组件是否渲染成功

ts 复制代码
import { beforeEach, afterEach, beforeAll, afterAll, describe, expect, test, it } from 'vitest'
import HoverCard from './HoverCard.vue'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'


describe('一个默认的悬浮组件', () => {
    let wrapper: VueWrapper<InstanceType<typeof HoverCard>>
    beforeEach(() => {
        wrapper = mount(HoverCard, { attachTo: document.body })
    })

    it('测试 HoverCard 组件是否渲染成功', () => {
        expect(wrapper.exists()).toBe(true)
    })
})

3.2 测试组件的可访问性测试

ts 复制代码
import { axe } from 'vitest-axe'

it('测试 HoverCard 组件是否通过了可访问性测试', async () => {
    expect(await axe(wrapper.element)).toHaveNoViolations()
})

3.3 测试组件的用户模拟事件

ts 复制代码
describe('模拟 HoverCard 组件中的鼠标移入100ms后', () => {
    it('是否应该通过可访问性测试', async () => {
        await wrapper.find('a').trigger('mouseenter')
        await sleep(100)
        expect(await axe(document.body)).toHaveNoViolations()
    })
})

4)运行结果

那么到这里,我们的组件单元测试就写完了,其实细看整体还是很简单的,只要弄懂几个关键库的基本使用基本就可以解决 80% 的问题了。

四、测试覆盖率

运行

bash 复制代码
pnpm coverage

运行结束后会生成一个 coverage 文件,在这里就可以看到你的测试案例的覆盖率了

打开对应的 index.html

当然覆盖率可视化不仅只有这个,比如还有 vitest/uistorybook 等等,这些取舍就看你个人的了,大家明白即可。

总结

本文主要介绍了,headless ui基本组件测试,还有从用户角度出发的 E2E测试,我相信大家都能有所收获,肯定会对 vue 组件的测试有一定的很深入的了解。

接下来的安排:

  • 文档撰写
  • 支持 Nuxt 调试
  • 打包构建

Headless UI 往期相关文章:

  1. 在 2023 年屌爆了一整年的 shadcn/ui 用的 Headless UI 到底是何方神圣?
  2. 实战开始 🚀 在 React 和 Vue3 中使用 Headless UI 无头组件库
  3. 无头组件库既然这么火 🔥 那么我们自己手动实现一个来完成所谓的 KPI 吧
  4. 泰裤辣 🚀 原来实现一个 Popover 无头组件比传统组件简单辣么多!!

如果想跟我一起讨论技术吹水摸鱼 , 欢迎加入前端学习群聊

如果扫码人数满了,可以扫码添加个人 vx 拉你:JeddyGong

感谢大家的支持,码字实在不易,其中如若有错误,望指出,如果您觉得文章不错,记得 点赞关注加收藏 哦 ~

关注我,带您一起搞前端 ~

相关推荐
GIS程序媛—椰子34 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00140 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端43 分钟前
Content Security Policy (CSP)
前端·javascript·面试
木舟10091 小时前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤43911 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落1 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt