记录使用vue-test-utils + jest 在uniapp中进行单元测试

前情

  • uniapp推荐了测试方案@dcloudio/uni-automator,属于自动化测试,api提供的示例偏重于渲染组件,判断当前渲染的组件是否和预期一致
  • vue推荐的测试方案vue test utils,属于单元测试,可以搭配jest、mocha等单测运行器

我选了方案2🕶️

关于vue的组件测试,vue官方提到:

你的 Vue 应用中大部分内容都应该由组件测试来覆盖,我们建议每个 Vue 组件都应有自己的组件测试文件。
当进行测试时,请记住,测试这个组件做了什么,而不是测试它是怎么做到的

对于 视图 的测试:根据输入 prop 和插槽断言渲染输出是否正确。
对于 交互 的测试:断言渲染的更新是否正确或触发的事件是否正确地响应了用户输入事件

本身的测试写起来很简单,就是挺多东西需要配置的,比较麻烦,记录在后文

安装依赖

  • @vue/test-utils
    vue2项目安装:
    npm install --save-dev @vue/test-utils@1
    不指定的话默认安装最新,适合vue3项目吧
  • jest
  • vue-jest:为了处理.vue文件
    npm install --save-dev @vue/vue2-jest@29 (最后写jest版本)
  • babel-jest
  • jest-environment-jsdom

jest版本在27以上,是要安装jest-environment-jsdom
其他版本下如果报错:

[vue-test-utils]: window is undefined, vue-test-utils needs to be run in a browser environment. 
    You can run the tests in node using jsdom 

可以尝试:
npm install --save-dev jsdom jsdom-global

// 在测试的设置 / 入口中
require('jsdom-global')()

package.json配置

加一条就好

"scripts": {
	"test": "jest"
},

jest配置

可以配在package.json的jest选项中
也可以新建jest.config.js,我选了后者

module.exports = {
  moduleFileExtensions: [
      'js',
      'vue'
  ],
  transform: {
      '^.+\\.vue$': '<rootDir>/node_modules/@vue/vue2-jest',
      '^.+\\.js$': '<rootDir>/node_modules/babel-jest'
  },
  moduleNameMapper: { // webpack中设置了别名,@设置为/src 的别名,就需要配这个
      '^@/(.*)$': '<rootDir>/src/$1'
  },
  testMatch: ['**/__tests__/**/*.spec.js'],
  transformIgnorePatterns: ['<rootDir>/node_modules/'],
  testEnvironment: "jsdom" // jest v27以上要加
}

⚠️:
官网提到的一个注意点:

如果你使用了 Babel 7 或更高版本,
你需要在你的 devDependencies 里添加 babel-bridge 
($ npm install --save-dev babel-core@^7.0.0-bridge.0)

我在运行时有相关的报错提示,所以我也按照这样安装了
如果你也有的话,可以参考一下

测试文件目录

新建__tests__目录,放在src目录下可以,根目录下也可以
(注意别少打s)
目录下的测试文件扩展名应该是.spec.js或者test.js ,我选了前者
这个也可以改,想改去找jest文档————

编写setup.js

通常用于执行一些全局的设置或引入一些测试所需的全局依赖,以确保这些设置和依赖在所有测试文件中都可用!

jest.config.js中新加一条:
setupFiles: ["./__tests__/setup.js"]

__test__文件夹下新建setup.js

1.项目中用到的uni或者wx的api是识别不了的,所以放在这里预先配置一下
2.在Vue.prototype上挂载的比如$toast、$api,$store、直接用this.调用的时候也是识别不了的,也要在这配置一下

localVue可以理解成创建本地的vue实例,可以使用localVue.prototype挂载一些东西而不会污染到真正的Vue.prototype,我在这挂到全局了,实际上可以在每个单独的测试文件中都create新的

import { createLocalVue } from "@vue/test-utils";
import Vuex from 'vuex'
import axios from 'axios'

const CODE = '用户登录凭证';
// 创建的一个 Vue 的本地拷贝
const localVue = createLocalVue()

localVue.use(Vuex)
const store = new Vuex.Store({
  state: {},
  mutations: {
    // 这里如果只是为了代码里不卡住,直接jest.fn()就可以
    login: jest.fn((state, userInfo) => state.userInfo = userInfo),
    setFlag: jest.fn((state,param) => state.flag = param.flag),
    logout: jest.fn((state) => state.userInfo = {})
  }
})
localVue.prototype.$store = store
localVue.prototype.$toast = jest.fn()
// 后面很多场景的使用是const {confirm} = await this.$modal(xxx), 这里直接模拟cofirm为true
localVue.prototype.$modal = jest.fn(() => Promise.resolve({ confirm: true }))
localVue.prototype.$api = {
  student: {
    studentLogin: jest.spyOn(axios, 'post')
  },
}

global.uni = {
  showLoading: jest.fn(),
  hideLoading: jest.fn(),
  navigateTo: jest.fn(),
  switchTab: jest.fn(),
  getStorageSync: jest.fn(),
  setStorageSync: jest.fn(),
  login: jest.fn(() => Promise.resolve([,CODE]))
}
global.setValue = (target, value) => {
  target.element.value = value
  target.trigger('input')
}
global.wx = global.uni
global.localVue = localVue

ps:这里挂了一个全局的方法setValue,因为官方的那个我使用会报错显示没有setValue(),查看setValue(),不知道是不是因为我的input是小程序的🤔

编写第一个测试文件

对组件StudentLogin.vue,新建studentLogin.spec.js

变更一个响应式属性之后,为了断言这个变化,测试需要等待 Vue 完成更新,可以

  1. await vm.nextTick() 2. await 操作,比如trigger
import { shallowMount } from "@vue/test-utils";
import StudentLogin from '@/pages/student-login/student-login'

 const TEST_VALUE = '123456'
 const TEST_TIP = {
    NO_NUMBER: '请填写学号!',
    NO_PASSWORD: '请填写密码!'
  }
// describe(name, fn): 表示一组测试,如果没有describe,那整个测试文件就是一个describe。name是这组测试的名字,fn是这组测试要执行的函数。
describe('StudentLogin.vue', () => {
  let wrapper;
  beforeEach(() => {
    // shallowMount和mount区别在于不会挂载子组件,比较适合单元测试,子组件的测试逻辑单独写
    wrapper = shallowMount(StudentLogin, {
      localVue
    })
  })
 
  // formSubmit触发时,输入账号没输入密码,提示请填写密码!
  test('if formSubmit triggered with number but no password, show tip', async () => {
    setValue(wrapper.find('input[name="number"]'), TEST_VALUE)
    await wrapper.vm.$nextTick();
    await wrapper.find('.submit-btn').trigger('click')
    expect(localVue.prototype.$toast).toBeCalledWith('error', TEST_TIP.NO_PASSWORD)
  })
  // formSubmit调用后,应该发起请求
  it('if formSubmit done, send request', async () => {
    setValue(wrapper.find('input[name="number"]'), TEST_VALUE)
    setValue(wrapper.find('input[name="password"]'), TEST_VALUE)
    await wrapper.vm.formSubmit()
    expect(localVue.prototype.$api.student.studentLogin).toBeCalled();
    expect(localVue.prototype.$api.student.studentLogin).toBeCalledWith(TEST_VALUE, TEST_VALUE, CODE)
  })
  // 销毁所有被创建的 Wrapper 实例
  enableAutoDestroy(afterEach)
})

jest.fn()和jest.spyOn()

承接上文:
轻轻记录一下jest.fn()jest.spyOn()
他们都是用来模拟函数的行为,都会跟踪函数的调用和传参
区别:jest.fn()是创建一个全新的模拟函数,jest.spyOn()一般是模拟对象上的现有方法

比如
页面需要axios发请求,但是我们测试的时候不需要实际调用,
就可以利用

localVue.prototype.$api = {
  student: {
    studentLogin: jest.spyOn(axios, 'post')
  },
}

使用场景非常多,后文也会涉及
他们两返回的其实就是mockFn,在jest官网有非常多对mockFn的操作
指路:mockFn

我常用的一个mockFn.mockResolvedValue(value)
例如:
测试这个函数,是否成功发送请求,但我们无需发送真的请求,就可以模拟返回值

async getList() {
	const { data } = await this.$api.student.getData()
	this.list = data
}
// test.spec.js
test('', async () => {
	localVue.prototype.$api.student.getData.mockResolvedValue({
		list: [1,2,3]
	})
	await wrapper.vm.getList()
	expect(wrapper.list.length).toBe(3)
})

⚠️提醒一下自己,注意:
比如说我们要断言,trigger某个操作或者更新了页面之后,某个函数应该要被调用
会使用

const spy = jest.spyOn(wrapper.vm, 'someFunction')
expect(spy).toBeCalled()

但要注意这个必须要写在更新操作之前,如果写在之后是会断言错误的
👇 jest.spyOn写在了trigger之后,也就是开始跟踪的时候已经触发完了,
那么expect(infoSpy).toBeCalled()就会失败

 test('if term picker triggered', async () => {
   const picker = wrapper.findComponent('picker')
   await picker.trigger("change", 1);
   const infoSpy = jest.spyOn(wrapper.vm, 'getInfo')
   expect(wrapper.vm.termIndex).toBe(1)
   expect(infoSpy).toBeCalled()
 })

jest 解析scss失败

比如这个页面有引入scss:
import { THEME_COLOR } from "@/uni.scss";
如果不做配置的话就会报错

解决方法:
新建一个styleMock.js

// styleMock.js
module.exports = {
  process() {
    return {
      code: `module.exports = {};`,
    };
  },
};

然后在jest.config.js中配置transform

transform: {
   '^.+\\.vue$': '<rootDir>/node_modules/@vue/vue2-jest',
   '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
   '\\.(css|less|scss|sass)$': '<rootDir>/styleMock.js',
},

然后运行npm run test,如果还是没生效,可以试试关闭编辑器重新启动

测试vuex

这里不提官网有的部分,有需要可自查
在组件中测试vuex

目前场景是这个组件在计算属性中使用了mapState

computed: {
	... mapState(['flag', 'userInfo'])
}

然后当userInfo.level = 1 && flag = 1时候要渲染某容器,我需要测试这个,那么就需要修改state中的数据
由于前面在setup.js中已经在localVue上安装了vuex,这里就通过localVue来访问

localVue.prototype.$store.state.flag = 1
localVue.prototype.$store.state.userInfo = { level: 1 }

如果使用$store.commit()的话,就要在setup.js中localVue.prototype.$store上用jest.fn()具体实现mutation中的方法了,只是简单的用jest.fn()模拟是不生效的
像这样👇

const store = new Vuex.Store({
  state: {},
  mutations: {
    // 这里如果只是为了代码里不卡住,直接jest.fn()就可以
    login: jest.fn((state, userInfo) => state.userInfo = userInfo),
    setFlag: jest.fn((state,param) => state.flag = param.flag),
    logout: jest.fn((state) => state.userInfo = {})
  }
})

⚠️:更改完数据后,要等待页面更新,记得await nextTick()一下,否则断言会失败

$refs

类似于这样的代码:

close() { 
	// 清除校验结果
	this.$refs.form.clearValidate();
	this.$emit('close');
},

this.$refs.form.clearValidate();会报错,提示找不到clearValidate这个function
解决方法1: 模拟一个form塞在stubs里

// 这里要写的是组件的名字,不是ref设置的名字
 const UniForms = {
   render: jest.fn(),
   methods: {
     validate: () => {},
     clearValidate:() => {}
   }
 }
wrapper = shallowMount(ForgetPassword, {
  localVue,
  stubs: {
    UniForms
  }
})

(模板上<uni-forms ref="form"></uni-forms>)
但我这个例子用这个方法不太行,会影响我别的测试(一些元素渲染失败,wrapper.find时会找不到)
先记录在这吧

解决方法2:
加一行👇
wrapper.vm.$refs.form.clearValidate = jest.fn()

如果有要返回的数据,可以在jest.fn()中直接模拟
比如说:
我们需要拿到返回的password、email,简单的jest.fn()无法满足需求

const { password, email } = await this.$refs.form.validate();

设定jest.fn()模拟的函数,返回成功值

wrapper.vm.$refs.form.validate = jest.fn(() => Promise.resolve({ 
	password: 1, 
	email: 1
}))

后续:
又有一处用到$refs:

mounted() {	
	this.$refs.form.setRules(this.formRules);
}

这次是在mounted()里使用,方法2就用不了了,因为需要先mount(wrapper),才能拿到wrapper.vm,但这里又是要在mounted中执行的,假如我们使用wrapper.vm.$refs.form.setRules = jest.fn()其实就已经晚了,mounted已经执行完了

这个时候就可以用方法1~

定时器

检验有关定时器的方法

setTime(number) {
	this.codeText = `倒计时${number}s`;
	if(!number) {
		this.codeText = '发送验证码';
		this.isSending = false;
		this.timer = null;
		return;
	} else {
		number--;
	}
	this.timer = setTimeout(() => {
		this.setTime(number);
	}, 1000);
},

使用jest.useFakeTimers()指定全局使用假的定时器api
jest.advanceTimersByTime(1000)模拟时间快进1s

jest.useFakeTimers()
const sendCodeBtn = wrapper.findComponent('.send-code')
test('if setTime triggered 60, change btn content and start countdown', async () => {
    const setTimeSpy = jest.spyOn(wrapper.vm, 'setTime')
    await wrapper.vm.setTime(60)
    expect(sendCodeBtn.text()).toBe('倒计时60s')
    // 过一秒
    jest.advanceTimersByTime(1000)
    expect(setTimeSpy).toBeCalledWith(60 - 1)
  })

  test('if setTime triggered 0, change btn content and close timer', async () => {
    await wrapper.vm.setTime(0)
    expect(sendCodeBtn.text()).toBe('发送验证码')
    // 过一秒
    jest.advanceTimersByTime(1000)
    expect(wrapper.vm.timer).toBe(null)
  })

测试函数调用n次

本来想测
1.titleInput或contentInput无内容时 => 提示’请输入必要内容’
2.titleInput和contentInput都有内容时 => 不显示提示
(错误写法👇)

test("", async () => {
    await form.trigger('submit')
    expect(localVue.prototype.$toast).toHaveBeenCalledWith('none', '请输入必要内容')
    setValue(titleInput, TEST_VALUE)
    await form.trigger('submit')
    expect(localVue.prototype.$toast).toHaveBeenCalledWith('none', '请输入必要内容')
    setValue(contentInput, TEST_VALUE)
    await form.trigger('submit')
    expect(localVue.prototype.$toast).not.toHaveBeenCalled()
});

但上面这种写法是错的,实际上localVue.prototype.$toast的调用是累积的,不是相互隔离的,第三次expect(localVue.prototype.$toast)的时候实际上已经被调用三次了,那么not.toHaveBeenCalled()就不可能通过测试

这时候应该使用toHaveBeenNthCalledWidth(),第一个参数写n,表示第n次
第三次的时候不应该被调用,就用toHaveBeenCalledTimes()判断总调用次数

test("", async () => {
    await form.trigger('submit')
    expect(localVue.prototype.$toast).toHaveBeenNthCalledWith(1, 'none', '请输入必要内容')
    setValue(titleInput, TEST_VALUE)
    await form.trigger('submit')
    expect(localVue.prototype.$toast).toHaveBeenNthCalledWith(2, 'none', '请输入必要内容')
    setValue(contentInput, TEST_VALUE)
    await form.trigger('submit')
    expect(localVue.prototype.$toast).not.toHaveBeenCalledTimes(3);
});

手动调用生命周期

比如说
(onLoad是小程序里的生命周期)

onLoad({code}) {
	this.code = +code;
	// 每10min刷新一次
	if(!this.code) {
		this.getSignInCode();
		this.timer = setInterval(() => { this.getSignInCode() }, 600000);
	} 
}

这里想测试code为0的时候是否调用了函数getSignInCode,且过了10min是否再次调用

我想手动调用onLoad(),onLoad并不在wrapper.vm上,不能通过wrapper.vm.onLoad访问
可以通过两种方式找到:(这个组件名叫ShowQRcode

  1. ShowQRcode.onLoad({ code: 1 })
  2. wrapper.vm.$options.onLoad({ code: 1 })

但都会报错:this.getSignInCode is not a function,因为getSignInCode是在wrapper.vm上的,所以这里要更改this指向
ShowQRcode.onLoad.call(wrapper.vm, {code: 0 })

 test('', () => {
   const signInSpy = jest.spyOn(wrapper.vm, 'getSignInCode')
   ShowQRcode.onLoad.call(wrapper.vm, { code: 0 })
   expect(signInSpy).toHaveBeenCalledTimes(1)
   jest.advanceTimersByTime(600000)
   expect(signInSpy).toHaveBeenCalledTimes(2)
 })

处理其他模块导入的函数

场景:

import { uploadImg } from '@/util/uploadImg.js';

async selectImg(res) {
	// 上传图片
	const { url } = await uploadImg(res.tempFilePaths, 'files')
	this.imgPaths.push(url[0]);
}

如果要测试selectImg(),当执行到uploadImg()就会报错

我们就可以利用jest.mock来模拟这个模块
记录一下jest.mock的简单使用:
官网的例子:

// banana.js
export default () => 'banana';
// test.spec.js
// 后续的测试中,任何导入./banana模块的代码将会被自动模拟,而不是实际的banana.js模块
jest.mock('./banana');
// 这个导入的bannana就被自动模拟了
const banana = require('./banana');
// 不会返回banana,因为被模拟了,默认返回undefined
banana(); // will return 'undefined' 

还可以接收一个函数,显式指定模块导出的内容

// 相当于 
// const mockFn = jest.fn(() => 'bannana'
// export default mockFn
jest.mock('./bannana', () => {
  return jest.fn(() => 'bannana');
});

const bannana = require('./bannana');
bannana(); // Will return 'bannana';

所以这里就这样写:

// 相当于 
// export const uploadImg = jest.fn(() => Promse.resolve({ data: TEST_UPLOAD_RESPONSE}))
jest.mock('@/util/uploadImg.js', () => ({
  otherFunction: xxx,
  uploadImg: jest.fn(() => Promise.resolve({ data: TEST_UPLOAD_RESPONSE }))
}));

测试插槽

项目比较简单,用插槽的地方很少,甚至没用到作用域插槽
这里只记录最简单的方法
官网是有例子的:测试插槽

就是在shallowMount的时候配置slots

 beforeEach(() => {
    wrapper = shallowMount(Detail, {
      localVue,
      slots: {
        list: '<view>list</view>',
        operation: '<view>operation</view>'
      }
    })
  })

这里slots配置的就是模拟传入插槽的内容
比如list: '<view>list</view>',就是该组件内有一个插槽出口<slot name="list"></slot>
然后我们模拟传入这个插槽的内容是<view>list</view>
之后打印wrapper.html()会发现插槽出口确实都被替换成了我们预设的内容
只需要断言expect(wrapper.html()).toContain('<view>list</view>')即可完成测试

这里还出现一个问题,我有一个插槽出口长这样👇

<slot name="top">
  <view class="top__button" 
		v-if="flag === 'xxx'">
    <text>{{ xxx }}</text>
  </view>
</slot>

在插槽中指定了默认内容,且默认内容要通过v-if控制显示隐藏
并且这个地方我也写了一个测试,是测试top__button的显隐
如果我一开始预设的时候,预设了插槽top的内容,就会导致这个测试失败,因为找不到top__button了,直接被替换成了我预设的内容
其实失败的原因是我两个测试共用了一个wrapper的配置(习惯写在beforeEach里)

解决的方法就是在这个测试中,单独的再重新创建一个wrapper,不要预设slots就好

补充:
测试作用域插槽

参考:
https://juejin.cn/post/7119314584371986468?searchId=2023092122585499D5137C15C4283D9452
https://blog.csdn.net/pk142536/article/details/122255192
https://zhuanlan.zhihu.com/p/457648810

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值