微信小程序单元测试攻略
Posted 腾讯WeTest
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了微信小程序单元测试攻略相关的知识,希望对你有一定的参考价值。
导语
本文作者是腾讯社交增值产品部高级前端工程师林毅雄,对前端开发领域颇有研究。接下来,本文将从测试框架、实战、覆盖率、踩坑等方面分享一下微信小程序的单元测试经验,希望能帮到大家。
1. 为什么写测试
大家先看看A公司与B公司的差距:
从上图可以看出,B公司的单元测试做的比较好,每百行error数也远比A公司项目低。
总体来说,单元测试有以下一些好处:
1.及早发现代码错误,提高代码质量和可维护性
2.代码变更时可以快速进行检查
然而,要做好测试也有一定的困难:
1.花费时间长
2.被测代码包含复杂的环境因素需要处理或模拟、例如使用了storage、调用了接口、使用了环境变量等。
(图片来源:码农翻身公众号)
但无论如何,有价值的东西就应该去做,应该要知难而退嘛。
接下来给大家介绍一下测试框架。
2. 微信小程序测试框架:miniprogram-simulate
这是微信小程序自定义组件测试工具集。主要提供以下功能方便测试:
1.模拟 touch 事件、自定义事件触发
2.选取子节点
3.更新自定义组件数据
4.触发生命周期
2.1 架构
(图片来源:掘金技术社区)
2.2 接入
2.2.1安装
// 小程序工具集
$ npm i --save-dev miniprogram-simulate
// Jest测试框架
$ npm i --save-dev jest
2.2.2 在package.json中, 添加测试相关命令
sd
...
script:
"test": "jest --coverage"
...
2.2.3 添加jest.config.js:
注意 testEnvironment设为 'jsdom',因为框架使用的是这个环境,如果配错会运行不起来。
// 更多配置查看: https://jestjs.io/docs/zh-Hans/configuration
module.exports =
verbose: true,
modulePathIgnorePatterns: [
'<rootDir>/dist-wx/',
'<,rootDir>/node_modules/',
],
// 是否开启自动mock测试文件中导入的文件
automock: false,
testRunner: 'jasmine2',
// 测试文件执行前会先执行该文件,用来给Jest测试函数加代理从而收集测试用例
setupFilesAfterEnv: ['./node_modules/@tencent/dwt/dist/src/testbase/testbase.js'],
// 覆盖率报告依赖
reporters: [
'default',
'@tencent/dwt-reporter',
],
// 测试文件匹配规则
testMatch: [
'**/__test__/**/*.test.ts?(x)',
],
// 测试覆盖报告文件列表,下面是默认列表
coverageReporters: ['json', 'lcov', 'text', 'clover'],
// 全局变量配置
globals:
NODE_ENV: 'test',
__wxConfig:
global:
window: ,
,
,
,
moduleNameMapper:
'@/(.*)$': '<rootDir>/$1.ts',
,
setupFiles: ['<rootDir>/__test__/wx.ts'],
transform:
'^.+\\\\.[jt]s?$': 'ts-jest',
,
preset: 'ts-jest',
testEnvironment: 'jsdom',
collectCoverage: true,
coverageDirectory: './__test__/coverage',
coverageReporters: ['json-summary', 'text', 'lcov'],
coveragePathIgnorePatterns: [
'/node_modules/',
],
moduleNameMapper:
'^@/(.*)$': '<rootDir>/$1',
,
coverageThreshold:
global:
branches: 50,
functions: 50,
lines: 50,
statements: 50,
,
,
;
2.3 组件测试
示例:如何给一个提现弹窗写组件测试?
// 自定义组件 JS逻辑
Component(
properties:
showDialog: Boolean, // 是否展示Dialog
title: String, // Dialog标题
okText: String, // 确认按钮文案
cancelText: String, // 取消按钮文案
showCancel: Boolean, // 是否展示取消按钮
confirmStyle: String, // 确认按钮Style
num: Number,
,
methods:
ok()
// 提现逻辑
this.triggerEvent('ok');
,
onCancel()
this.triggerEvent('cancel');
);
2.3.1 要考虑的事项
1.根据组件传入的属性有相对应的DOM表现
传入不同的属性值, 其组件产生的内容、结构、样式变化也是可预计的,例如:
根据showCancel属性值, 判断Cancel按钮是否展示
title, text, okText, cancelText文案是否一致
confirmStyle, titleStyle的值与实际样式是否一致
2.响应用户交互触发事件
处理用户操作, 保证事件触发时, 响应函数如预期,例如:
onOk 当用户点击确认按钮时触发
onCancel 当用户点击取消按钮时触发
2.3.2 实践
2.3.2.1 环境初始化:
import path from 'path';
import load, render, extendRequest from 'miniprogram-simulate';
describe('[wx-component]dialog', () =>
// ...
2.3.2.2 确认组件属性是否与DOM里的内容、结构、样式相同:
it('[dialog] 属性文案渲染正常', () =>
const id = load(path.join(__dirname, '../dist-wx/components/dialog/index'));
// comp是渲染后的组件树
const comp = simulate.render(id,
showDialog: true,
title: '请确认提现金额',
num: '0.03',
okText: '确认提现',
cancelText: '取消',
showCancel: false,
confirmStyle: 'background-color: blue;'
);
// 组件树提供querySelector,支持class查询
const title = comp.querySelector('.main-title');
const okBtn = comp.querySelector('.ok-btn');
const cancelBtn = comp.querySelector('.cancel-btn');
// 内容检测断言
// toContain, 检测内容中是否包含预期内容
expect(title.innerhtml).toContain('请确认提现金额');
expect(okBtn.innerHTML).toContain('确认提现');
expect(cancelBtn.innerHTML).toContain('取消');
// 结构检测断言
// 判断取消按钮是否按预期不存在
expect(cancelBtn).toBeUndefined();
// 样式检测断言
// 判断确认按钮样式是否按预期是蓝色
expect(window.getComputedStyle(okBtn.dom).backgroundColor).toBe('blue');
);
querySelector支持以下查找节点的方式:
1.ID 选择器:#the-id
2.class 选择器(可以连续指定多个):.a-class.another-class
3.子元素选择器:.the-parent > .the-child
4.后代选择器:.the-ancestor .the-descendant
5.跨自定义组件的后代选择器:.the-ancestor >>> .the-descendant
6.多选择器的并集:#a-node, .some-other-nodes
2.3.2.3 确认用户操作是否正确响应:
要感知事件是否响应,我们需要使用spyOn方法。该方法和sinon.spy一样,生成函数的“间谍”,可以断言该函数的已调用次数、调用入参、调用返回等是否符合预期。
it('[dialog]btn event', async () =>
const comp = simulate.render(id,
showDialog: true,
title: '请确认提现金额',
okText: '确认提现',
num: '0.03',
cancelText: 'Cancel',
showCancel: true
);
// 分别监控 ok, onCancel, cancelDialog函数
const spyOk = jest.spyOn(comp.instance,"getData");;
const spyCancel = jest.spyOn(comp.instance, 'onCancel');
const spyHide = jest.spyOn(comp.instance, 'cancelDialog');
const ok = comp.querySelector('.ok-btn');
const cancel = comp.querySelector('.cancel-btn');
const mask = comp.querySelector('.dialog-mask');
// 触发确认按钮的tap事件
ok.dispatchEvent('tap');
// 触发取消按钮的tap事件
cancel.dispatchEvent('tap');
// 触发mask的tao事件
mask.dispatchEvent('tap');
// 模拟异步回调
await simulate.sleep(200);
// 断言监控到的结果
expect(spyOk).toHaveBeenCalled();
expect(spyCancel).toHaveBeenCalled();
expect(spyHide).toHaveBeenCalled();
);
页面本质上是特殊的组件,因此组件测试的方法也适用于页面测试。只是在调用方法的时候需要改为页面的方法,例如对于加载完事件,组件调用ready,页面调用onload。
2.3.3 完整的断言方法
2.3.4 模拟数据mock
当被测方法包含环境因素不能直接测试时,例如使用了localStorage,又或者被测方法调用了接口,不希望测试时调用接口影响业务或降低测试速度,可以通过mock来模拟数据。
模拟接口调用示例:
// 被测代码A
import axios from 'axios'
export function getData()
return axios.get('/api').then(res => res.data)
// 测试代码B
import axios from 'axios';
jest.mock('axios');
// 模拟一次接收到的数据
axios.get.mockResolvedValueOnce(
data: '123'
)
const data1 = await getData()
expect(data1).toBe('123')
模拟storage调用示例1:
// 扩展 wx.getStorage 方法
simulate.extendApi(
"getStorage", //API 名称
key: `my_storage_key` , //API 参数
data: //API 返回结果
);
模拟 storage调用示例2:
const mockStorage =
get: jest.fn(),
set: jest.fn(),
remove: jest.fn(),
;
jest.mock('../src/storage.js', () => mockStorage);
mockStorage.get.mockImplementationOnce(() => JSON.stringify(
value,
expire: new Date('2030-1-1').getTime(),
));
它们是怎么mock方法的呢?其实是在mock的时候,就将这个方法放在cache中,当其他地方要import方法时,会先查看cache中有没有该方法,如果我们有mock了,他就使用mock的方法了。如果cache中没有该方法,再使用正常的方式import。
可以看看以下简化的原理:
const jest =
mock(mockPath, mockExports = )
const path = require.resolve(mockPath, paths: ["."] );
require.cache[path] =
id: path,
filename: path,
loaded: true,
exports: mockExports,
;
,
;
const jest =
fn(impl = () => )
const mockFn = (...args) =>
mockFn.mock.calls.push(args);
return impl(...args);
;
mockFn.originImpl = impl;
mockFn.mock = calls: [] ;
return mockFn;
,
;
2.3.5 更多组件测试方法
调用组件实例的 setData 方法:
comp.setData( text: 'a' , () => )
触发组件实例的生命周期钩子:
comp.triggerLifeTime('ready')
扩展 getApp()的返回结果,当组件中需要使用全局数据时,可通过该方式进行 mock:
const extendAppData = require("../app.data.json");
simulate.extendApp(extendAppData);
扩展 getCurrentPages()的返回结果,当组件中需要使用页面栈数据时,可通过该方式进行 mock:
simulate.extendCurrentPages(["pages/index/index.html"]);
模拟元素滚动:
simulate.scroll(comp, 100, 15) // 纵向滚动到 scrollTop 为 100 的位置,期间会触发 15 次 scroll 事件
获取符合给定匹配串的所有节点,返回 Component 实例列表:
const childComps = comp.querySelectorAll('.a')
3. 覆盖率
3.1 覆盖率包括
1.行覆盖率(line coverage):是否每一行都执行了?
2.函数覆盖率(function coverage):是否每个函数都调用了?
3.分支覆盖率(branch coverage):是否每个if代码块都执行了?
4.语句覆盖率(statement coverage):是否每个语句都执行了?
3.2覆盖率检测原理
插桩代码进行采集。
3.3 覆盖率报告
使用“jest --coverage”进行覆盖率测试时,会在项目里生成覆盖率报告:
给人看的:
报告示例:
4. 踩坑日志:
4.1 load的id为null 、render组件 undefined
load的路径必须为dist后的文件
4.2 s-jestjest-transformer Got a .js file to compile while allowJs option is not set to true (file: /dist-wx/components/game-earnings/index.js).
tsconfig.ts添加:
"allowJs": true,
4.3 cannot find module 'path' / '__dirname' or its corresponding type declarations.
安装 @types/node
tsconfig.ts添加:
"typeRoots": [
"node_modules/@types/node",
],
4.4 Module '"path"' can only be default-imported using the 'esModuleInterop' flag
tsconfig.ts添加:
"esModuleInterop": true,
4.5 找不到名称 "document"
tsconfig.ts添加:
"lib": ["dom"],
4.6 解决小程序编译与单测运行的类型定义重复问题:Cannot redeclare block-scoped variable 'require'
因为小程序编译时需要wx库,单测时需要node库,他们有一些相同的变量声明。解决办法:
tsconfig.ts加上:
"skipLibCheck": true,
5. 实验性测试:小程序真机测试
5.1 使用框架
miniprogram-automator
5.2 框架功能
1.操作 IDE(如打开开发者工具、打开小程序、关闭开发者工具、关闭小程序等)
2.调用小程序 API (如 navigateTo、getSystemInfo 等)
3.mock 小程序 api 调用结果
4.evaluate(向逻辑层注入代码片段并返回执行结果)
5.对页面元素进行操作(如 获取元素、获取属性、滑动 等)
5.3 简要流程
5.4 详细流程
关于腾讯WeTest
腾讯WeTest是由腾讯官方推出的一站式品质开放平台。十余年品质管理经验,致力于质量标准建设、产品质量提升。腾讯WeTest为移动开发者提供兼容性测试、云真机、性能测试、安全防护等优秀研发工具,为百余行业提供解决方案,覆盖产品在研发、运营各阶段的测试需求,历经千款产品磨砺。金牌专家团队,通过5大维度,41项指标,360度保障您的产品质量。
关注腾讯WeTest,了解更多热门测试产品:
WeTest腾讯质量开放平台https://wetest.qq.com/-专注游戏 提升品质
以上是关于微信小程序单元测试攻略的主要内容,如果未能解决你的问题,请参考以下文章