小程序单元测试实践之miniprogram-simulate

4,683 阅读4分钟

概述

原理说明


miniprogram-simulate 流程梳理

涉及的依赖项及功能

miniprogram-simulate

  • wxss 编译:查找所有依赖,并插入到文档流中

  • github.com/wechat-mini…

  • 循环查找usingComponents,并加载执行对应代码
  • wxml 编译:官方编译器,采用miniprogram-compiler编译;simulate编译就是直接读文件
  • mock:注册内置组件(例如view一类)、内置API(例如 wx.request )、注册 Touch 事件的参数
  • 其他主要是参数校验和包装,具体单一组件的功能实现靠 j-component

j-component

miniprogram-exparser

  • j-component依赖于此
  • 模板解析引擎

miniprogram-compiler

miniprogram-simulate

为什么用?

  • 主要对小程序的自定义组件单测提供运行环境?为什么自定义组件需要提供模拟环境?
  • 页面级对于Page的方法的测试,可以采用mock数据的方案实现,jestjs.io/docs/en/con…,在每个test执行前先设置函数执行
  • 官方原理:将原本小程序自定义组件双线程分离运行的机制调整成单线程模拟运行,利用 dom 环境进行渲染,借此来完成整个自定义组件树的搭建。

Page Demo

global.Page = ({ data, ...rest }) => {
    const page = {
        data,
        setData: jest.fn(function (newData:any, cb:Function) {
            this.data = {
                ...this.data,
                ...newData,
            };
            cb && cb();
        }),
        onLoad: noop,
        onReady: noop,
        onUnLoad: noop,    
        __wxWebviewId__: wId++,
        ...rest,
    };
    global.wxPageInstance = page;    
    return page;
};

  • 如果使用jest作为单测框架,实际上是在nodejs环境需要模拟小程序自定义组件的运行环境

完整DEMO演示

// 写对一个组件的单测
const simulate = require('miniprogram-simulate');
const { join } = require('path');
require('../../packages/component/index');
const page: any = global.wxPageInstance;// 上面Page的定义
const componentPath = join(
    __dirname,
    '../../packages/component/index',
);

/**
 * 加载自定义组件
 * 加载后的组件名(tagName)为:component
 * less: 自定义组件的 wxss 需要经过 less 编译
 * compiler:使用js实现的模拟编译器编译 wxml
 */
const componentId = simulate.load(componentPath, 'component', {
    less: true,
    compiler: 'simulate',
});

// 对于页面方法的测试
describe('component in example pages', () => {
    const mockEvent = {
        detail: {
            value: [''],
            index: '',
            component1: {
                setValues: jest.fn(),
            },
        },
    };
    test('should call multiple functions', () => {
        const funcList = [
            'onChange1',
            'onConfirm',
            'onCancel',
            'onChange2',
        ];
        funcList.forEach((v) => {
            const spy = jest.spyOn(page, v);
            page[v](mockEvent);
            expect(spy).toHaveBeenCalled();
            spy.mockRestore();
        });
    });
});

// 对于组件的测试
/**
 * render(componentId, properties)
 * 渲染自定义组件,返回 RootComponent
 * componentId,哪一个load的组件
 * properties 初始化properties对象
 */
const renderComponent = (options = {}) => {
    jest.resetModules();
    const comp = simulate.render(componentId, Object.assign({
        title: '',
    }, options));
    // 模拟事件
    comp.instance.$emit = jest.fn();
    return comp;
};
const mock = {
    onChange: () => {
        return {
            currentTarget: {
                dataset: {
                    index: 0
                }
            }
        };
    },
};

describe('component component init', () => {
    // 渲染后触发生命周期方法
    describe('test component lifetime', () => {
        const component = renderComponent();
        test('trigger lifeTime: created', () => {
            component.triggerLifeTime('created');
        });
        test('trigger lifeTime: attached', () => {
            component.triggerLifeTime('attached');
        });
        test('trigger lifeTime: ready', () => {
            component.triggerLifeTime('ready');
        });
    });
    // 测试组件内部事件
    describe('test component', () => {
        const component = renderComponent({
            title: page.data.title1,
        });
        test('trigger onChange event', () => {
            // 调用自定义组件上的 onChange 事件
            component.instance.onChange(mock.onChange());
        });
        test('test ui: child count', () => {
            // 查看节点数目对不对
            expect(component.querySelectorAll('.component-column').length).toBe(1);
        });
    });
    describe('test component with disabled', () => {
        const component = renderComponent({
            columns: page.data.column2,
        });
        test('data has disabled property', () => {
            expect(component.instance.data.columns[0].disabled).toBeTruthy();
        });
    });
    describe('test trigger tools methods', () => {
        // 渲染自定义组件
        const component = renderComponent({
            columns: page.data.column4,
        });
        // 异步方案
        test('trigger methods: setIndexes', () => {
            const mockData = [2, 3, 4];
            return component.instance.setIndexes(mockData).then((data: any) => {
                expect(data.length).toBe(3);
            });
        });

        test('trigger methods: getValues', () => {
            expect(component.instance.getValues(0)).toEqual(
                expect.arrayContaining(page.data.column4[0].values)
            );
        });
    });
    // 该组为正常情况下调用不到的部分,主要是报错
    describe('test inner function throw error', () => {
        // 渲染自定义组件
        const component = renderComponent({
            columns: page.data.column4,
        });
        // 触发observer方法
        const def = simulate.getDefinition(componentId);
        const observer = def.properties.columns.observer.bind(component.instance);

        test('trigger prop column\'s observer(non argu)', () => {
            observer();
        });

        test('trigger methods: setColumnValues(same model)', () => {
            // 相同内容调用会走same
            observer(page.data.column4);
        });
        // 测试promise抛错的情况
        test('trigger methods: setValues(throw error)', () => {
            return expect(
                component.instance.setValues(4)
            ).rejects.toMatch('setValues: 对应列不存在');
        });

        test('remove comonent', () => {
            component.triggerLifeTime('moved')
            component.detach();
        });
    });
});

API

behavior

load

  • 加载自定义组件,返回 componentId
  • 可以设置编译器为官方编译器还是js的编译器,在karma的场景下,只能使用官方编译器
  • 见 DEMO 演示

render

  • 见 DEMO 演示

match

  • dom 节点的内容是否符合给定的 html 结构

sleep

  • 延迟等待执行

scroll

  • 模拟滚动,触发scroll事件

Class: Component

  • 组件实例(有jsdom的一些方法和小程序本身的方法)
  • 属性
  • dom
    data
    instance:组件实例的this(可以直接调用组件的某些方法)
  • 方法
  • querySelector
    querySelectorAll
    setData
    dispatchEvent
    triggerLifeTime

Class: RootComponent

  • 继承自Class: Component,所有renderf返回的内容就是该实例
  • 方法
  • attach:挂载
    detach:卸载