Jest 简介
通用测试框架
测试框架的几大功能
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
异步测试
异步通过就是回调 callback
和 promise
两种,先来看下 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
来模拟、fn 是 Jest
最简单的 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文件
前置知识
- 渲染组件
mount
和shallowMount
- 传递属性
- 元素是否成功的显示
- 查找元素不同的方法
- get、getAll
- find、findAll
- findComponent、getComponent
- 触发事件
- trigger
- 测试界面是否正确更新
- dom更新是一个一步过程,在测试中使用asyn,await
挂载组件
首先 mount
和 shallowMount
的区别是什么?
用通俗的话来说、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、看到终端顺利通过。
触发点击事件
现在我们要测试点击 button
后 h1
的 count 显示为 1
it("test click", () => {
wrapper.get(".addCount").trigger("click");
expect(wrapper.get(".number").text()).toBe(1);
});
发现终端出现了错误。
上面讲到、更新视图是一个异步操作、另外 text
文本显示应该是 字符串 1
it("test click", async () => {
await wrapper.get(".addCount").trigger("click");
expect(wrapper.get(".number").text()).toBe("1");
});
修改后、测试顺利通过。
测试高级技巧
我们需要测试如下一个组件、当 store
里面当 user.isLogin 为 false
时显示登录按钮、点击 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-router
、vuex
等库。首先需要进行 mock
jest.mock("ant-design-vue");
jest.mock("vue-router");
jest.mock("vuex");
全局组件
编写第一个用例、当 isLogin
为 false
时显示登录按钮
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();
});
再次运行、顺利通过 😊