Bootstrap

vue单元测试:mock与替身技巧大全

开篇先提一个问题,什么是 mock?,各位先想想。

前一章节,我们详细了介绍了 vue 单元测试所用到的一些测试方法,但每一个测试 case,最终都会渲染出来真实的组件,再依据真实组件去做断言。

在单元测试中,遵循最小化原则,我们测一个组件,如果想快速的写完单元测试,就没有必要把所有组件都渲染出来。又或者测试用例的编写不想收到各种第三方库的干扰,我们就需要 mock 一些数据,这些数据可以是函数、日期、环境参数、组件、请求数据等等。

mock 优点

上述出现那么多种 mock 根本原因是,测试的环境很难做到跟真实运行的环境一样,只能通过 mock 去尽量靠近真实的环境,使用 mock 会有下面几点优点

  ·更快的测试速度

  · 减少环境依赖,避免风险和干扰

  · 更快的编写单元测试

mock函数

vi.fn

vi.fn()?创建一个新的模拟函数。这个函数可以在测试中作为任何需要通过函数验证行为的地方使用。当你需要一个没有具体实现的通用函数,或者当你想要捕捉函数调用及其参数的信息而不关心该函数是从哪里来的时候,你会用到vi.fn()。

例如那么我们就可以用vi.fn定义一个假的requestFn函数,这个requestFn可以记录是否被调用,调用参数是什么,也可以设置任何假的返回值。我们不需要关注他是不是真的发送请求给后端。

 
  1.    it('vi.fn function', async () => {

  2.    const requestFn = vi.fn((url: string, params: string) => {

  3.    return {

  4.    data: {

  5.    name: 'xxx'

  6.    }

  7.    }

  8.    })

  9.    // 测试代码中调用模拟函数

  10.    requestFn('/url', 'params')

  11.    // 断言模拟函数的调用信息

  12.    expect(requestFn).toHaveBeenCalled()

  13.    expect(requestFn).toHaveBeenCalledWith('/url', 'params') // 被调用的时候参数是 '/url', 'params' })

  14.    expect(requestFn('/url', 'params')).toEqual({

  15.    data: {

  16.    name: 'xxx'

  17.    }

  18.    })

  19.    })

vi.spyOn

vi.spyOn()?用于创建一个模拟函数,并且连接到对象的方法上。vi.spyOn()?允许你监控一个对象上的已有方法的调用情况,而不改变其实现。这在你需要保持原有代码行为同时捕获该函数调用信息时是非常有用的。

可以理解成代理,例如下面有一个 getApples 函数,但在调用的时候,被一个假的getApples给拦截了,充当真的 getApples 函数,但这个真的 apples 函数也会被执行到,有点像是ES6 proxy 的感觉,它也可以记录是否被调用,调用参数是什么,也可以设置任何假的返回值。

 
  1.    it('vi.spyOn function', async () => {

  2.    const cart = {

  3.    getApples: () => {

  4.    console.log('getApples 被执行了') // 假的函数也会被执行到

  5.    return 42

  6.    }

  7.    }

  8.    const spy = vi.spyOn(cart, 'getApples')

  9.    cart.getApples()

  10.    expect(cart.getApples()).toBe(42) // 假的函数也会被执行到

  11.    expect(spy).toHaveBeenCalled()

  12.    })

·vi.fn 是创建一个新的假函数去代替旧的函数,是代替

· vi.spyOn 是拦截了旧函数,并且代理了旧函数调用,并且旧函数也会被执行,是拦截代理

mock 实例

vi.fn 和 vi.spyOn 创建的函数有一些公共方法,下面列举常用的

· 多次修改 mock 函数的返回值,可以使用mockImplementation、mockReturnValue、mockResolvedValue 其中之一,区别是一个是传递函数,一个是直接返回值,一个返回 promise

 
  1.    it('vi.fn function 修改返回值', async () => {

  2.    const requestFn = vi.fn((arg1, arg2) => 'requestFn1')

  3.    expect(requestFn('/url', 'params')).toBe('requestFn1')

  4.    // mockImplementation

  5.    requestFn.mockImplementation((arg1, arg2) => 'requestFn2')

  6.    expect(requestFn('/url', 'params')).toBe('requestFn2')

  7.    // mockReturnValue

  8.    requestFn.mockReturnValue('requestFn3')

  9.    expect(requestFn('/url', 'params')).toBe('requestFn3')

  10.    // mockResolvedValue

  11.    requestFn.mockResolvedValue('requestFn4')

  12.    const res = await requestFn('/url', 'params')

  13.    expect(res).toBe('requestFn4')

  14.    })

  15.    it('vi.spyOn function 修改返回值同理', async () => {

  16.    const cart = {

  17.    getApples: () => 42

  18.    }

  19.    expect(cart.getApples()).toBe(42)

  20.    const spy = vi.spyOn(cart, 'getApples').mockReturnValue(10)

  21.    expect(cart.getApples()).toBe(10)

  22.    expect(spy).toHaveReturnedWith(10)

  23.    })

每次 mock 之后,防止其他 case 用了上一次 mock 出来的脏数据吗,需要重置或者清空,有以下三种方式。

1. vi.mockReset()

当你调用vi.mockReset(),它会重置所有模拟函数的历史记录和实现。这意味着该函数的调用次数、传递的参数、返回的值等信息都会被清除。同时,如果函数有自定义的实现(你可能在调用vi.fn(implementation)时提供了一些模拟逻辑),那么vi.mockReset()会将这个函数重置为一个没有任何实现的模拟函数。

 
  1.    it('mockReset', async () => {

  2.    const mockFunction = vi.fn(() => 'return value')

  3.    expect(mockFunction()).toBe('return value')

  4.    mockFunction.mockReset() // 清除函数的所有调用数据及其自定义实现

  5.    expect(mockFunction()).toBe(undefined)

  6.    })

2. vi.mockClear()

vi.mockClear()方法仅重置mock函数的调用记录,不影响其实现。如果你的模拟函数有自定义的逻辑,调用vi.mockClear()之后,这些自定义逻辑仍会照常工作,只是之前的调用记录不再存在。

 
  1.    it('mockClear', async () => {

  2.    const mockFunction = vi.fn(() => 'return value')

  3.    expect(mockFunction()).toBe('return value')

  4.    mockFunction.mockClear() // 清除函数的调用数据,保留实现

  5.    expect(mockFunction()).toBe('return value')

  6.    })

3. vi.mockRestore()

vi.mockRestore()是与vi.spyOn()一起使用的特殊方法,它不仅重置mock函数的行为和状态,还会恢复被spy函数的原始实现。这在你使用vi.spyOn()来监控某个对象的方法,而在测试结束时希望完全还原这个方法时特别有用。

 
  1.    it('mockRestore', async () => {

  2.    const object = { method: () => 'original return value' }

  3.    const spy = vi.spyOn(object, 'method').mockImplementation(() => 'mocked return value')

  4.    expect(object.method()).toBe('mocked return value')

  5.    spy.mockRestore() // 清除调用数据,并恢复method的原始实现

  6.    expect(object.method()).toBe('original return value')

  7.    })

总之,这三个方法都是在不同场景下管理你的 mock 函数时会用到的,其中vi.mockClear()用于清理调用数据但保留模拟的实现,vi.mockReset()用于完全重置模拟函数(包括它的实现),而vi.mockRestore()则通常与vi.spyOn()配合使用,既重置函数的行为和状态,也还原任何通过spy改变过的方法的原始实现。

mock 环境参数

mock window 属性

 
  1.    it('mock window属性', () => {

  2.    vi.stubGlobal('innerWidth', 100)

  3.    expect(window.innerWidth).toBe(100)

  4.    })

mock 模块

使用 vi.mock 来模拟一个模块,分为两大块

  ·mock 一个第三方 npm 包

  · mock 本地工具函数

mock 一个第三方 npm 包

接下来我们要测试我们自己的基于第三方库 axios 封装的 request 方法

 
  1.   import axios from 'axios'

  2.   export function request(url: string, params: any) {

  3.    // 基于 axios 做各种封装

  4.    return axios.get(url, params)

  5.   }

  6.   import { request } from './request'

  7.   import axios from 'axios'

  8.   it('mock 一个第三方 npm 包', async () => {

  9.    vi.mock('axios')

  10.    const mockedAxios = axios

  11.    mockedAxios.get.mockResolvedValue({ data: 'mocked data' })

  12.    // Call the function with a test param

  13.    const result = await request('test-param', {})

  14.    // Assert that axios.get was called with the correct param

  15.    expect(mockedAxios.get).toHaveBeenCalledWith('test-param', {})

  16.    // Assert that the function returns the correct data

  17.    expect(result).toEqual({ data: 'mocked data' })

  18.   })

mock 模块中的部分方法

vi.mock 一个模块的时候,会把当前的 mock代码模块提升到文件的最顶层,如果直接 mock 一个函数代替导入模块中的其中一个函数,mock 出来的函数会不生效(因为mock的时候,是运行阶段,文件已经在编译阶段被导入了),有三个请求方法,引入了上面基于 axios 封装的 request 请求方法,在测试的时候,我们并不需要再次验证 request  里面的内部逻辑,只需要保证能否请求回来数据即可,那就需要 mock 请求回来的数据。

 
  1.   import { request } from './request'

  2.   export function getList(params: string) {

  3.    return request('/getList', params)

  4.   }

  5.   export function getAge(params: string) {

  6.    return request('/getAgeList', params)

  7.   }

  8.   export function getName(params: string) {

  9.    return request('/getName', params)

  10.   }

  11.   import { getList } from './request'

  12.    it('mock 模块中的一个函数', () => {

  13.    vi.mock('./utils', async () => {

  14.    return {

  15.    ...((await vi.importActual('./utils')) as any),

  16.    getList: vi.fn().mockReturnValue({

  17.    data: 'mockGetList'

  18.    })

  19.    }

  20.    })

  21.    getList('xx')

  22.    expect(getList).toHaveBeenCalledTimes(1)

  23.    expect(getList).toHaveBeenCalledWith('xx')

  24.    expect(getList).toHaveReturnedWith({

  25.    data: 'mockGetList'

  26.    })

  27.    vi.clearAllMocks()

  28.    })

还有一种写法,偶尔也能看到,这里贴一下,使用到了vi.hoisted,它的功能就是回调函数里面的代码,将会提升到文件最前面执行,比 import 和 vi.mock 还早 (ES 模块中的所有静态 import 语句都被提升到文件顶部,因此在导入之前定义的任何代码都将在导入之后执行。)。

 
  1.   import { getList } from './request'

  2.   const { mockedMethod } = vi.hoisted(() => {

  3.    return {

  4.    mockedMethod: vi.fn().mockReturnValue({

  5.    data: 'mockGetList'

  6.    })

  7.    }

  8.   })

  9.   describe('mock 模块中的一个函数', () => {

  10.    it('mock 模块中的一个函数的另一种常用方法', () => {

  11.    vi.mock('./request', async () => {

  12.    return { getList: mockedMethod }

  13.    })

  14.    getList('xx')

  15.    expect(getList).toHaveBeenCalledTimes(1)

  16.    expect(getList).toHaveBeenCalledWith('xx')

  17.    expect(getList).toHaveReturnedWith({

  18.    data: 'mockGetList'

  19.    })

  20.    })

  21.    vi.clearAllMocks()

  22.   })

如果不使用 vi.hoisted

mock 组件

我们在测试一个组件的时候,如果他有很多子组件,子组件如果依赖其他数据或者远程数据等复杂逻辑,那会导致测试复杂度提升或者需要更多考虑子组件等边界。不符合测试的逻辑,也降低测试积极性。所以,如果我们只想要测试当前组件的逻辑,如何防止子组件渲染出来了呢?

正常我们渲染一个带子组件的父组件:

 
  1.   <script setup lang="ts">

  2.   import StubChild from './StubChild.vue'

  3. </script>

  4.   <template>

  5.    <h1>i an parent component</h1>

  6.    <StubChild></StubChild>

  7.    <!-- 会渲染成如下 <h1>i an child component</h1> -->

  8.   </template>

子组件用到了 axios 去请求远程数据,势必会占用测试的时间和增加测试的复杂性。

 
  1.   <script setup lang="ts">

  2.   import axios from 'axios'

  3.   import { onMounted } from 'vue'

  4.   onMounted(() => {

  5.    axios.get('www.baidu.com');

  6.   })

  7. </script>

  8.   <template>

  9.    <h1>i an child component</h1>

  10.   </template>

  11.    it('mount', async () => {

  12.    const wrapper = mount(StubParent)

  13.    // 组件和会渲染成如下

  14.    // <h1>i an parent component</h1>

  15.    // <h1>i an child component</h1>

  16.    console.log(wrapper.html())

  17.    })

我们可以通过stub 参数,把子组件用一个假的替身替代,如果有多个,那就写多个 true

 
  1.    const wrapper = mount(StubParent, {

  2.    global: {

  3.    stubs: {

  4.    StubChild: true

  5.    // A组件名:true

  6.    // B组件名:true

  7.    }

  8.    }

  9.    })

  10.    // 直接渲染

  11.    // <h1>i an parent component</h1>

  12.    // <h1>i an child component</h1>

  13.    expect(wrapper.html()).toMatchInlineSnapshot(`"<h1>i an parent component</h1>

  14.   <stub-child-stub></stub-child-stub>"`)

  15.   组件就会被渲染成 <stub-child-stub></stub-child-stub>

如果有多个子组件,除了使用上面的写法,还有个更加简单的写法,浅渲染模式,默认就会把当前组件的所有子组件都不渲染出来,但这种方式不能自定义渲染内容。

 
  1.    const wrapper = mount(StubParent, {

  2.    shallow: true

  3.    })

  4.   如果用了 shallow: true但希望某个子组件会真实渲染出来。

  5.    const wrapper = mount(StubParent, {

  6.    shallow: true,

  7.    global: {

  8.    stubs: { StubChild: false } // 其中某个子组件会真实渲染出来

  9.    }

  10.    })

如果想自定义子组件渲染的内容,可以写成如下

 
  1.    const wrapper = mount(StubParent, {

  2.    global: {

  3.    stubs: {

  4.    StubChild: {

  5.    template: '<span />'

  6.    }

  7.    }

  8.    }

  9.    })

  10.   

  11.    // <h1>i an parent component</h1>

  12.    // <span></span>

mock teleport

上节课我们知道,直接 mount 一个带有 teleport 组件是不会渲染出来的,如果我们不想测试交互,就只想测试内容是否被渲染出来,可以直接 mock teleport,

 
  1.   test('mock teleport', async () => {

  2.    const wrapper = mount(Teleport, {

  3.    global: {

  4.    stubs: {

  5.    teleport: true

  6.    }

  7.    }

  8.    })

  9.    console.log(wrapper.html())

  10.    const signup = wrapper.getComponent(Signup)

  11.    await signup.get('input').setValue('valid_username')

  12.    await signup.get('form').trigger('submit.prevent')

  13.    expect(signup.emitted().signup[0]).toEqual(['valid_username'])

  14.   })

mock 指令

有时,指令会做一些相当复杂的事情,比如执行大量 DOM 操作,业务测试并不会测试指令(上节课介绍过专门测试指令)

 
  1.    it('tooltip', async () => {

  2.    const wrapper = mount(Directive, {

  3.    global: {

  4.    directives: {

  5.    tooltip: vTooltip

  6.    },

  7.    stubs: {

  8.    vTooltip: true

  9.    }

  10.    }

  11.    })

mock 全局方法和属性

如果我们用到一些全局的方法或者属性,在 template 里是可以直接使用的,但测试当前组件的时候,组件是会报错的。因为全局函数一般都是单独的单元测试,我们并不需要在当前组件里面去测试,往往需要跳过。

 
  1.   app.config.globalProperties.$myGlobalMethod = $myGlobalMethod

  2.   app.config.globalProperties.$myGlobalParams = '$myGlobalParams'

  3.   <script setup lang="ts">

  4.   </script>

  5.   <template>

  6.    <h1>i an child component</h1>

  7.    {{ $myGlobalMethod('member-info') }}

  8.    {{ $myGlobalParams }}

  9.   </template>

我们需要 mocks 全局属性和方法,全局属性和方法,类似 route,route,route,store,直接在 template 上用到,但当前组件没有引用的方法。

 
  1.    it('mock 组件template上用到的全局属性和方法', async () => {

  2.    // 创建 mocks 对象

  3.    const mockMethod = vi.fn().mockReturnValue('mocked $myGlobalMethod')

  4.    const wrapper = mount(MockInstance, {

  5.    global: {

  6.    mocks: {

  7.    // 使用 mock 函数而不是实际的 $myGlobalMethod

  8.    $myGlobalMethod: mockMethod,

  9.    $myGlobalParams: 'mock $myGlobalParams'

  10.    }

  11.    }

  12.    })

  13.    // 现在 $myGlobalMethod 已经被 mock 了,我们可以断言它被调用

  14.    expect(mockMethod).toHaveBeenCalled()

  15.    expect(mockMethod).toHaveBeenCalledWith('member-info')

  16.    // 检查渲染后的 HTML 是否包含了 mock 方法的返回值

  17.    expect(wrapper.html()).toContain('mocked $myGlobalMethod')

  18.    })

特别说明一下,$myGlobalMethod虽然是使用 vi.fn 返回的替身函数,但也可以用一个真实的函数去真实渲染数据。

 
  1.    it('mock 组件template上用到的全局属性和方法,也可以是真实方法', async () => {

  2.    const $t = ()=>{

  3.    return '我是 i18n ,可能会渲染中文或者英文'

  4.    }

  5.    const wrapper = mount(MockInstance, {

  6.    global: {

  7.    mocks: {

  8.    // 使用 mock 函数而不是实际的 $myGlobalMethod

  9.    $myGlobalMethod: $t,

  10.    $myGlobalParams: 'mock $myGlobalParams'

  11.    }

  12.    }

  13.    })

  14.    expect(wrapper.html()).toContain('我是 i18n ,可能会渲染中文或者英文')

  15.    expect(wrapper.html()).toContain('mock $myGlobalParams')

  16.    })

感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!有需要的小伙伴可以点击下方小卡片领取   

;