TDSL (Test Domain Specific Language), 测试领域专用语言,是一套高效简洁描述测试代码的规范脚本语法。
- 一、为什么要使用TDSL
单元测试是前端开发中重要的一部分工作,但常常由于时间紧张,除了要写测试用例代码,还要维护旧的用例,每个人用例写法多少也有不一致,同伴的用例代码也往往会含有特殊的想法和逻辑,而且代码量大修改起来也难定位,前前后后也消耗了不少时间。不得不说,用例虽不难,但持续性开发维护会让我们投入不少精力。
由于测试用例大部分都是单元模块IO的验证代码,核心代码比较固定和机械化,但是往往修改了代码,还要去改用例,非常闹心。实际上我们可以根据源码进行ast分析,解析出各个模块的输入输出结构,直接生成用例代码,我们只需要指令式管理IO样本数据即可。所以,这里定义了一套简洁的描述语法规则TDSL。TDSL的基础理念是:重复类似的测试用例的代码只需要工具写一次,其他的交给TDSL。
而为了做到跨技术栈使用,可通过tdsl.config.js配置来适配自己的项目。使用简洁直接的语法书写用例,然后自动生成对应的完整jest测试用例代码。
优势:
1,不用了解框架即可手写用例,也不用维护用例代码。
2,面向指令,所有用例和模块IO一目了然。边界IO也更加直观。
3,统一用例代码,使用核心常用语法,保持一致性。
4,约束源码实现,约束一个函数只做一件IO事情,手动编写的用例过于灵活,后面其他人维护成本较高。
5,大大降低TDD成本。
6,统一IO数据样本文件管理,样本数据更易维护。
7,提高效率的同时对源码的组织结构形成反向约束。
8,覆盖率低?不存在的。
劣势:
1,TDSL语法成本。(但是不用再去折腾测试用例api怎么写了,TDSL语法远比用例语法简单,记住一种语法规范即可)
- 二、快速开始
npm i tdsl --save-dev
配置script任务,运行 npm run tdsl
即可,就能自动读取文件分析出用例了,默认会在根目录生成__test__/目录,里面包含所有的用例代码。
解析的用例dsl文件需要配置,详细看 tdsl.config.js的配置。
scripts: {
...
"tdsl": "node ./node_modules/tdsl/scripts/tdsl"
}
- 三、TDSL基本语法
1、声明引用
一般情况下,一个.test (默认扩展名为.test,可以进行配置) 文件关联一个源码文件,同时选择一种全局注入方式。注入方式在tdsl.config中配置。编写是支持常用的注释方式。
test './module.js';
use 'moduleName';
// add(1, 3) => (3)
# add(1, 3) => (3)
2、断言比较。相等 =>、深度相等 ==>、不相等 !=>、深度不相等 !==>
**调用函数方法,返回预期的校验值,如果没有returnValue,则没有断言判断输出,例如:
test './module.js';
use 'moduleName';
B(1, 3) => (3)
B(1, 3) !=> (4)
B(1, 3) ==> (3)
B(1, 3) !==> (4)
B(1, 4) => ()
module.js代码为
export function B (a: number, b: number): number {
return a * b;
}
自动编译后,B(1, 4) => () 没有返回值,不会编译输出:
test("B module", () => {
expect(B(1, 3)).toBe(3);
expect(B(1, 3)).not.toBe(4);
expect(B(1, 3)).toEqual(3);
expect(B(1, 3)).not.toEqual(4);
});
3,语法换行和注释
如果需要换行,把块注释换行前面的 * 去掉即可,另外遵循不要在过程内部换行,例如
# 换行
pullToRefresh.call('mockThis:of:../data/data.js')
-> 1(wx.showToast)
-> 1(setTimeout:mockof:../../../common/utils) => ()
// add(1, 2) => (3)
/* add(1, 2) => (3) */
4,常量路径 :of:
("data.dataKey1:of:path", ...params) => ('data.dataKey:of: path')
如果我们要处理的样本数据太大,需要从外部一个数据文件中导出使用,就可以使用:of:。使用:of:分割,:of: 前为读取mock数据的属性key,如果没有key则表示无输入,后面为相对路径,此时参数输入必须为字符串,相对路径最终会根据生成用例文件的路径对比,计算出一个新的相对路径,而且相同重复的路径最终会自动合并到一个import语句。
test './module.js';
use 'moduleName';
A("a:of:./data.js", 3) => (3)
A("b:of:./data.js", "d:of:./data1.js") => (1)
A("a:of:./data.js", 1) => ("a.b:of:./data1.js")
./module.js文件内容为
export function B (a: number, b: number): number {
return a * b;
}
其中data.js和data1.js文件内容为:(支持export和module.exports导出)
// data.js
module.exports = {
a: 1,
b: 1
};
// data1.js
export default {
c: 1,
d: 1
}
自动编译后,常量路径会被自动计算并填充到变量:
import { a, b } from '../data.js';
import { c, d } from '../data1.js';
test("B module", () => {
expect(B(a, 3)).toBe(3);
export(B(b, d)).toBe(1);
export(B(a, 1)).toBe(a.b);
});
5, 析构参数路径 [Number]...[key]:of:
("[number]...data.dataKey1:of:path", ...params) => ('data.dataKey:of: path')
常量路径能够帮助我们引入文件数据,简化脚本长度,但是如果有时需要引入多个常量路径,脚本仍不够简洁,这时就可以使用析构常量路径。注意此时常量路径输出必须是数组类型才生效,其中数字表示使用前面多少个参数,方便不同个数参数复用,如果不设置,则自动为函数的最大参数个数计算。
test './module.js';
use 'moduleName';
A("2...params:of:./data.js", 3) => (6)
A("...params1:of:./data.js") => (6)
./module.js文件内容为
export function B (a: number, b: number, c: number): number {
return a + b + c;
}
其中data.js文件内容为:(支持export和module.exports导出)
// data.js
module.exports = {
params: [1, 2],
params1: [1, 2, 3]
};
自动编译后,常量路径会被自动计算并填充到变量:
import { params, params1 } from '../data.js';
test("B module", () => {
expect(B(params[0], params[1], 3)).toBe(6);
expect(B(params1[0], params1[1], params1[2])).toBe(6);
});
6, 属性判断
fn(...Params).property => (lengthValue)
属性读取判断,所以尽可能使用这条规则实现更多的原始判断,例如:
test './module.js';
use 'moduleName';
B(1, 3).length => (1)
B(1, 3)['length'] => (1)
B(1, 3)['length']['xxx'] !=> (1)
./module.js文件内容为
export function B (a: number, b: number): number {
return a * b;
}
自动编译后输出:
test("B module", () => {
expect(B(1, 3).length).toBe(1);
expect(B(1, 3)['length']).toBe(1);
expect(B(1, 3)['length']['xxx']).not.toBe(1);
});
7,异步判断
fn(...params) -> (module.property) ==> (returnValue)
有时我们需要异步执行一个函数,然后再判断某个变量是否符合预期。例如:异步执行fn,然后判断module的property属性值是否和returnValue相符。注意:这里因为右侧只能写一个输出数据,所以中间过程仅支持一项,如果有多个,请分多条书写。
test './module.js';
use 'moduleName';
// 拉取接口数据,然后把接口数据dispatch到store上,然后判断store的数据是否符合预期
getOverviewChart({}) -> (getState().apiData) ==> ({chart: {a:1}})
./module.js文件内容为
export const getOverviewChart = (params = {}) => {
// ...request操作,然后dispatch数据到store的apiData
...
}
编译后输出:
test("getOverviewChart module", done => {
getOverviewChart({}).then(() => {
expect(getState().apiData).toEqual({
chart: {
a: 1
}
});
done();
}, () => {});
})
如果是async异步函数,则会解析编译为:
test("async getOverviewChart module", async done => {
await getOverviewChart({});
expect(getState().apiData).toEqual({
chart: {
a: 1
}
});
})
8, 二阶异步判断
moduleName(fn(...params)) -> (module.property) -> (returnValue)
有时模块函数可能是高阶函数,需要嵌套一次调用测试,例如: dispatch(A())。目前最多支持二阶函数。
test './module.js';
use 'moduleName';
// 拉取接口数据,然后把接口数据dispatch到store上,然后判断store的数据是否符合预期
dispatch(getOverviewChart({})) -> (getState().apiData) ==> ({chart: {a:1}})
./module.js文件内容为
export const getOverviewChart = (params = {}) => (dispatch, getState) => {
// ...request操作,然后dispatch数据到store的apiData
...
}
编译后输出
test("getOverviewChart module", done => {
dispatch(getOverviewChart({})).then(() => {
expect(getState().apiData).toEqual({
chart: {
a: 1
}
});
done();
}, () => {});
})
9, 函数调用 :of:、:mockof:、:from:
9.1、fn(...params) -> number1('module1:of:path') -> ... => (returnValue)
常常我们会去测试一个事件触发时是否调用了一些数据业务逻辑,这种情况可以使用调用次数的判断。例如,fn调用时调用module1次数为number1,调用module2次数为number1,module1来自path路径的文件,module1和module2均会被自动mock,然后返回断言值为returnValue。中间的调用判断支持多项,并支持自动合并。注意这里 number1('module1:of:path') -> (module) 不能和异步判断混用。如果需要多次判断,请分多条规则书写。
test './module.js';
use 'moduleName';
//执行initData时,分别执行了1次onChangeChartDate和1次getAdvertiserListInfo,其中两个函数都来自../action模块
initData({})
-> 1(onChangeChartDate:of:../action)
-> 1(getAdvertiserListInfo:of:../action) => ()
./module.js文件内容为
export const initData = function (options = {}) {
onChangeChartDate(time);
getAdvertiserListInfo({page: 1});
}
这里的模块路径均会被自动重新计算,编译后输出:
import { onChangeChartDate, getAdvertiserListInfo } from '../action';
test("initData module", () => {
jest.clearAllMocks();
initData();
expect(onChangeChartDate).toBeCalledTimes(1);
expect(getAdvertiserListInfo).toBeCalledTimes(1);
});
9.2、fn(...params) -> number1('module1:mockof:path') -> ... => (returnValue),如果需要对引入的方法进行mock,则可使用:mockof:
test './module.js';
use 'moduleName';
// 执行initData时,分别执行了1次onChangeChartDate和1次getAdvertiserListInfo,其中两个函数都来自../action模块
initData({}) -> 1(onChangeChartDate:mockof:../action)
-> 1(getAdvertiserListInfo:mockof:../action) => ()
./module.js文件内容为
export const initData = function (options = {}) {
onChangeChartDate(time);
getAdvertiserListInfo({page: 1});
}
这里的模块路径均会被自动重新计算和自动mock,编译后输出:
import { onChangeChartDate, getAdvertiserListInfo } from '../action';
jest.mock('../action', () => {
return {
onChangeChartDate: jest.fn(),
getAdvertiserListInfo: jest.fn(),
};
});
test("initData module", () => {
jest.clearAllMocks();
initData();
expect(onChangeChartDate).toBeCalledTimes(1);
expect(getAdvertiserListInfo).toBeCalledTimes(1);
});
9.3、fn(...params) -> number1('module1:mockof:path:from:path1') -> ... => (returnValue),此外如果需要对引入的方法进行自定义mock,则可使用:from:,其中path1的路径为自定义mock函数的路径地址,注意这里path和path1导出module1的名称必须相同。
test './module.js';
use 'moduleName';
// 执行initData时,执行了1次onChangeChartDate,其中onChangeChartDate的自定义mock函数为../mock.js中的onChangeChartDate;getAdvertiserListInfo和7.2相同
initData({}) -> 1(onChangeChartDate:mockof:../action:from:../mock.js) -> 1(getAdvertiserListInfo:mockof:../action) => ()
./module.js文件内容为
export const initData = function (options = {}) {
onChangeChartDate(time);
getAdvertiserListInfo({page: 1});
}
这里的模块路径均会被自动重新计算和自动mock,编译后输出:
import { onChangeChartDate as _onChangeChartDate, } from '../mock.js';
import { onChangeChartDate, getAdvertiserListInfo, } from '../action';
jest.mock('../action', () => {
return {
onChangeChartDate: _onChangeChartDate,
getAdvertiserListInfo: jest.fn(),
};
});
test("initData module", () => {
jest.clearAllMocks();
initData();
expect(onChangeChartDate).toBeCalledTimes(1);
expect(getAdvertiserListInfo).toBeCalledTimes(1);
});
10, this绑定call、apply
fn.call(this, ...params) -> number1('module1:of:path') -> ... => (returnValue)
fn.apply(this, [...params]) -> number1('module1:of:path') -> ... => (returnValue)
有时验证一个模块时,由于模块可能依赖了this的一些方法和属性,才能运行,此时我们就需要mock一个this,然后用call或apply进行绑定。推荐使用call,apply后面数组元素如果引用了路径不会被解析。前面的方法模块均支持和call或apply绑定。
test './module.js';
use 'moduleName';
initData.call('mockThis:of:./data.js', {}) -> 1(getMessageList:of:./action) => (true)
./module.js文件内容为
export const initData = function (options = {}) {
getMessageList({page: 1});
this.setData({
a: 1,
});
return true;
}
// 其中./data.js中包含
export const mockThis = {
setData: () => {},
xxx...
}
编译后输出:
import { mockThis } from "../data.js";
import { getMessageList } from '../action';
jest.mock('../action', () => {
return {
getMessageList: jest.fn()
};
});
test("initData module", () => {
jest.clearAllMocks();
initData.call(mockThis, {});
expect(getMessageList).toBeCalledTimes(1);
expect(initData.call(mockThis, {})).toBeCalledTimes(1);
});
四、tdsl.config配置
tdsl是基于jest的跨框架单元测试描述脚本语法,tdsl.config.js用于配置项目,项目中tdsl会自动查找项目中的tdsl.config.js配置文件。
一个tdsl.config.js配置例子如下:
module.exports = {
ruleReg: /\.test/, // 匹配哪些文件进行tdsl解析
rootDirs: ['./test', './src'], // 匹配哪些目录下文件进行解析
module: [{
name: 'moduleName1', // 模块的名称,DSL中使用use "moduleName1" 来关联
async: 'async', // 模块函数是否使用async方式测试
inject: { // 注入的内容,会出现在编译后代码的前后面
// 会出现在测试代码文件头部
beforeAllCode: `
import { useDispatch, initStore, getState } from '../../../initStore';
initStore({});
const dispatch = useDispatch();
`,
// 会出现在测试代码文件尾部
afterAllCode: '',
}
}, {
name: 'moduleName2',
test: 'src/biz-components/.*/index.js',
inject: {
beforeAllCode: `
jest.clearAllMocks();
jest.mock('../../../initStore',() => {
return {
useDispatch: jest.fn(),
}
});
const simulate = require('miniprogram-simulate');
`,
afterAllCode: ``,
},
ui: 'miniprogram', // 标识为ui类用例,并且是小程序类ui
}]
};
五、测试用例类型
tdsl目前定义了四种类型的函数模块用例:
- (1) 同步数据处理转换类。用户输入数据,处理后输出判断是否符合预期。例如同步计算两个数字相加,
add(1, 2) => (3)
- (2) 异步数据操作并判断操作后数据。例如异步请求接口,并把接口返回的数据dispatch到store上,然后判断store上数据是否符合预期(可能需要结合接口mock一起使用)。
requestAndSetStore.callby('dispatch', {paramId: 1003}) -> (getState().xx) ==> ({list:[]})
- (3) 事件触发函数测试。一般用于测试触发函数执行时,是否调用了对应了业务逻辑函数,以及业务逻辑函数的次数。
initData({}) -> 1('onChangeChartDate:of:../action') -> 1('getAdvertiserListInfo:of:../action') => (true)
- (4) UI类测试。调用render函数后查找节点,判断其个数、类名、属性等是否符合预期 (待完善)
对于React技术栈UI测试为例,npm i jest jest-babel @babel/preset-es2015 @babel/preset-env @babel/preset-react
,package.json可如下配置
"jest": {
"testRegex": "test/test.jsx?$",
"moduleDirectories": [
"node_modules"
],
"transform": {
"^.+\\.js": "<rootDir>/node_modules/babel-jest"
},
"collectCoverage": true
}
.babelrc配置(babel7的配置)
{
"presets": ["@babel/preset-env", "@babel/react"]
}
TODO: 1, 编译语法出错提示 2,编辑器语法支持(暂时可使用coffeescript,HLSL等语法支持高亮和快捷注释) 3,一些复杂用法去掉或简化