基础知识-单元测试

327 阅读14分钟

Mock 大全

参考 写了一本开源小书《Jest 实践指南》

之前一直没有跟大家讲 Jest 的 Mock,就是想让大家先体会体会 Mock 的便利性以及复杂性。如果没有真正用过就看总结, 你会觉得:就这?

在前面一些章节,我们都遇到了不少 Mock 的场景,比如 window.location.href、Http 请求、函数的 Mock 等等。 相信对 Jest 的 Mock 都有大致印象了,所以这一章就来总结一下 Jest Mock 的一些实用场景吧。

一次性 Mock

这里的 “一次性” 是指在一个文件只 Mock 一次。Jest 的官方文档 在 Mock Functions 这一章 (opens new window)写了一些这种 Mock 的用法, 这里简单说一下。

Mock 模块

类似 axios 这样的第三方 NPM 库,可以这样实现 Mock:

import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};
  axios.get.mockResolvedValue(resp);

  // 你也可以使用下面这样的方式:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then(data => expect(data).toEqual(users));
});

但是这样的方法 并 不 好 用!  一般我们都会在项目里用 TypeScript,而 axios.get 是没有 jest 这些类型的,所以会报以下错误:

TS2339: Property 'mockResolveValues' does not exist on type '  >(url: string, config?: AxiosRequestConfig | undefined) => Promise '.

1

正确的用法应该是用 jest.spyOn 来代替上面这种写法:

import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};

  jest.spyOn(axios, 'get').mockResolvedValue(resp);

  // 你也可以使用下面这样的方式:
  // jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve(resp))

  return Users.all().then(data => expect(data).toEqual(users));
});

如果你非要用 axios.get.mockImplementation,那么建议你使用 ts-jest 里的 helper 函数 (opens new window)mocked

import { mocked } from 'ts-jest/utils'
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};

  const mockedGet = mocked(axios.get); // 带上 jest 的类型提示
  mockedGet.mockResolvedValue(resp); // 含有 jest 的类型提示

  return Users.all().then(data => expect(data).toEqual(users));
});

ts-jest@28.0 已经把 mocked 移除了!这个函数被放到 jest-mock@27.4.0 这个包里了(内置到 Jest)!

部分依赖

上面会把整个模块的实现都给干掉,如果只想 Mock 部分内容,官方也提供了对应的写法:

// foo-bar-baz.js
export const foo = 'foo';
export const bar = () => 'bar';
export default () => 'baz';
//test.js
import defaultExport, {bar, foo} from '../foo-bar-baz';

jest.mock('../foo-bar-baz', () => {
  // 真实的 foo-bar-baz 模块内容
  const originalModule = jest.requireActual('../foo-bar-baz');

  // Mock 默认导出和 foo 的内容
  return {
    __esModule: true,
    ...originalModule,
    default: jest.fn(() => 'mocked baz'),
    foo: 'mocked foo',
  };
});

test('should do a partial mock', () => {
  const defaultExportResult = defaultExport();
  expect(defaultExportResult).toBe('mocked baz');
  expect(defaultExport).toHaveBeenCalled();

  expect(foo).toBe('mocked foo');
  expect(bar()).toBe('bar');
});

要注意的是:jest.mock 和 jest.unmock 是一对非常特殊的 API,它们会被提升到所有 import 前。也就是说,上面这段代码看起是先 import 再 mock,而真实情况是,先 mock 了,再 import:

// jest.mock 会被提升到所有 import 前
jest.mock('../foo-bar-baz', () => {
  // 真实的 foo-bar-baz 模块内容
  const originalModule = jest.requireActual('../foo-bar-baz');

  // Mock 默认导出和 foo 的内容
  return {
    __esModule: true,
    ...originalModule,
    default: jest.fn(() => 'mocked baz'),
    foo: 'mocked foo',
  };
});

import defaultExport, {bar, foo} from '../foo-bar-baz';

test('should do a partial mock', () => {
  const defaultExportResult = defaultExport();
  expect(defaultExportResult).toBe('mocked baz');
  expect(defaultExport).toHaveBeenCalled();

  expect(foo).toBe('mocked foo');
  expect(bar()).toBe('bar');
});

只有这样你从 '../foor-bar-baz' 拿到的内容才是 Mock 内容。所以,也推荐大家在用 jest.mock 和 jest.unmock 这两个 API 时最好写成先 mock 后 import 来避免理解上的歧义。

有同学会问:除了这俩还有没有别的 API 会这样提升的呢?我搜了很多地方,大家只需要记住这两就好了。

TIP

这样的提升代码形为原本是通过 babel-plugin-jest-hoist 这个插件实现的,所以你在选 Jest 的转译器时,也要留意一下这些小坑。不过目前大部分的转译工具都有这个功能了。

多次 Mock

官网对 Mock 的展示到上面就结束了。然而,我们经常会在同一个测试文件中给对象、函数、变量进行多次 Mock,以此模拟多种用例场景。

举个例子,我们添加一个配置文件 src/utils/env.ts

// src/utils/env.ts
export const config = {
  getEnv() {
    // 很复杂的逻辑...
    return 'test'
  }
}

假如我们想测试一下不同环境下的一些行为:

describe('环境', () => {
  it('开发环境', () => {
    // Mock config.getEnv => 'dev'
    // ...
  })

  it('正式环境', () => {
    // Mock config.getEnv => 'prod'
    // ...
  })
})

如果还用 jest.mock 的方法来做 Mock 的话,就有点不合适了。下面来聊聊这种需要多次 Mock 的解决方法。

doMock

刚刚说到 jest.mock 会提升到整个文件最前面,这也导致我们无法再次修改 Mock 的实现。jest 还提供了另一个 API jest.doMock,它也会执行 Mock 操作,但是不会被提升。利用这个特性再加上内联 require 就可以实现多次 Mock 的效果了:

// tests/utils/env/doMock.test.ts
describe("doMock config", () => {
  beforeEach(() => {
    // 必须重置模块,否则无法再次应用 doMock 的内容
    jest.resetModules();
  })

  it('开发环境', () => {
    jest.doMock('utils/env', () => ({
      __esModule: true,
      config: {
        getEnv: () => 'dev'
      }
    }));

    const { config } = require('utils/env');

    expect(config.getEnv()).toEqual('dev');
  })

  it('正式环境', () => {
    jest.doMock('utils/env', () => ({
      __esModule: true,
      config: {
        getEnv: () => 'prod'
      }
    }));

    const { config } = require('utils/env');

    expect(config.getEnv()).toEqual('prod');
  })
});

需要注意的是:这里一共引用了两次 utils/env,因此要用 jest.resetModules 来重置前一次引入的模块内容。

不过,这个方法也太挫了。

spyOn

对上面这种要多次 Mock 一个函数的情况,比较推荐的方法是用 jest.spyOn,添加 tests/utils/env/spyOn.test.ts

// tests/utils/env/spyOn.test.ts
import { config } from "utils/env";

describe("spyOn config", () => {
  it('开发环境', () => {
    jest.spyOn(config, 'getEnv').mockReturnValue('dev')

    expect(config.getEnv()).toEqual('dev');
  })

  it('正式环境', () => {
    jest.spyOn(config, 'getEnv').mockReturnValue('prod')

    expect(config.getEnv()).toEqual('prod');
  })
});

有人看到 jest.spyOn 会说:这个我早就知道了。别急,我们慢慢增加难度。

对象属性

假如我们的 env.ts 中 config 里存不是 getEnv 函数,而是一个 env 属性:

export const configObj = {
  env: 'test'
}

改成了属性后,我们就要 Mock config.env 的属性值。而由于属性值不是函数,所以我们无法使用 jest.spyOn 来 Mock 了。

不过,如果你学过 阮一峰的《属性描述对象 - 7.存取器》 (opens new window),那么你应该还记得这一章讲到:对象属性可以定义自己的 getter 和 setter

const obj = Object.defineProperty({}, 'p', {
  get: function () {
    return 'getter';
  },
  set: function (value) {
    console.log('setter: ' + value);
  }
});

obj.p // "getter"
obj.p = 123 // "setter: 123"

我们把 env.ts 的代码改成以下面 getter 取值方式:

export const configObj = {
  get env() {
    return 'test';
  }
}

这里我们可以给 config.env 定义 env 的 getter,当获取 config.env 时,实际上相当于调用 config.env 的 getter 函数。 既然 getter 是个函数,我们又可以使用上面的 jest.spyOn 了:

// tests/utils/env/getter.test.ts
import { configObj } from "utils/env";

describe("configObj env getter", () => {
  it('开发环境', () => {
    jest.spyOn(configObj, 'env', 'get').mockReturnValue('dev');

    expect(configObj.env).toEqual('dev');
  })

  it('正式环境', () => {
    jest.spyOn(configObj, 'env', 'get').mockReturnValue('prod');

    expect(configObj.env).toEqual('prod');
  })
});

jest.spyOn 最后一个参数是对象属性的 accessTypeget),通过指定这个参数,我们就能控制对象的属性值了。

单独导出函数

上面的例子都是直接导出一个对象,算是比较理想的情况了。那如果 env.ts 导出的是一个函数呢:

// src/utils/env.ts
export const getEnv = () => 'test'

这下好了,我们连能够 spyOn 的对象都没了,这又该如何测呢?很简单,在导入的时候把它弄成对象就好了:

// tests/utils/env/getEnv.test.ts
import * as envUtils from 'utils/env';

describe("getEnv", () => {
  it('开发环境', () => {
    jest.spyOn(envUtils, 'getEnv').mockReturnValue('dev')

    expect(envUtils.getEnv()).toEqual('dev')
  })

  it('正式环境', () => {
    jest.spyOn(envUtils, 'getEnv').mockReturnValue('prod')

    expect(envUtils.getEnv()).toEqual('prod')
  })
});

有没有感觉开始魔幻起来了?

直接导出变量

再极端点,这次不直接导出 getEnv 函数,而是导出 env 变量:

// src/utils/env.ts
export const env = 'test';

聪明的同学会想到把 import * as envUtils 和 spyOn env getter 的方法:

// tests/utils/env/env.test.ts
import * as envUtils from 'utils/env';

describe("env", () => {
  it('开发环境', () => {
    // @ts-ignore
    jest.spyOn(envUtils, 'env', 'get').mockReturnValue('dev')

    expect(envUtils.env).toEqual('dev');
  })

  it('正式环境', () => {
    // @ts-ignore
    jest.spyOn(envUtils, 'env', 'get').mockReturnValue('prod')

    expect(envUtils.env).toEqual('prod');
  })
});

这里 TS 已经提示我们不能把 dev 和 prod 赋值给 test 了,没关系,我们先用 @ts-ignore 忽略它。硬着头皮换来的结果就是报错:

这里又为什么不能监听属性值的 getter 呢?因为这里的 envUtils 没有定义 getter accessor,所以这里无法使用 jest.spyOn 了。

这里我也搜了一下,发现 这个 Issue: spyOn getter only works for static getters and not for instance getters (opens new window)。 jest 只能监听对象静态属性的 getter 而不能监听对象实例的属性,大家也要注意一下这个点。

要解决这个问题,我们可以通过强行赋值来解决它:

import * as envUtils from 'utils/env';

const originEnv = envUtils.env;

describe("env", () => {
  afterEach(() => {
    // @ts-ignore
    envUtils.env = originEnv;
  })
  
  it('开发环境', () => {
    // @ts-ignore
    envUtils.env = 'dev'

    expect(envUtils.env).toEqual('dev');
  })

  it('正式环境', () => {
    // @ts-ignore
    envUtils.env = 'prod'

    expect(envUtils.env).toEqual('prod');
  })
});

这里依然要用 @ts-ignore 来解决 “不能把 dev 和 prod 赋值给 test” 的报错,而且还要把 export const env = 'test' 改成 export let env = 'test' 才能进行赋值。多少有点挫了。

要解决上面这些问题,我们请出 Jest Mock 里最万能的方法 —— Object.defineProperty

import * as envUtils from 'utils/env';

const originEnv = envUtils.env;

describe("env", () => {
  afterEach(() => {
    Object.defineProperty(envUtils, 'env', {
      value: originEnv,
      writable: true,
    })
  })

  it('开发环境', () => {
    Object.defineProperty(envUtils, 'env', {
      value: 'dev',
      writable: true,
    })

    expect(envUtils.env).toEqual('dev');
  })

  it('正式环境', () => {
    Object.defineProperty(envUtils, 'env', {
      value: 'prod',
      writable: true,
    })

    expect(envUtils.env).toEqual('prod');
  })
});

这样我们既不需要用 @ts-ignore 也不需要把 const 改成 let 了。

TIP

要注意的是,无论用直接赋值还是 Object.defineProperty,都需要在最开始记录 env 的值,然后加一个 afterEach 在执行每个用例后又赋值回去,否则会造成用例之间的污染!

上面就是在同一个文件里对不同测试用例多次 Mock 的一些技巧,基本能覆盖到 80% 的测试场景。

Object.defineProperty

相信看到上面最后一个例子的同学可能会震惊:Object.defineProperty 我一年都没用几次,这样做是不是不太正规呀?错了,这个 API 在前端测试的 Mock 里非常常见, 也是最万能的 Mock 方法。

比如,我们这里的 env 取的是 window.env 的全局变量时,你也可以用它来 Mock:

Object.defineProperty(window, 'env', {
  value: 'dev'
})

虽然这个 API 很强大,但是使用时会污染到别的测试用例,因此你需要在每个用例执行完后重新赋一次原来的值。而当你用它来 Mock 公共内容时, 比如 String.split,Array.map,你会污染所有测试文件!因此,不得万不得已,尽量不用它,应该看看有没有更好的 Mock 方法,或者换种测试策略。

奇行种

一路看起来,你会发现 Jest 的 Mock 方式非常 Hacky,然而我在搜索 Jest 的 Mock 技巧时,还发现了一些更 Hacky 的 奇行种 (opens new window),下面分享两个:

依赖注入

我们经常会遇到这样的情况:同一个文件中,A 函数里调用了 B 函数。

export function getPlanet () {
  return 'world';
}

export default function getGreeting () {
  return `hello ${getPlanet()}!`;
}

这里我们希望通过 Mock getPlanet 的不同值来检查 getGreeting 的返回值。解决这个问题的关键思路是引入默认变量:

export function getPlanet () {
  return 'world';
}

export default function getGreeting (_getPlanet = getPlanet) {
  return `hello ${_getPlanet()}!`;
}

然后在写测试时,用一个外部的 getPlanet 来替代同文件里的 getPlanet

import getGreeting from '../greeting.dependency-injection';

describe('getGreeting', () => {
  it('默认值', () => {
    expect(getGreeting()).toBe('hello world!');
  });

  it('输出 mars', () => {
    expect(getGreeting(() => 'mars')).toBe('hello mars!');
  });

  it('输出 jupiter', () => {
    expect(getGreeting(() => 'jupiter')).toBe('hello jupiter!');
  });

  it('回到默认值', () => {
    expect(getGreeting()).toBe('hello world!');
  });
});

这就可以在不修改 getGreeting 的用法前提下实现 Mock,但是万一以后要添加参数了,这个方法还不是特别保险。

改写文件内容

这个就更奇葩了,直接改写文件内容。一般在测 fs 模块相关代码时,比如清理文件内容,追加文件内容等,我们会希望某个目录下已经有对应的内容了,而不是自己创建文件。 这时你可以使用 fs 提供的 __setVolumeContents 来实现:

import cleanDirectory from '../clean-directory';
import fs, { __setVolumeContents} from 'fs';

jest.mock('fs'); // check out ../__mocks__/fs.js to see why this works!

test('cleanDirectory() wipes away contents of /foo/bar/baz with 2 files', () => {
  __setVolumeContents({
    '/foo/bar/baz/qux1.txt': 'hello',
    '/foo/bar/baz/qux2.txt': 'world',
  });

  const numFilesDeleted = cleanDirectory();

  expect(numFilesDeleted).toBe(2);
  expect(fs.readdirSync('/foo/bar/baz')).toHaveLength(0);
});

test('cleanDirectory() wipes away contents of /foo/bar/baz with 3 files', () => {
  __setVolumeContents({
    '/foo/bar/baz/one.txt': '1',
    '/foo/bar/baz/two.txt': '2',
    '/foo/bar/baz/three.txt': '3',
  });

  const numFilesDeleted = cleanDirectory();

  expect(numFilesDeleted).toBe(3);
  expect(fs.readdirSync('/foo/bar/baz')).toHaveLength(0);
});

不过我没有使用过这个方法,个人觉得还是写一个脚本放在 utils 里去自动创建需要的测试文件比较好,而不是用这种 Hacky 的方式。

#总结

这一章里,我们学会了一些 Mock 技巧:

  • jest.mock 会提升到整个文件的顶端,先 mock 再 import
  • 可以用 ts-jest 提供的 mocked 函数让被 Mock 的函数自动拥有 jest 类型提示,不过这个已在 ts-jest@28.0 中被移除,放到了 jest 自带的 jest-mock 库中
  • doMock + 内联导入模块确实能解决修改 Mock 值的问题,但是太挫了,不推荐使用
  • 可以用 spyOn 来监听函数以及对象属性的 getter 来修改返回值
  • 可以用 Object.defineProperty 来更改变量值以及对象属性值,不过,这个方法也会带来一些副作用,需要手动重置修改过的值

断言

toHaveBeenCalled 函数是否被调用

expect(props.click).toHaveBeenCalled()

.toBe(value) 断言expect的值

expect(wrapper.state(test)).toBe(true)

调用组件中的方法

wrapper.instance().方法名

测试hook

参考

基础 Hooks

渲染

假如我们有个简单的 hook 要测试:

import { useState, useCallback } from 'react'


export default function useCounter() {
  const [count, setCount] = useState(0)
  const increment = useCallback(() => setCount((x) => x + 1), [])
  return { count, increment }
}

为了测试useCounter,我们需要使用 react-hook-testing-library 内的renderHook方法来渲染它。

import { renderHook } from '@testing-library/react-hooks'
import useCounter from './useCounter'


test('should use counter', () => {
  const { result } = renderHook(() => useCounter())


  expect(result.current.count).toBe(0)
  expect(typeof result.current.increment).toBe('function')
})

可以看到,result.current 的值和我们 hook 返回的值一致。

更新

上面的例子很好很完备,但是并没有测试到我们如何用它来计数。我们可以调用increment方法,然后检查返回值是否增加,来使得这个测试更加完备。

import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'


test('should increment counter', () => {
  const { result } = renderHook(() => useCounter())


  act(() => {
    result.current.increment()
  })


  expect(result.current.count).toBe(1)
})

increment方法调用后,result.current.count 的值表示的就是hook 的最新值。 你可能看到我们用给一个 actincrement方法包起来了。这个方法模拟了 hook 在浏览器中的表现,让我们可以在里面做值更新,更多细节,请看 React documentation

注意: 更新的时候有个坑,renderHook在更新的时候会修改 current 里面的值,所以你不要子在这个时候去做解构 current,因为解构赋值操作拿到的是旧值的拷贝值(这里不确定翻译的是不是准确!)。

提供 Props

有时候 hook 需要依赖 props 的值来做事,比如 useCounter Hook 就可以接收一个 prop 作为 counter 的初始值:

import { useState, useCallback } from 'react'


export default function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
  const increment = useCallback(() => setCount((x) => x + 1), [])
  return { count, increment }
}

设置初始值很简单,直接作为参数调用就好:

import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'


test('should increment counter from custom initial value', () => {
  const { result } = renderHook(() => useCounter(9000))


  act(() => {
    result.current.increment()
  })


  expect(result.current.count).toBe(9001)
})

props

许多 hook 使用依赖数组来决定什么时候执行特定操作,比如循环计算复杂值或者跑副作用。如果我们扩展一下 useCounter,让它有一个初始化函数可以设置初始值 initialValue,代码看起来会是这样:

import { useState, useCallback } from 'react'

export default function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
  const increment = useCallback(() => setCount((x) => x + 1), [])
  const reset = useCallback(() => setCount(initialValue), [initialValue])
  return { count, increment, reset }
}

现在,只有当initialValue 发生变化时,reset 函数才会更新。要在测试中更改 hookinput props,只需要简单地更新变量中的值并重新渲染 hook:

import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'


test('should reset counter to updated initial value', () => {
  let initialValue = 0
  const { result, rerender } = renderHook(() => useCounter(initialValue))


  initialValue = 10
  rerender()


  act(() => {
    result.current.reset()
  })


  expect(result.current.count).toBe(10)
})

但是如果有很多 props,使用变量去跟踪好像就有点困难了。另一种方法是使用 initialProps 配置和改变 rerender 的入参 newProps

这种方法还有另一种适用场景-当你想要限制变量的作用域在 hook 的回调函数内时。下面这个测试用例不会通过,因为 useEffect 调用的挂起和卸载过程中,id 的值都改变了。

import { useEffect } from 'react'
import { renderHook } from '@testing-library/react-hooks'
import sideEffect from './sideEffect'

test('should clean up side effect', () => {
  let id = 'first'
  const { rerender } = renderHook(() => {
    useEffect(() => {
      sideEffect.start(id)
      return () => {
        sideEffect.stop(id) // this id will get the new value when the effect is cleaned up
      }
    }, [id])
  })

  id = 'second'
  rerender()

  expect(sideEffect.get('first')).toBe(false)
  expect(sideEffect.get('second')).toBe(true)
})

使用initProps配合newProps,首次渲染的 id 会被捕捉并且在卸载阶段中保持,测试用例可以顺利通过。


image.png 使用 initProps 传参的方式,每次的 rerender 都会使用 hookProps 中的值作为最新值,而不是等到调用 callback 的时候使用测试用例中定义的 initialValue。


import { useEffect } from 'react'
import { renderHook } from '@testing-library/react-hooks'
import sideEffect from './sideEffect'




test('should clean up side effect', () => {
  const { rerender } = renderHook(
    ({ id }) => {
      useEffect(() => {
        sideEffect.start(id)
        return () => {
          sideEffect.stop(id) // this id will get the old value when the effect is cleaned up
        }
      }, [id])
    },
    {
      initialProps: { id: 'first' }
    }
  )


  rerender({ id: 'second' })


  expect(sideEffect.get('first')).toBe(false)
  expect(sideEffect.get('second')).toBe(true)
})

这是一个边界的情况,所以选一个最适合你的方法就好。