- (20)长连开发经验分享
在微信小程序/小游戏的开发中,网络传输主要是依靠http的短连接和webSocket 长连接来完成的。 在一般web服务中,大多使用短连接来向服务器请求资源,与服务器的交互频率低,次数少。而在一些需要与服务器交互频繁,需要及时收到服务器推送的场景,比如直播、多人实时游戏,更适合使用 webSocket 进行通讯。 这次的小故事主要分享 webSocket 在微信小程序/小游戏开发上的一些经验。 长连的生命周期介绍 webSocket的生命周期一共有4个状态:connecting、open、closing、closed。我们可以通过 socketTask 的 readyState 属性来获取当前 webSocket 长连的状态。webSocket 的生命周期过程和 API 间的调用关系可以简单的入下图所示。 [图片] 注意:只有长连处在 open 状态,才能够正常的收发消息,其他状态均会报错。 客户端长连的断开机制 当小游戏进入到后台运行超过5秒时,客户端会禁止小游戏的所有网络连接。这是一个非常频繁的断线逻辑,十分考验程序断线错误处理逻辑。建议大家可以在用户点击右上角按钮退出小程序/小游戏时,主动帮用户断线,待用户切回时再重接上去。 当 webSocket 长连超过一段时间没有任何网络传输时,客户端会主动关闭这条长连,以节省资源。开发者可以设置业务心跳,每隔一段时间与后台进行一次通讯,维持长连。 如何选择长连的接口 API接口主要有两类,一类是前缀为 “wx” 的接口,一类 “socketTask” 的接口。举例,同样是连接长连后发送一条消息,两种写法区别如下。 [图片] 最初小游戏只允许存在1个 webSocket 连接时,并没开放 socketTask 的管理方式。随着小游戏的能力提升,可支持同时存在的 webSocket 连接个数变多,在使用 wx.connectSocket 创建 webSocket 连接时会返回 socketTask 任务对象,便于去管理每一条连接链路。 推荐开发者尽量使用 socketTask 的方式去管理 webSocket 链接,每一条链路的生命周期都更加可控。同时存在多个 webSocket 的链接的情况下使用 wx 前缀的方法可能会带来一些和预期不一致的情况。例如:当存在多条连接时,wx.onSocketOpen、wx.sendSocketMessage、wx.onSocketMessage 等接口会只作用于第一条连接的长连。且wx.onSocketOpen 接口不能多次注册 webSocket 长连的回调函数,仅最后一次生效。使用 socketTask 任务的方式则不会出现上述问题。 开发与调试的建议 01微信提供了 webSocket 最基础的接口能力,开发者可以在其基础上进行封装,根据业务需要扩展能力。比如封装一个 offSocketOpen 的方法来取消注册 socketOpen 的回调函数。 02 长连并没有像短连那样“一问一答”的交互形式。在某些场景下,开发者需要这种与服务器的交互。建议前端与后台协议,每条客户端上行的信息,服务器都下发一个对应的回包,去“模拟短连”。比如开发者向服务器询问1+1和1+2等于多少,服务器返回了3和2,便可清晰知道哪一个数字对应着哪一个请求的答案。此外,还可以设置业务超时逻辑,便于判断上传是否丢包 03 在 webSocket 发送数据时,数据格式可以选择string或者ArrayBuffer。这里要注意的是,由于小游戏禁止了 Function() 和 eval()语法。所以像 protobufjs 这类用了这些语法的库是不能直接拿来用的。 04 在测试调试长连的时,目前开发者工具不支持通过设置 offline 模拟长连断网的情况(短连是支持的),所以在测试断线重连的一些情况时,可以辅助一些第三方工具,或者用真机调试以及“拔网线”的方式来测试。 05 在进行多人游戏测试时,在开发者工具中熟练使用“自定义编译条件”,以及“多账号调试”这两个功能可以极大的提升开发测试效率。 06 长连占用的系统资源,会导致手机发热比较明显。所以在不需要使用 webSocket 的场景下,建议及早断开长连,需要时再连接。 异常断线的监控 监控长连是否异常断线,在长连的使用中,尤其在小游戏多人对战中是尤为重要的。socketError 事件并不能认为是异常断线。 首先 socketError 事件并不一定会导致断线,其次若是由客户端机制断开的长连,是不会触发 socketError 事件的。 最简单的方式可以通过 onClose 回调函数触发时系统传入的 code 是否为1000来判断。当然开发者自身也可以通过代码判断是否是自身调用的 close 函数触发的 onclose 事件,监控异常断线。 希望大家在实际应用中能帮助到到大家。
2018-09-29 - 尝试用类vue3 composition-api 写小程序
尝试在小程序里使用 composition-api 下载 npm install miniprogram-composition-api --save 仓库地址 缺点 更新属性繁琐, 没有采用 Object.defineProperty(为了减少 属性添加删除上疑惑) 做监听, 也没有采用Proxy(版本问题) 导致 useCompute, useEffect 都需要开发者主动声明依赖 目前有线程直接基于 @vue/reactivity 写的框架, 更加方便,只是有版本要求, vue-mini props 还没有做代理 还在测试 不太适用于大量静态内容, 建议提前定义好data, 因为数据是在 onLoad/attached触发赋值的 自己就已经发现了好多问题,但是还没有好的办法解决 有点不伦不类的, react hooks和 composition-api 杂交了 只是个实验性, 用来玩玩的项目 setup setup 函数是一个新的组件选项。作为在组件内使用 Composition API 的入口点。 调用时机 在onLoad/attached时候被执行 参数 该函数接收 props 作为其第一个参数, context 第二个参数 [代码]definePage({ data: { test: '' }, setup(props, context) { console.log(props.name) } }) [代码] [代码]props[代码]暂时还没有做特殊处理, [代码]context[代码]作为上下文对象, 暴露了一些api [代码]definePage({ data: { test: '' }, setup(props, context) { context.event.on('load', () => {}); context.setData({ age: useRef(16)[0] }) } }) [代码] [代码]event[代码]是事件通知模块, 包含常用的emit, on, once, off, context注册的事件, 在页面/组件被销毁时也会主动off [代码]setData[代码]是封装后的setData, 支持对[代码]ref包装对象[代码]的解析, 其他用法和原生一致 [代码]this[代码]指向, 目前, setup, onLoad等其他的函数, this将会绑定到当前页面/组件实例 包裹对象useRef [代码]useRef[代码] [代码]返回值1[代码] 接受一个参数值并返回一个数组, 第一项 是被包装的对象。ref 对象拥有一个指向内部值的单一属性 .value。 [代码]const [count, setCount] = useRef(0) console.log(count.value) // 0 setCount(1) console.log(count.value) // 1 [代码] [代码]返回值1[代码] 是更改该值的方法, 接受一个值或者一个方法 [代码]const [count, setCount] = useRef(0) // 回调函数返回的值作为要赋值 setCount((value) => value + 1); setCount(value + 1); [代码] 更新值已经做了diff, 两次赋同一值将不会触发改变 采用了westore 的 json diff,用于对比文件并提取需要更改的路径, 用于最小化setData 在视图层中读取 当该值被[代码]setup[代码]返回, 将进入data值, 可在模板中被读取到, 会自动解套,无需在模板中额外书写[代码].value[代码] [代码]<template> <div>{{ count }}</div> </template> definePage({ setup(props, context) { const [count, setCount] = useRef(0) return { count, updateCount() { setCount(count.value + 1) } } } }) [代码] context.setData [代码]setup[代码]返回值其实也是执行了[代码]context.setData[代码] 计算属性 [代码]useComputed[代码] 返回一个 不可手动修改的 ref 对象。可以理解为没有set方法返回的useRef [代码]const [count, setCount] = useRef(1) const plusOne = computed(() => count.value + 1, [count]) console.log(plusOne.value) // 2 setCount(2) [代码] [代码]参数[代码] [代码]callback[代码] 监听变化的回调, 返回任意值 [代码]any[][代码] 这个框架没有做依赖收集, 需要用户主动传入所有的依赖, 当里面的依赖变化时, 会触发回调函数执行,计算 计算属性总是最少会执行一次,为了第一次赋值 监听Ref值更新 [代码]useEffect[代码] 当被监听的ref对象变化时, 将触发, 返回值是个方法, 用于停止监听 [代码]参数[代码] [代码]callback[代码] 监听变化的回调 [代码]any[][代码] 这个框架没有做依赖收集, 需要用户主动传入所有的依赖, 当里面的依赖变化时, 会触发回调函数执行 [代码]const [count, setCount] = useRef(1) const stopHandle = useEffect(() => { console.log('我发送了变化'); stopHandle() }, [count]) setCount(2) [代码] 声明周期函数 可以直接导入 [代码]onXXX[代码] 一族的函数来注册生命周期钩子: [代码]import { onAttached, onHide, onShow } from 'vue' const MyComponent = { setup() { onAttached(() => { console.log('mounted!') }) onHide(() => { console.log('updated!') }) onShow(() => { console.log('unmounted!') }) }, } [代码] 依赖注入 [代码]useProvide[代码] 和 [代码]useInject[代码], [代码]useInjectAsync[代码] 提供依赖注入, 功能和 [代码]Session[代码] 一致, 只是找了地方存了以下 不要这样做 setup 不能是异步 [代码]useEffect[代码], [代码]useComputed[代码]尽量在setup内做, 如果不是的话,注意做好清除清除监听 在未来某个时间 [代码]useEffect[代码], [代码]useComputed[代码], 在setup期间执行的监听操作都将绑定在该实例上, 在该实例销毁后, 也会同步取消监听事件, 如果你注册的监听,恰好某个组件执行了setup, 会出现, 他销毁后, 你注册的监听不起效果了, 一开始是不做这样的处理的, 只是为了避免大量的取消监听的写法, 于是做了这样的处理 我也很纠结, 这个问题一旦碰上了, 那就很致命了, 哎, 可是也没有特别好的办法 为了什么 替代 mixins, 代码复用新方案 全局状态管理, 计算属性, watch 版本要求不高() 解决页面状态一旦props很多地方,很深就很烦 data, methods 不再分散 降级版 没有采用 @vue/reactivity 因为 小程序经打点发现目前还有好多用户都不支持 Proxy,Reflect, 于是不采用了(已经有人写好了小程序版composition-api,可以直接用这个)[https://github.com/yangmingshan/vue-mini] 理论上应该没有基础库兼容问题 思考1 setup触发 是一开始小程序加载就触发,用来初始化数据,还是 onLoad,attached 来触发, 如果是onLoad来触发,setup里面注册onLoad事件,感觉有点奇怪 provide 是否要实现单例? 如果真的要单例, 其实可以不放在生命周期触发, 加载即触发, 只provide一次也是一样的效果 差异 setup this执行是组件的实例 自定义组件setup执行是在attached, 尽管create更早, 但是为了获取props, 所以就采用了attached了, props接下来需要ref化 useCompute, useEffect, 没有采用 Object.defineProperty 做依赖收集, 由开发者手动做依赖收集 注意 暂不支持 ref 嵌套 ref的情况, 也是可以支持的, 而且容易有问题, 就是 更改最外层的ref的值, 是否会能直接更改里面ref的值, 所以不支持这样 TODO 需要一个能 根据 key 实现缓存组件的效果, 多个同一个key 的组件共享状态, 声明周期也不应该重复触发 参考之前的hooks的那个声明周期,可以实现类似的 代理 props router.go({ url: ‘’, params:{} }), 自定义路由方法, params支持传入方法, 子页面可以被正常调用被传入的方法 5.1. router.back({ delta: 1, params: {}}) 后退的参数, 是否允许带到 onShow, 是否有必要 router支持别名, 用于解决以前是 /pages/logistics, 现在是 /sub-logistics/logsitcs 路径问题, 拦截这个别名, 跳转到我指定的路径 对于tabbar页面实现页面传参, 额外添加声明周期 onTabPageShow 可以接受到 跳转到当前页, 相当于 onShow生命周期,用于解决tab页面第二次进入onLoad不触发, onShow也没有参数的问题, 还需要配置 让框架知道 哪些页面是tabbar页面, onTabPageShow需兼容直接进入的情况, 不通过自带的参数进来也需要能参数带来 全局Components, Page混入还是有必要的, 比如 小程序双向绑定通过 bind:ing="$", 需要功能混入 $方法 inject感觉还可以更强大, 比如setup内的在组件或小程序注销后,也会被注销 setup 支持异步 [代码]import { defineComponent } from ''; defineComponent({ setup(props) { /** * useRef返回是个数组, 数组第一个是 返回的可被监听的 对象, .value访问存储的值, 返回的第二个是个方法,用来触发改变的 * 在视图层不需要 .value 来访问 */ const [ name, setName ] = useRef('along'); setName('along1'); // 计算属性返回的也是个可被观察的对象, .value是值 const sayName = useCompute(() => { return '我名字叫' + name.value }, [ name ]); // watch,需手动传入要监听的 const stopHandle = useEffect(() => { console.log('监听name'); }, [ name ]); // 停止监听 // stopHandle(); return { name, setName } } }) [代码] 下一版本将支持router带方法传递 方法可以类似于props被带过来, 主要为了减少事件发布订阅 [代码] import { router } from ''; router.go({ url: '/pages/createSuccess?isplit=123', params: { onSubmit() { } } }) // /pages/createSuccess definePage((props) => { props.onSubmit && props.onSubmit(); }); [代码] 场景问题解决 为了解决mixins问题 示例 searchList [代码]function useSearchList () { const [ pageStatus, setPageStatus] = useRef({ page: 1, pageSize: 10, loadStatus: { isLoading: false, isEnd: false, isEmpt: false, isError: false } }); const searchList = async function (api, params, options) { setPageStatus(status => { status.loadStatus.isLoading = true; return status }); try { await api(Object.assign({ page: pageStatus.value.page, pageSize: pageStatus.value.pageSize }, params)); setPageStatus(status => { status.page += 1; return status }); } catch (e) { setPageStatus(status => { status.loadStatus.isError = true; return status }); } finally { setPageStatus(status => { status.loadStatus.isLoading = false; return status }); } } return { pageStatus, searchList } } createComponent({ props: { name: string }, /** * 构建页面方法, 注意, 这个是小程序加载就执行的, 不要做什么错误的示例, 只能做初始化的 */ setup () { const { pageStatus, searchList, run, reset, refresh } = useSearchList(); run(async () => { const { data } = await searchList(api.pack.getList, {}, {}); }) onLoad((props) => { reset(); }) onShow(() => { refresh(); }) return { pageStatus, renderList } } }) [代码] 子组件需要等待某个数据完成 场景, 页面有两个组件, 依赖父亲的值做渲染, 可是, 依赖某些数据, 是异步来的, 需要一个合适的方法, 让子组件知道什么父亲什么时候完成了? 事件通知, 同时支持 回调 和 promise, 事件通知, 多了会很讨厌的 就是有了数据再渲染组件,可不适用于所有的场景 [代码]<template> <child1 title="title"></child1> <child2 packStatus="packStatus"></child2> </template> Page({ data: { title: '准备中', packStatus: { id: 0, name: '准备中' } }, setup() { onLoad (async() => { const id = await Api(); useEmit('packageStatus', { id }) }) } }); [代码] 下面这个方式不行, 影响到了声明周期的调用,很容易留坑 [代码]Componet(async () => { const packageStatus = await useInjectAsync('packageStatus'); /** 应该还支持动态再新增属性 */ useSetData() return { packageStatus } }) [代码]
2020-06-16 - 校友会小程序开发笔记十二: 小程序异常监控及错误处理
在小程序中使用抛出异常机制能让代码结构更加的简洁,减少很多的逻辑判断,并且能够得到出错时的详细错误信息,可说是好处多多,今天 要说的就是在校友录小程序的js中抛出(throw)异常。js中可以抛出任何类型的异常,比如数字、字符串甚至布尔值,例如: <script> try { throw 'error'; throw 123; throw false; } catch (e) { alert(e); } </script> 当然,像大多数的面向对象语言中有内置的Exception类一样,js中也有内置的异常类: Error , 我们可以自定义异常类并继承Error基类: /** * Notes: 应用异常处理类 * Date: 2020-09-05 04:00:00 * Version : CCMiniCloud Framework Ver 2.0.1 const appCode = require('./app_code.js'); class AppError extends Error { constructor(message, code = appCode.LOGIC) { super(message); this.name = 'AppError'; this.code = code; } } module.exports = AppError; 错误代码定义: /** * Notes: 错误代码定义 * Ver : CCMiniCloud Framework 2.0.1 * Date: 2020-09-05 04:00:00 * Version : CCMiniCloud Framework Ver 2.0.1 */ module.exports = { SUCC: 200, SVR: 500, //服务器错误 LOGIC: 1600, //逻辑错误 DATA: 1301, // 数据校验错误 HEADER: 1302, // header 校验错误 NOT_USER: 1303, // 用户不存在 USER_EXCEPTION: 1304, // 用户异常 MUST_LOGIN: 1305, //需要登录 USER_CHECK: 1306, //用户审核中 ADMIN_ERROR: 2001 //管理员错误 } 在校友录小程序中应用: [图片] // 取得openid const cloud = cloudBase.getCloud(); const wxContext = cloud.getWXContext(); let r = ''; try { if (!util.isDefined(event.router)) { console.error('Router Not Defined'); return handlerSvrErr(); } r = event.router.toLowerCase(); // 路由不存在 if (!util.isDefined(router[r])) { console.error('Router [' + r + '] Is Not Exist'); return handlerSvrErr(); } let routerArr = router[r].split('@'); let controllerName = routerArr[0]; let actionName = routerArr[1]; let token = event.token || ''; let params = event.params; console.log(''); console.log(''); let time = timeUtil.time('Y-M-D h:m:s'); console.log('+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++'); console.log('[' + time + '][Request][Route=' + r + '], Controller=[' + controllerName + '], Action=[' + actionName + '], Token=[' + token + '], ###IN DATA=\r\n', JSON.stringify(params, null, 4)); let openId = wxContext.OPENID; if (!openId) { console.error('OPENID is unfined'); if (config.TEST_MODE) openId = config.TEST_TOKEN_ID; else return handlerSvrErr(); } // 引入逻辑controller controllerName = controllerName.toLowerCase().replace('controller', '').trim(); const ControllerClass = require('controller/' + controllerName + '_controller.js'); const controller = new ControllerClass(openId, params, r, token); // 调用方法 let result = await controller[actionName](); // 返回值处理 if (!result) result = handlerSucc(r); // 无数据返回 else result = handlerData(result, r); // 有数据返回 console.log('------'); time = timeUtil.time('Y-M-D h:m:s'); console.log('[' + time + '][Response][Route=' + r + '], ###OUT DATA=', result); console.log('+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++'); console.log(''); console.log(''); return result; } catch (ex) { const log = cloud.logger(); if (ex.name == 'AppError') { log.warn({ router: r, errCode: ex.code, errMsg: ex.message }); // 自定义error处理 return handlerAppErr(ex.message, ex.code); } else { console.log(ex); log.error({ router: r, errCode: ex.code, errMsg: ex.message, errStack: ex.stack }); // 系统error return handlerSvrErr(); } } 代码网址: https://gitee.com/cclinux2/cc-alumni
2021-01-07 - wx.request 经 Promise 封装后,如何拿到requestTask
大家会用 promise 将 wx.request 包装一层。但经过这么一层包装后,就拿到不到 requestTask,从而调用不了 abort 方法。大家都是如何解决的? 代码来自:https://www.kancloud.cn/xiaoyulive/wechat/526990 [代码]class Request {[代码][代码] [代码][代码]constructor (parms) {[代码][代码] [代码][代码]this[代码][代码].withBaseURL = parms.withBaseURL[代码][代码] [代码][代码]this[代码][代码].baseURL = parms.baseURL[代码][代码] [代码][代码]}[代码][代码] [代码][代码]get (url, data) {[代码][代码] [代码][代码]return[代码] [代码]this[代码][代码].request([代码][代码]'GET'[代码][代码], url, data)[代码][代码] [代码][代码]}[代码][代码] [代码][代码]post (url, data) {[代码][代码] [代码][代码]return[代码] [代码]this[代码][代码].request([代码][代码]'POST'[代码][代码], url, data)[代码][代码] [代码][代码]}[代码][代码] [代码][代码]put (url, data) {[代码][代码] [代码][代码]return[代码] [代码]this[代码][代码].request([代码][代码]'PUT'[代码][代码], url, data)[代码][代码] [代码][代码]}[代码][代码] [代码][代码]request (method, url, data) {[代码][代码] [代码][代码]const vm = [代码][代码]this[代码][代码] [代码][代码]return[代码] [代码]new[代码] [代码]Promise((resolve, reject) => {[代码][代码] [代码][代码]wx.request({[代码][代码] [代码][代码]url: vm.withBaseURL ? vm.baseURL + url : url,[代码][代码] [代码][代码]data,[代码][代码] [代码][代码]method,[代码][代码] [代码][代码]success (res) {[代码][代码] [代码][代码]resolve(res)[代码][代码] [代码][代码]},[代码][代码] [代码][代码]fail () {[代码][代码] [代码][代码]reject({[代码][代码] [代码][代码]msg: [代码][代码]'请求失败'[代码][代码],[代码][代码] [代码][代码]url: vm.withBaseURL ? vm.baseURL + url : url,[代码][代码] [代码][代码]method,[代码][代码] [代码][代码]data[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码] [代码][代码]})[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码]}[代码] [代码]const request = [代码][代码]new[代码] [代码]Request({[代码][代码] [代码][代码]baseURL: [代码][代码]'http://test'[代码][代码],[代码][代码] [代码][代码]withBaseURL: [代码][代码]true[代码][代码]})[代码] [代码]module.exports = request[代码]
2019-03-22 - 有赞前端质量保障体系
前言 最近一年多一直在做前端的一些测试,从小程序到店铺装修,基本都是纯前端的工作,刚开始从后端测试转为前端测试的时候,对前端东西茫然无感,而且团队内没有人做过纯前端的测试工作,只能一边踩坑一边总结经验,然后将容易出现问题的点形成体系、不断总结摸索,最终形成了目前的一套前端测试解决方案。在此,将有赞的前端质量保障体系进行总结,希望和大家一起交流。 先来全局看下有赞前端的技术架构和针对每个不同的层次,主要做了哪些保障质量的事情: [图片] [图片] 有赞的 Node 技术架构分为业务层、基础框架层、通用组件和基础服务层,我们日常比较关注的是基础框架、通用组件和业务层代码。Node 业务层做了两件事情,一是提供页面渲染的 client 层,用于和 C 端用户交互,包括样式、行为 js 等;二是提供数据服务的 server 层,用于组装后台提供的各种接口,完成面向 C 端的接口封装。 对于每个不同的层,我们都做了一些事情来保障质量,包括: 针对整个业务层的 UI 自动化、核心接口|页面拨测; 针对 client 层的 sentry 报警; 针对 server 层的接口测试、业务报警; 针对基础框架和通用组件的单元测试; 针对通用组件变更的版本变更报警; 针对线上发布的流程规范、用例维护等。 下面就来分别讲一下这几个维度的质量保障工作。 一、UI 自动化 很多人会认为,UI 自动化维护成本高、性价比低,但是为什么在有赞的前端质量保证体系中放在了最前面呢? 前端重用户交互,单纯的接口测试、单元测试不能真实反映用户的操作路径,并且从以往的经验中总结得出,因为各种不可控因素导致的发布 A 功能而 B 功能无法使用,特别是核心简单场景的不可用时有出现,所以每次发布一个应用前,都会将此应用提供的核心功能执行一遍,那随着业务的不断积累,需要回归的测试场景也越来越多,导致回归的工作量巨大。为了降低人力成本,我们亟需通过自动化手段释放劳动力,所以将核心流程回归的 UI 自动化提到了最核心地位。 当然,UI 自动化的最大痛点确实是维护成本,为降低维护成本,我们将页面分为组件维度、页面维度,并提供统一的包来处理公用组件、特殊页面的通用逻辑,封装通用方法等,例如初始化浏览器信息、环境选择、登录、多网点切换、点击、输入、获取元素内容等等,业务回归用例只需要关注自己的用例操作步骤即可。 1、框架选择 – puppeteer[1],它是由 Chrome 维护的 Node 库,基于 DevTools 协议来驱动 chrome 或者 chromium 浏览器运行,支持 headless 和 non-headless 两种方式。官网提供了非常丰富的文档,简单易学。 UI 自动化框架有很多种,包括 selenium、phantom;对比后发现 puppeteer 比较轻量,只需要增加一个 npm 包即可使用;它是基于事件驱动的方式,比 selenium 的等待轮询更稳当、性能更佳;另外,它是 chrome 原生支持,能提供所有 chrome 支持的 api,同时我们的业务场景只需要覆盖 chrome,所以它是最好的选择。 – mocha[2] + mochawesome[3],mocha 是比较主流的测试框架,支持 beforeEach、before、afterEach、after 等钩子函数,assert 断言,测试套件,用例编排等。 mochawesome 是 mocha 测试框架的第三方插件,支持生成漂亮的 html/css 报告。 js 测试框架同样有很多可以选择,mocha、ava、Jtest 等等,选择 mocha 是因为它更灵活,很多配置可以结合第三方库,比如 report 就是结合了 mochawesome 来生成好看的 html 报告;断言可以用 powser-assert 替代。 2、脚本编写 封装基础库 封装 pc 端、h5 端浏览器的初始化过程 封装 pc 端、h5 端登录统一处理 封装页面模型和组件模型 封装上传组件、日期组件、select 组件等的统一操作方法 封装 input、click、hover、tap、scrollTo、hover、isElementShow、isElementExist、getElementVariable 等方法 提供根据 “html 标签>>页面文字” 形式获取页面元素及操作方法的统一支持 封装 baseTest,增加用例开始、结束后的统一操作 封装 assert,增加断言日志记录 业务用例 安装基础库 编排业务用例 3、执行逻辑 分环境执行 增加预上线环境代码变更触发、线上环境自动执行 监控源码变更 增加 gitlab webhook,监控开发源码合并 master 时自动在预上线环境执行 增加 gitlab webhook,监控测试用例变更时自动在生产环境执行 每日定时执行 增加 crontab,每日定时执行线上环境 [图片] [图片] [图片] [图片] 二、接口测试 接口测试主要针对于 Node 的 server 层,根据我们的开发规范,Node 不做复杂的业务逻辑,但是需要将服务化应用提供 dubbo 接口进行一次转换,或将多个 dubbo 接口组合起来,提供一个可供 h5/小程序渲染数据的 http 接口,转化过程就带来了各种数据的获取、组合、转换,形成了新的端到端接口。这个时候单单靠服务化接口的自动化已经不能保障对上层接口的全覆盖,所以我们针对 Node 接口也进行自动化测试。为了使用测试内部统一的测试框架,我们通过 java 去请求 Node 提供的 http 接口,那么当用例都写好之后,该如何评判接口测试的质量?是否完全覆盖了全部业务逻辑呢?此时就需要一个行之有效的方法来获取到测试的覆盖情况,以检查有哪些场景是接口测试中未覆盖的,做到更好的查漏补缺。 – istanbul[4] 是业界比较易用的 js 覆盖率工具,它利用模块加载的钩子计算语句、行、方法和分支覆盖率,以便在执行测试用例时透明的增加覆盖率。它支持所有类型的 js 覆盖率,包括单元测试、服务端功能测试以及浏览器测试。 但是,我们的接口用例写在 Java 代码中,通过 Http 请求的方式到达 Node 服务器,非 js 单测,也非浏览器功能测试,如何才能获取到 Node 接口的覆盖率呢? 解决办法是增加 cover 参数:–handle-sigint,通过增加 --handle-sigint 参数启动服务,当服务接收到一个 SIGINT 信号(linux 中 SIGINT 关联了 Ctrl+C),会通知 istanbul 生成覆盖率。这个命令非常适合我们,并且因此形成了我们接口覆盖率的一个模型: [代码]1. istanbule --handle-sigint 启动服务 2. 执行测试用例 3. 发送 SIGINT结束istanbule,得到覆盖率 [代码] 最终,解决了我们的 Node 接口覆盖率问题,并通过 jenkins 持续集成来自动构建 [图片] [图片] [图片] 当然,在获取覆盖率的时候有需求文件是不需要统计的,可以通过在根路径下增加 .istanbule.yml 文件的方式,来排除或者指定需要统计覆盖率的文件 [代码]verbose: false instrumentation: root: . extensions: - .js default-excludes: true excludes:['**/common/**','**/app/constants/**','**/lib/**'] embed-source: false variable: __coverage__ compact: true preserve-comments: false complete-copy: false save-baseline: false baseline-file: ./coverage/coverage-baseline.json include-all-sources: false include-pid: false es-modules: false reporting: print: summary reports: - lcov dir: ./coverage watermarks: statements: [50, 80] lines: [50, 80] functions: [50, 80] branches: [50, 80] report-config: clover: {file: clover.xml} cobertura: {file: cobertura-coverage.xml} json: {file: coverage-final.json} json-summary: {file: coverage-summary.json} lcovonly: {file: lcov.info} teamcity: {file: null, blockName: Code Coverage Summary} text: {file: null, maxCols: 0} text-lcov: {file: lcov.info} text-summary: {file: null} hooks: hook-run-in-context: false post-require-hook: null handle-sigint: false check: global: statements: 0 lines: 0 branches: 0 functions: 0 excludes: [] each: statements: 0 lines: 0 branches: 0 functions: 0 excludes: [] [代码] 三、单元测试 单元测试在测试分层中处于金字塔最底层的位置,单元测试做的比较到位的情况下,能过滤掉大部分的问题,并且提早发现 bug,也可以降低 bug 成本。推行一段时间的单测后发现,在有赞的 Node 框架中,业务层的 server 端只做接口组装,client 端面向浏览器,都不太适合做单元测试,所以我们只针对基础框架和通用组件进行单测,保障基础服务可以通过单测排除大部分的问题。比如基础框架中店铺通用信息服务,单测检查店铺信息获取;比如页面级商品组件,单测检查商品组件渲染的 html 是否和原来一致。 单测方案试行了两个框架: Jest[5] ava[6] 比较推荐的是 Jest 方案,它支持 Matchers 方式断言;支持 Snapshot Testing,可测试组件类代码渲染的 html 是否正确;支持多种 mock,包括 mock 方法实现、mock 定时器、mock 依赖的 module 等;支持 istanbule,可以方便的获取覆盖率。 总之,前端的单测方案也越来越成熟,需要前端开发人员更加关注 js 单测,将 bug 扼杀在摇篮中。 四、基础库变更报警 上面我们已经对基础服务和基础组件进行了单元测试,但是单测也不能完全保证基础库的变更完全没有问题,伴随着业务层引入新版本的基础库,bug 会进一步带入到业务层,最终影响 C 端用户的正常使用。那如何保障每次业务层引入新版本的基础库之后能做到全面的回归?如何让业务测试同学对基础库变更更加敏感呢?针对这种情况,我们着手做了一个基础库版本变更的小工具。实现思路如下: [代码]1. 对比一次 master 代码的提交或 merge 请求,判断 package.json 中是否有特定基础库版本变更 2. 将对应基础库的前后两个版本的代码对比发送到测试负责人 3. 根据 changelog 判断此次回归的用例范围 4. 增加 gitlab webhook,只有合并到合并发布分支或者 master 分支的代码才触发检查 [代码] 这个小工具的引入能及时通知测试人员针对什么需求改动了基础组件,以及这次基础组件的升级主要影响了哪些方面,这样能避免相对黑盒的测试。 第一版实现了最简功能,后续再深挖需求,可以做到前端代码变更的精准测试。 [图片] 五、sentry 报警 在刚接触前端测试的时候,js 的报错没有任何追踪,对于排查问题和定位问题有很大困扰。因此我们着手引入了 sentry 报警监控,用于监控线上环境 js 的运行情况。 – sentry[7] 是一款开源的错误追踪工具,它可以帮助开发者实时监控和修复崩溃。 开始我们接入的方式比较简单粗暴,直接全局接入,带来的问题是报警信息非常庞大,全局上报后 info、warn 信息都会打出来。 更改后,使用 sentry 的姿势是: sentry 的全局信息上报,并进行筛选 错误类型: TypeError 或者 ReferenceError 错误出现用户 > 1k 错误出现在 js 文件中 出现错误的店铺 > 2家 增加核心业务异常流程的主动上报 最终将筛选后的错误信息通过邮件的形式发送给告警接收人,在固定的时间集中修复。 [图片] [图片] 六、业务报警 除了 sentry 监控报警,Node 接口层的业务报警同样是必不可少的一部分,它能及时发现 Node 提供的接口中存在的业务异常。这部分是开发和运维同学做的,包括在 Node 框架底层接入日志系统;在业务层正确的上报错误级别、错误内容、错误堆栈信息;在日志系统增加合理的告警策略,超过阈值之后短信、电话告警,以便于及时发现问题、排查问题。 业务告警是最能快速反应生产环境问题的一环,如果某次发布之后发生告警,我们第一时间选择回滚,以保证线上的稳定性。 七、约定规范 除了上述的一些测试和告警手段之外,我们也做了一些流程规范、用例维护等基础建设,包括: 发布规范 多个日常分支合并发布 限制发布时间 规范发布流程 整理自测核心检查要点 基线用例库 不同业务 P0 核心用例定期更新 项目用例定期更新到业务回归用例库 线上问题场景及时更新到回归用例库 目前有赞的前端测试套路基本就是这样,当然有些平时的努力没有完全展开,例如接口测试中增加返回值结构体对比;增加线上接口或页面的拨测[8];给开发进行自测用例设计培训等等。也还有很多新功能探索中,如接入流量对比引擎,将线上流量导到预上线环境,在代码上线前进行对比测试;增加UI自动化的截图对比;探索小程序的UI自动化等等。 参考链接 [1] https://github.com/GoogleChrome/puppeteer [2] https://www.npmjs.com/package/mocha [3] https://www.npmjs.com/package/mochawesome [4] https://github.com/gotwarlost/istanbul [5] https://github.com/facebook/jest [6] https://github.com/avajs/ava [7] https://docs.sentry.io [8] https://tech.youzan.com/youzan-online-active-testing/
2019-04-25