如何使用Jest做单元测试

3,149 阅读7分钟

Jest 简介

通用测试框架

测试框架的几大功能

  • 断言
    • Jest 内置
    • Mocha 需要安装Chai 或者其他断言库
  • 异步支持
    • Jest 内置
    • Mocha 需要安装Sinon
  • 代码覆盖率
    • Jest 内置
    • Mocha 需要安装istanbul

Jest 特点

开箱即用、配置、快、内置代码覆盖率、Mocking 很容易

Jest 基本使用

全局安装 Jest、或者项目内安装、先来一个小🌰。新建一个 sum.js

function sum(a, b) {
    return a + b
}
module.exports = sum;

编写一个测试用例 sum.spec.js

const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
});

添加 "test": "npx jest" 脚本并运行、看到终端绿色的PASS说明顺利通过。ok、接下来让我们再看看别的方法、来验证不同的东西

test("two plus two is four", () => {
    expect(2 + 2).toBe(4);
});

test('object assignment', () => {
    const data = { one: 1, two: 2 };
    expect(data).toEqual({ one: 1, two: 2 });
});

test("true or false", () => {
    expect(1).toBeTruthy()
    expect(0).toBeFalsy()
})

test('two plus two', () => {
    const value = 2 + 2;
    expect(value).toBeGreaterThan(3);
    expect(value).toBeGreaterThanOrEqual(3.5);
    expect(value).toBeLessThan(5);
    expect(value).toBeLessThanOrEqual(4.5);

    // toBe and toEqual are equivalent for numbers
    expect(value).toBe(4);
    expect(value).toEqual(4);
});

更多的方法可以查看expect

异步测试

异步通过就是回调 callbackpromise 两种,先来看下 callback

const featchUser = (cb) => {
    setTimeout(() => {
        cb("dewu")
    }, 100);
}

test("test callback", () => {
    featchUser((data) => {
        expect(data).toBe("dewu")
    })
})

运行 Jest 发现顺利通过了、但是一看时间不对、明明延迟了 100 可是终端只有 1 毫秒,这个时候需要用到 Jest 提供给我们的 done 方法,如下:

test("test callback", (done) => {
    featchUser((data) => {
        expect(data).toBe("dewu")
        done()
    })
})

再次运行、正常了 😌

再看下 Promise 的用法

const userPromise = () => Promise.resolve("dewu")
test("test with async", async () => {
    const data = await userPromise()
    expect(data).toBe("dewu")
})

test("test with expect", () => {
    return expect(userPromise()).resolves.toBe("dewu")
})

const rejectPromise = () => Promise.reject("error")
test("test with reject", () => {
    return expect(rejectPromise()).rejects.toBe("error")
})

Mock功能

在单元测试中、我们一般对最小单元进行测试、不会去关心业务/模块之间的耦合情况、只需要知道是否被调用即可。因此会使用 mock 来模拟、fnJest 最简单的 mock 函数

function mockTest(call, cb) {
    if (call) {
        return cb(22)
    }
}

it("test with mock function", () => {
    const mockCb = jest.fn()
    mockTest(true, mockCb)
    // 是否被调用
    expect(mockCb).toHaveBeenCalled()
    // 被什么值调用
    expect(mockCb).toHaveBeenCalledWith(22)
    // 被调用次数
    expect(mockCb).toHaveBeenCalledTimes(1)
})

上面演示了如何模拟一个 Mock 函数、我们就需要关心是否被调用、不必在乎它内部的实现

那么、Mock 如何返回一个值呢

test("test with mockReturnValue", () => {
    let mockResult = jest.fn().mockReturnValue("dewu");
    let result = mockResult();
    expect(result).toBe("dewu");
});

test("test with fn", () => {
    let mockResult = jest.fn((str) => str);
    expect(mockResult("dewu")).toBe("dewu");
});

替换第三方模块

有时候我的模块依赖第三方库或者模块、我们可以用 Jest 来实现 "替换",如下🌰

getUserName() 是一个应用于 axios 发生请求的方法、最终我们要测试返回是否正确,Jest 给我们提供的对应的方法

jest.mock("axios")
axios.get.mockImplementation(() => {
    return Promise.resolve("aotuman")
})

it("test with request", async () => {
    const name = await getUserName(1)
    expect(name).toBe("aotuman")
    expect(axios.get).toHaveBeenCalled()
    expect(axios.get).toHaveBeenCalledTimes(1)
})

运行测试通过、也可以写成上面的返回方式

jest.mock("axios")

axios.get.mockReturnValue(Promise.resolve("aotuman"))
axios.get.mockResolvedValue(Promise.resolve("aotuman"))

Jest 提供了一种更简单的方案、可以在目录下新建 __mocks__ 目录、新建 axios.js

const axios = {
    get: jest.fn(() => Promise.resolve("aotuman"))
}
module.exports = axios

再次运行、发现也是正常通过测试

Timers

原生定时器功能(即setTimeout,setInterval,clearTimeout,clearInterval)对于测试环境来说不太理想,因为它们依赖于实时时间。Jest 可以将定时器换成允许我们自己控制时间的功能。

运行所有计时器(Run All Timers)

const featchUser = (cb) => {
    setTimeout(() => {
        cb("aotuman")
    }, 1000);
}

it("test the callback after 1 sec", () => {
    const callback = jest.fn()
    featchUser(callback)
    expect(callback).not.toHaveBeenCalled()
    // “快进”时间使得所有定时器回调被执行
    jest.runAllTimers()
    expect(callback).toHaveBeenCalled()
    expect(callback).toHaveBeenCalledWith("aotuman")
})

运行待定时间器 RunOnlyPendingTimers

const loopFeatchUser = (cb) => {
    setTimeout(() => {
        cb("one")
        setTimeout(() => {
            cb("two")
        }, 2000);
    }, 1000);
}

it("test the callback after 1 sec", () => {
    const callback = jest.fn()
    loopFeatchUser(callback)
    expect(callback).not.toHaveBeenCalled()
    // “快进”时间使得所有定时器回调被执行
    jest.runOnlyPendingTimers()
    expect(callback).toHaveBeenCalledTimes(1)
    expect(callback).toHaveBeenCalledWith("one")
    jest.runOnlyPendingTimers()
    expect(callback).toHaveBeenCalledTimes(2)
    expect(callback).toHaveBeenCalledWith("two")
})

更多方法查看Jest

Vue单侧

上章简单说了下 Jest 的基本使用、咋不能光说不练、看看在 Vue 中如何使用 Jest 来测试

环境

如何你是用 Vue-cli 生成模版创建时直接选择测试即可、如果不是、vue-cli 给我们提供了插件能力、直接执行 vue add unit-jest

插件会做以下几件事情:

  • 安装依赖
    • vue-test-unit
    • vue-jest
    • 注入了新的命令
  • vue-cli-server test:unit
    • any files in tests/unit that end in .spec.(js|jsx|ts|tsx)
    • any (js(x) | ts(x))files inside tests directories
  • vue-jest转换
    • 将vue SFC文件格式转换为对应的ts文件
    • 将ts通过presets/typescript-babel转换成对应的js文件

前置知识

  • 渲染组件
    • mountshallowMount
    • 传递属性
  • 元素是否成功的显示
    • 查找元素不同的方法
    • get、getAll
    • find、findAll
    • findComponent、getComponent
  • 触发事件
    • trigger
  • 测试界面是否正确更新
    • dom更新是一个一步过程,在测试中使用asyn,await

挂载组件

首先 mountshallowMount 的区别是什么?

用通俗的话来说、mount 会渲染所有的子组件、孙子组件、shallowMount 浅渲染、既不会渲染子组件,更不用提孙子辈的组件、会原原本本的显示子组件的存根、如 <my-componet-stub></my-componet-stub>

首先定义一个基础组件

<template>
  <p class="msg">{{ msg }}</p>
  <h1 class="number">{{ count }}</h1>
  <button @click="addCount" class="addCount">ADD</button>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

export default defineComponent({
  name: "Test",
  props: {
    msg: String,
  },
  setup() {
    const count = ref<number>(0);
    const addCount = () => {
      count.value++;
    };
    return {
      count,
      addCount,
    };
  },
});
</script>

tests 目录下新建一个 test.spec.ts 文件、启动命令 yarn test:unit --watch、监听文件变化、编写一个基本 case

import { mount } from "@vue/test-utils";
import Test from "@/components/test.vue";

describe("HelloWorld.vue", () => {
  it("renders props.msg when passed", () => {
    const msg = "new message";
    const wrapper = mount(Test, {
      props: { msg },
    });    
    expect(wrapper.get(".msg").text()).toBe(msg);
  });
});

ok、看到终端顺利通过。

触发点击事件

现在我们要测试点击 buttonh1 的 count 显示为 1

  it("test click", () => {
    wrapper.get(".addCount").trigger("click");
    expect(wrapper.get(".number").text()).toBe(1);
  });

发现终端出现了错误。

trigger-click.png

上面讲到、更新视图是一个异步操作、另外 text 文本显示应该是 字符串 1

  it("test click", async () => {
    await wrapper.get(".addCount").trigger("click");
    expect(wrapper.get(".number").text()).toBe("1");
  });

修改后、测试顺利通过。

测试高级技巧

我们需要测试如下一个组件、当 store 里面当 user.isLoginfalse 时显示登录按钮、点击 a-button 改变 store 里面的登录状态、显示出登出按钮、点击登录触发改变状态并触发 router.push

<template>
  <a-button
    type="primary"
    v-if="!user.isLogin"
    class="user-profile-component"
    @click="login"
  >
    登录
  </a-button>
  <div v-else>
    <a-dropdown-button class="user-profile-component">
      <router-link to="/setting">{{ user.userName }}</router-link>
      <template v-slot:overlay>
        <a-menu class="user-profile-dropdown">
          <a-menu-item key="0" @click="logout">登出</a-menu-item>
        </a-menu>
      </template>
    </a-dropdown-button>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType } from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import { UserProps } from "../store/user";

export default defineComponent({
  name: "user-profile",
  props: {
    user: {
      type: Object as PropType<UserProps>,
      required: true,
    },
  },
  setup() {
    const store = useStore();
    const router = useRouter();
    const login = () => {
      store.commit("login");
      message.success("登录成功", 2);
    };
    const logout = () => {
      store.commit("logout");
      message.success("退出登录成功,2秒后跳转到首页", 2);
      setTimeout(() => {
        router.push("/");
      }, 2000);
    };
    return {
      login,
      logout,
    };
  },
});
</script>
<style>
.user-profile-dropdown {
  border-radius: 2px !important;
}
.user-operation > * {
  margin-left: 30px !important;
}
</style>

我们要实现 mock 全局组件、模拟第三方库的实现,使用了 ant-design-vue 里面的组件、并且使用了 vue-routervuex 等库。首先需要进行 mock

jest.mock("ant-design-vue");
jest.mock("vue-router");
jest.mock("vuex");

全局组件

编写第一个用例、当 isLoginfalse 时显示登录按钮

describe("UserProfile component", () => {
  beforeAll(() => {
    wrapper = mount(UserProfile, {
      props: {
        user: { isLogin: false },
      },
    });
  });
  it("should render login button when login is flase", () => {
    console.log(wrapper.html());
  });
});

终端提示 Failed to resolve component、我们需要模拟一些全局组件

const mockComponent = {
  template: "<div><slot></slot></div>",
};
const globalComponents = {
  "a-button": mockComponent,
  "a-dropdown-button": mockComponent,
  "router-link": mockComponent,
  "a-menu": mockComponent,
  "a-menu-item": mockComponent,
};

添加全局组件

wrapper = mount(UserProfile, {
  // ...
  global: {
    components: globalComponents,
  },
});

调整用例

it("should render login button when login is flase", () => {
  console.log(wrapper.html());
  expect(wrapper.get("div").text()).toBe("登录");
});

顺利通过。下面通过改变 store 值、展示登出

it("should render username when login is true", async () => {
  await wrapper.setProps({
    user: {
      isLogin: true,
      userName: "凹凸曼",
    },
  });
  console.log(wrapper.html());
});

打印发现没有 登出字段。发现是因为登出在 template v-slot:overlay 里面、需要自定义一下 a-dropdown-button

const mockComponent2 = {
    template: '<div><slot></slot><slot name="overlay"></slot></div>'
    'a-dropdown-button': mockComponent2,
}

再次打印 发现有了、补充下显示 userName 和 登出的 case

expect(wrapper.get(".user-profile-component").html()).toContain("凹凸曼");
expect(wrapper.find(".user-profile-dropdown").exists()).toBeTruthy();

mock 行为

  • ant-design-vue message.success()
  • vuex useStore().commit
  • vue-router useRouter().push

先来测试点击触发 message.success、先注释 store.commit("login"),我们可以继续 mock ant-design-vue

jest.mock("ant-design-vue", () => ({
  message: {
    success: jest.fn(),
  },
}));

在定义引入 import { message } from "ant-design-vue"

::: warning 注意 虽然在顶部引入 message,但是被 jest.mock 后只会走我们自定义的、修改 case 点击后触发 message.success方法 :::

it("should render login button when login is flase", async () => {
  expect(wrapper.get("div").text()).toBe("登录");
  await wrapper.get("div").trigger("click");
  expect(message.success).toHaveBeenCalled();
});

注入全局 store

现在我们换一种方式来解决全局 mock

  • 移除 jest.mock("vuex")
  • 首先引入真实的 store
  • 挂在到全局属性上
import store from "@/store";
wrapper = mount(UserProfile, {
  global: {
    components: globalComponents,
    provide: {
      store,
    },
  },
});

来测试 store 里面的 userName 是否正确显示

expect(store.state.user.userName).toBe("凹凸曼");

mock 半真半假

上面演示了 mock 全局组件、也演示了 使用真实 store,下面我们来使用一种半真半假来测试 useRouter().push 行为

编写一个 点击登出后、触发 message.success 提示、2s 后路由跳转的 case

it("should call logout and show message, call router.push after timeout", async () => {
  await wrapper.get(".user-profile-dropdown div").trigger("click");
  expect(store.state.user.isLogin).toBeFalsy();
  expect(message.success).toHaveBeenCalledTimes(1);
  jest.runAllTimers();
  expect(mockedRoutes).toEqual(["/"]);
});

这里用到了之前的 jest.useFakeTimers()jest.runAllTimers(),不过运行出现错误、提示我们 toHaveBeenCalledTimes 被调用了两次,是因为我们在之前 call 了一次,我们可以使用之前的 afterEach 来重置下

afterEach(() => {
  (message as jest.Mocked<typeof message>).success.mockReset();
});

再次运行、顺利通过 😊