Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mpx 小程序单元测试 #1

Open
Blackgan3 opened this issue Jan 31, 2022 · 1 comment
Open

Mpx 小程序单元测试 #1

Blackgan3 opened this issue Jan 31, 2022 · 1 comment

Comments

@Blackgan3
Copy link
Owner

什么是单元测试

In computer programming, unit testing is a software testing method by which individual units of source code—sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures—are tested to determine whether they are fit for use  _ wikipedia

对一个函数,模块,类进行运行结果正确性检验的工作就是单元测试,此外每个单元测试的对象应该是一个最简单的组件/函数。

那写单测又能给我们哪些收益呢?

  • 大幅提高项目代码可维护性
  • 覆盖率到达一定指标后可大幅提高研发效率
  • 让你的代码零线上bug,上线不再提心吊胆

此外,单测高覆盖率的项目也会给公司节省大量支出:
unit-test-money.png
据统计结果,如上图展示,绝大多数bug都是在coding阶段产生,并且随着需求开发进度的推进,修复bug的成本会随指数级增长,当我们在unit test阶段发现并修复这个bug是能给公司带来巨大收益的。

下方介绍两种常见的项目单元测试规范:

  • TDD (Test driven development)

测试驱动开发,在书写业务代码之前,先根据需求进行单元功能测试用例书写

这里驱动开发的不是简单的测试用例,是能够持续验证、重构并且对需求功能极致细化的测试用例

  • BDD (Behavior driven development)

行为驱动开发,通过具有辨识力的测试用例驱动项目开发团队所有人,使用自然语言来描述功能

单元测试的书写初期必定伴随着大量精力与时间的消耗,但长期持续维护的业务在搭建并完善好整个单元测试系统后,可大大提高项目稳定性和研发效率

前端单元测试

前端单元测试框架

前端单元测试有很多框架,我们下方列出三个较为流行的框架进行介绍

  • Karma:A simple tool that allows you to execute JavaScript code in multiple real browsers
  • Mocha: 功能丰富的 javascript 测试框架,运行在node.js和浏览器中
  • Jest:一个全面的 javascript 测试解决方案,适用于绝大多数 js 项目

测试断言库

在单测运行框架中,我们需要断言库来进行方法返回和实例状态的正确性验证

  • chai:  expect(),assert()和should风格的断言都支持,全能型选手
  • should:  BDD风格贯穿始终 (true).should.be.ok
  • expect: expect()样式断言 expect(true).toBe(true)
  • assert: Node.js 内置断言模块 assert(true === true)

在众多前端单元测试框架中,jest 目前凭借零配置,高性能,且对于断言,快照,覆盖率等都有很好的集成,目前是较为流行的一个单测框架

jest 框架简介

这里我们简单来看下 jest 框架的特点以及大致的运行原理

jest 的整体框架特点大概归纳总结为以下几点:

  • 在操作系统上高效的进行文件搜索以及相互依赖关系匹配
  • 单元测试并行执行
  • 内置断言库、覆盖率、快照测试等,开箱可用
  • 单元测试之间相互隔离,使用 vm 来进行沙盒环境隔离

image.png
打开 jest pacakges,可以看到大概有50多个包,这里我们根据这些不同的包来将整个 jest 运行流程大概串起来

第一步 jest-cli 读取相关配置
当我们执行 jest 命令时,先去执行 jest-cli 中的 run 方法,再调用 jest-core 中的 runCli 方法,其中通过 jest-config 提供的 readConfigs 来读取 jest 相关配置,返回全局配置(globalConfig)和局部配置(configs)

unit-test-jest-step1.png

第二步 文件静态分析
使用 jest-haste-map 库来进行项目中所有文件的检索以及文件之间的相互依赖关系,在 jest-core 中的 _run10000 方法中执行 buildContextsAndHasteMaps,返回 contexts 和 hasteMapInstances,contexts 中的 hasteFs 就是文件以及依赖关系的存储。

�**jest-haste-map **检索的过程中借助 jest-worker 来根据当前cpu核数并行的进行文件检索,借助 fb-watch-man/crawler 对整体文件变动做实时监听,做到只执行最小改动的单元测试,实现缓存效果。

unit-test-jest-haste.png
下方看一个简单的jest-haste-map使用示例

// index.js
import JestHasteMap from 'jest-haste-map';
import {cpus} from 'os';

const hasteMap = new JestHasteMap.default({
  extensions: ['js'],
  maxWorkers: cpus().length,
  name: 'test',
  platforms: []
});

const {hasteFS} = await hasteMap.build();
const testFiles = hasteFS.getAllFiles();

console.log(testFiles);
// ['/path/to/tests/list1.spec.js', '/path/to/tests/list2.spec.js', …]

第三步 单测执行前排序
经过第一步和第二步,我们拿到了 配置对象 configs,以及文件Map HasteContext,通过 SearchSource 对象检索出所有的单元测试到一个数组中,接下来根据配置项去执行这些单测了,不过在正式执行之前,我们需要先对当前拿到的所有单测进行权重优先级排序。

通过 jest Sequencer 进行单测排序,排序规则为 failed(上次失败的先运行)> duration(耗时长的先运行) > size(文件体积大的先运行),当然这里你也可以自定义customSequencer来覆盖 jest 默认的排序规则,jest 排序规则如下。

		return tests.sort((testA, testB) => {
      if (failedA !== failedB) {
        return failedA ? -1 : 1;
      } else if (hasTimeA != (testB.duration != null)) {
        // If only one of two tests has timing information, run it last
        return hasTimeA ? 1 : -1;
      } else if (testA.duration != null && testB.duration != null) {
        return testA.duration < testB.duration ? 1 : -1;
      } else {
        return fileSize(testA) < fileSize(testB) ? 1 : -1;
      }
    });

unit-test-jest-scquencer.png
第四步 开始执行
通过 TestScheduler 来进行单元测试执行调度,例如 scheduler 会推算是串行执行还是并行执行,scheduler 之后会调用 jest-runner 中的 runTests 方法

runTests 方法, 使用 jest-worker 创建多个worker thread 或者是 child process 池子来对 tests parallel 执行

单元测试中所写的全局方法和变量中,比如 test() describe() it() 等是由 jest-cirucs/jest-jasmine 提供并写入global中,同时真正触发执行tests的方法也在jest-circus/jest-jasmines中提供,整个单测执行流程中单测状态以及执行结果是由 jjest-circus/jest-jasmines中提供的一套类似于 redux 的数据流机制来进行管理维护

unit-test-jest-run-test.png
最终 js 的执行是由 jest-runtime 中提供的 vm 虚拟机隔离执行,vm 作用域中的方法,比如 setTimeout/setInterval/document等,是由 jest-environment-(node/jsdom) 提供,jest-runtime还提供了require的具体实现,mocking的具体实现,以及transformer等,在接下来的Mpx框架单元测试章节我们会去详细介绍它。

第五步 处理返回结果
当执行结果从 jest/circus 返回后,jest 对数据进行序列化处理后吐给 jest-runner和 scheduler,最后在 jest-core 中的runJest方法中进行执行结果的终端输出/文件输出等一系列处理。

小程序单元测试

与 web 应用的不同

上个章节讲完前端单测,以及jest单测框架的大概运行原理后,我们来看下小程序中的单测与web应用的不同

小程序本身是双线程分离的机制,但目前并没有这种独特的运行环境可以用来进行单元测试,这里需要借助 miniprogram-simulate 工具集,来将整体运行机制调整为单线程模拟运行,并利用 dom 环境来进行渲染,从而完成整个自定义组件树的搭建

运行小程序的单元测试依赖 js 运行环境和 dom 环境,这里我们选择 jest 框架来提供对应的环境

test('comp', () => {
    const id = simulate.load(path.join(__dirname, './comp')) // 加载自定义组件,返回组件 id
    const comp = simulate.render(id) // 使用 id 渲染自定义组件,返回组件封装实例

    const parent = document.createElement('parent-wrapper') // 创建容器节点
    comp.attach(parent) // 将组件插入到容器节点中,会触发 attached 生命周期

    expect(comp.dom.innerHTML).toBe('<div>123</div>') // 判断组件渲染结果
    // 执行其他的一些测试逻辑

    comp.detach() // 将组件从容器节点中移除,会触发 detached 生命周期
})

上边是一个简单的测试demo,具体关于miniprogram-simulate 的使用详情可以去官方文档查看 https://github.com/wechat-miniprogram/miniprogram-simulate

此外对于小程序工具集的整体运行流程,在下方章节进行了简要总结。

小程序单测框架整体流程

  1. 首先小程序单元测试工具的整体流程控制工具是miniprogram-simulate, 该工具会进行小程序内置组件的注册以及模拟微信原生api的注入,每个单测都是从调用load方法注册组件开始:
  • 执行 miniprogram-simulate load 方法,传入对应组件路径
  • 调用 miniprogram-simulate register 函数进行自定义组件注册
  • 定义component definition对象
  • 递归处理组件 usingComponents,读取 wxss,读取 wxml 并经过 miniprogram-compiler 处理
  • jest require 方法加载并执行js文件, run component js in cache
  • 触发全局定义 global.component 方法,调用 j-component 中 register 方法
  1. 接下来进入j-component 库中
  • 调用 j-component/src/componentManager,创建 componentManager 实例
	/**
   * 注册组件
   */
  register(definition = {}) {
    const componentManager = new ComponentManager(definition)

    return componentManager.id
  }
  • componentManager 初始化中调用 minipgoram-exparser registerElement 进行自定义元素注册

众所周知,小程序基础库提供组件和API,处理数据绑定、组件系统、事件系统、通信系统等一系列框架逻辑,这里miniprogram-exparser 就是小程序的组件系统模块,exparser 的组件模型和 WebComponents标准中的 ShadowDOM 高度相似,维护整个页面的节点树,包括属性,事件等,类似一个简化版的 Shadow DOM。

  • 将整个 componentManager 以 id 为 key 缓存,将 id 返回
// j-component/src/componentManager
	constructor(definition) {
    this.id = definition.id || _.getId(true)
    this.path = _.normalizeAbsolute(definition.path)
    this.definition = definition

    if (definition.tagName) _.setTagName(this.id, definition.tagName) // 保存标签名
    if (this.path) PATH_TO_ID_MAP[this.path] = this.id // 保存 path 到 id 的映射

    const template = definition.template

    this.data = {}
    this.generateFunc = typeof template === 'function' ? transform(template, definition.usingComponents || {}) : compile(template, this.data, definition.usingComponents || {}) // 解析编译模板
    this.exparserDef = this.registerToExparser()

    _.cache(this.id, this)
  },
  registerToExparser() {
    ...
    const exparserDef = {
      is: this.id,
      using,
      generics: [], // TODO
      template: {
        func: this.generateFunc,
        data: this.data,
      },
      properties: definition.properties,
      data: definition.data,
      methods: definition.methods,
      behaviors: definition.behaviors,
      created: definition.created,
      attached: definition.attached,
      ready: definition.ready,
      moved: definition.moved,
      detached: definition.detached,
      saved: definition.saved,
      restored: definition.restored,
      relations: definition.relations,
      externalClasses: definition.externalClasses,
      options: {
        ....
      },
      lifetimes: definition.lifetimes,
      pageLifetimes: definition.pageLifetimes,
      observers: definition.observers,
      definitionFilter,
      ...
    }
    return exparser.registerElement(exparserDef)
  }
  
  1. 拿到注册后的component id,执行 simulate.render(id)
  • miniprogram-simulate中render方法会调用j-component create,根据id从缓存对象中获取componentManager,进行组件实例创建
	// j-component/src/index.js
  /**
   * 创建组件实例
   */
  create(id, properties) {
    const componentManager = _.cache(id)

    if (!componentManager) return

    return new RootComponent(componentManager, properties)
  },
  • RootComponent 构造函数中使用之前的 _exparserReg 对象进行真实dom节点创建,生成 _exparserNode
class RootComponent extends Component{
	constructor(componentManager, properties) {
  	...
    this._exparserNode = exparser.createElement(tagName || id, exparserDef) // create exparser node and render
		...
    this._bindEvent() // touchstart,touchemove blur 等事件绑定
  }
}
  • 新生成的 rootComponent 实例继承Component对象,定义了许多我们单测中需要用到的组件方法
class Component {
	get dom() ...
  get data() ....
  get instance ...
  dispatchEvent ...
  addEventListener ...
  removeEventListener ...
  querySelector ...
  setData ...
  triggerLifeTime ...
  triggerPageLifeTime ...
  toJSON...
}

unit-test-miniprogram.png
当然中间还有很多细节实现,比如模版渲染 j-component/template/compile,组件更新 j-component/render 等,感兴趣的话可以详细去看下里边具体的实现,这里我们暂且按下不表。至此,我们拿到了 component 实例,并可以进行正常的组件状态获取以及更新,然后在jest框架中去断言组件的各种属性以及方法执行后的预期。

Mpx 框架单元测试

经过上方 jest 框架讲解以及小程序单元测试流程分析,接下来看下在Mpx框架中的单测能力支持实现

初期版本

Mpx框架的初期单测架构,是将Mpx框架开发的小程序项目,先构建编译为源码,再使用 miniprogram-simulate + j-component + jest 对构建后的小程序原生代码运行单元测试
mpx-old-unit-test-architecture.png
该方案执行任何case都需要执行完整的构建流程,而且预构建已经完成了所有的模块收集,无法使用jest提供的模块mock功能,导致业务使用成本很高,落地困难。

改良版本

通过jest提供的transform能力编写mpx-jest插件,实现在jest模块加载过程中实时地将当前的.mpx组件编译转换为原生小程序组件,再交由miniprogram-simulate加载运行测试case。该方案中模块加载完全基于jest并能实现按需编译,完美规避旧方案中存在的缺陷,缺点在于编译构建流程基于jest api重构,与mpx自身基于webpack的构建流程独立存在,带来额外维护成本,后续会将通用的编译转换逻辑抽离出来统一维护,在webpack和jest两侧复用。

首先来看下 jest-runtime 中 transform 的整体流程。
unit-test-jest-runtime.png

  • runtime.requireModule(path)
  • 判断是否是mock module,如果是则直接走 requireMock方法,否则继续往下进行
  • 定义 localModule
  • 调用 this._loadModule
  • _createRequireImplementation(module, options) 赋值给module.require
  • transformFile 处理对应的文件
  • createScriptFromCode(transformdCode)
  • getVmContext 使用 vm 创建沙盒环境
  • 在沙盒环境执行对应的 jest 单测代码
			compiledFunction.call(
        module.exports,
        module, // module object
        module.exports, // module exports
        module.require, // require implementation
        module.path, // __dirname
        module.filename, // __filename
        // @ts-expect-error
        ...lastArgs.filter(notEmpty),
      );

上方是整个 jest-runtime 中对于require module 时transform的整体流程。在jest的这一能力之上,我们给予 mpxjs/webpack-plugin 开发了 mpx-jest transformer,并采用 vm script 实现 require from memory 直接exec mpx-jest处理后的组件js代码。
unit-test-mpx-new-architecture.png

简单的单元测试书写

在上方介绍过整体的jest框架流程以及Mpx框架单元测试架构后,接下来我们着手进行一个简单的 Mpx 框架开发的小程序组件的单元测试用例书写

使用 @mpxjs/cli 创建模版项目时选择使用单元测试,这时会生成创建好的有单测能力的模版项目,和普通 jest + miniprogram-simulate 搭建的原生小程序单测项目不同的是,transform 中添加了 mpx 文件的处理

	transform: {
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
    '^.+\\.mpx$': '<rootDir>/node_modules/@mpxjs/mpx-jest'
  }

此外我们对于 miniprogram-simulate 仓库做了一些定制化方法,比如loadMpx,mockComponent 等,这里我们 fork 了该仓库并发布了定制化包 @mpxjs/miniprogram-simulate

下方是对于 component list 的单元测试demo

const simulate = require('@mpxjs/miniprogram-simulate')

describe('test components list', () => {
  it('components instance data', function () {
    const id = simulate.loadMpx('src/components/list.mpx')
    const comp = simulate.render(id)
    expect(comp.data.listData.length).toBe(3)
    expect(comp.data.listData).toEqual(["手机", "电视", "电脑"])
  })
  it('components innerHtml', function (done) {
    const id = simulate.loadMpx('src/components/list.mpx')
    const comp = simulate.render(id)
    const childrenComp = comp.querySelectorAll('.index')
    expect(childrenComp.length).toBe(3)
    comp.triggerLifeTime('attached')
    setTimeout(() => {
      expect(comp.data.attachedTrigger).toBe(true)
      done()
    },1000)
  });
})

示例中我们对组件的data,以及组件中生命周期触发后对应组件状态变化的预期管理,属于比较简单基础的用例
对于复杂功能组件的复杂单元测试的书写,会另起一篇文章进行介绍。

结语

通篇文章我们依次进行了前端框架简介,jest框架原理总结,小程序单元测试内部执行流程,最后介绍Mpx框架中单测能力的支持实现,学习到了jest不仅仅是一个单元测试框架,你甚至可以使用它的各个工具自己创建一个单元测试框架等,后续我们还会继续探讨推行业务中落地TDD,复杂逻辑组件单测用例规范等问题,持续在小程序单测方向深耕并有更好的规范落地。

参考文章:
https://jestjs.io
https://jestjs.io/docs/architecture
https://medium.com/code-for-cause/jest-architecture-9870bbfcda44

@FranklinTesla
Copy link

干哥牛逼

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants