# 前端测试套件构建实践
# 前言
前端开发过程中,我们常常忽略单元测试的功能和重要性,一个好的测试覆盖是软件稳定运行的前提和保证,作为软件工程研发领域不可获取的步骤,通常按照测试粒度可以区分为 单元测试
、集成测试
、E2E测试(UI测试)
,通常的测试会将最后一个粒度定位为系统测试,但是对于前端而言通常就是UI或者E2E测试,有的公司会把E2E测试单独拿出来进行分层,这里我们仅仅以简单的三层模型进行区分,按照数量有正三角和倒三角之分,通常对开发进行测试来说正三角的测试架构居多,也就是单元测试占比较多。
为了提升前端的开发效率,同时也为了减少前端编写单元测试代码的繁琐工作,testus测试套件旨在为前端测试开发工作提供便利,本文旨在介绍testus的一些设计理念及实现方案,希望能给前端基础建设中有关于测试构建相关工作的同学提供一些帮助和思路。
# 架构
整体架构思路是采用函数式编程的思路进行组合式构建,整体流程包含 预处理(preprocess)
、解析(parse)
、转换(transform)
、生成(generate)
四个阶段,通过脚手架构建的方法对用户配置的 testus.config.js
文件内容进行解析转化生成,其中:
预处理阶段:主要是通过解析用户的提供的配置文件进行相关的自定义传输数据结构DSL的构建;
解析阶段:主要是对生成的DSL中构建的目录树结构进行相关的读取文件内容操作,并修改树中的内容;
转化阶段:主要是对已有配置内容进行相关的模板转化及注释解析,其中用户配置中的插件配置也会进行相应的中间件转换;
生成阶段:主要是对已转化后的DSL进行相应的文件及文件夹生成操作
最后通过组合式函数编程对外暴露出一个复合构建函数,即导出类似:f(g(h(e(x))))
的结果,可通过 compose函数 进行相关的代码优雅编写。
对于扩展应用的插件化配置,这里采用了中间件的处理方案,前端的中间件不同于后端的中间件为上下游提供的思路,其本质其实是一个调用器。常见的中间件处理方式通常有切面型中间件也叫串行型中间件,另外就是洋葱型中间件。这里采用了切面的方式来实现中间件的调度方案,其不同于redux中间件的精巧设计Context上下文的思路,这里的核心业务逻辑其实不受影响,主要通过切面的形式为用户提供扩展。
# 目录
- packages
- core
- common.js
- generate.js
- index.js
- parse.js
- preprocess.js
- transform.js
- shared
- constants.js
- fn.js
- index.js
- is.js
- log.js
- reg.js
- utils.js
- testus-plugin-jasmine
- index.js
- testus-plugin-jest
- index.js
- testus-plugin-karma
- index.js
- core
# 源码
# core
核心模块提供了架构中的主要核心设计,其中 common.js
中抽离了四个模块所需要的公共方法,主要是对目录树相关的操作,这里整个核心过程其实都是基于自定义的DSL进行相关的处理和实现的,这里设计DSL的结构大致如下:
DSL = {
tree: [
],
originName: 'src',
targetName: 'tests',
middleName: 'spec',
libName: 'jest',
options: {
},
middlewares: [
]
};
其中对tree的定义最为重要,也是生成目录文件的关键,这里设计的基本节点结构为:
{
name: '', // 文件或文件夹名称
type: '', // 节点类型 'directory' 或者 'file'
content: undefined, // 文件内容,文件夹为undefined
ext: undefined, // 文件扩展名,文件夹为undefined
children: [
// 子节点内容,叶子节点为null
]
}
# preprocess.js
/**
* 用于从根目录下读取testus.config.js配置文件,如果没有走默认配置
*/
const path = require('path');
const fs = require('fs');
const { error, info, TEST_LIBRARIES, DEFAULT_TESTUSCONFIG, extend, clone, FILENAME_REG, isNil, isFunction } = require('../shared');
const { toTree } = require('./common');
// 默认只能在根路径下操作
const rootDir = path.resolve(process.cwd(), '.');
const createDSL = (options) => {
// TODO 执行命令的options
if(fs.existsSync(`${rootDir}/testus.config.js`)) {
const testusConfig = eval(fs.readFileSync(`${rootDir}/testus.config.js`, 'utf-8'));
return handleConfig(testusConfig);
} else {
return handleConfig(DEFAULT_TESTUSCONFIG)
}
}
function handleConfig(config) {
const DSL = {};
config.entry && extend(DSL, processEntry(config.entry || DEFAULT_TESTUSCONFIG.entry));
config.output && extend(DSL, processOutput(config.output || DEFAULT_TESTUSCONFIG.output));
config.options && extend(DSL, processOptions(config.options || DEFAULT_TESTUSCONFIG.options));
config.plugins && extend(DSL, processPlugins(config.plugins || DEFAULT_TESTUSCONFIG.plugins));
return DSL;
}
function processEntry(entry) {
const entryObj = {
tree: [],
originName: ''
};
if(entry.dirPath) {
if(fs.existsSync(path.join(rootDir, entry.dirPath))) {
entryObj.originName = entry.dirPath;
entryObj.tree = toTree(path.join(rootDir, entry.dirPath), entry.dirPath ,entry.extFiles || [], entry.excludes || []);
} else {
error(`${entry.dirPath}目录不存在,请重新填写所需生成测试文件目录`)
throw new Error(`${entry.dirPath}目录不存在,请重新填写所需生成测试文件目录`)
}
}
return entryObj;
}
function processOutput(output) {
const outputObj = {
targetName: '',
middleName: ''
};
if(output.dirPath) {
if( fs.existsSync( path.join(rootDir, output.dirPath) ) ) {
error(`${output.dirPath}目录已存在,请换一个测试文件导出名称或者删除${output.dirPath}`)
throw new Error(`${output.dirPath}目录已存在,请换一个测试文件导出名称或者删除${output.dirPath}`)
} else {
outputObj.targetName = output.dirPath
}
}
if(output.middleName) {
if(FILENAME_REG.test(output.middleName)) {
error(`中间名称不能包含【\\\\/:*?\"<>|】这些非法字符`);
throw new Error(`中间名称不能包含【\\\\/:*?\"<>|】这些非法字符`);
} else {
outputObj.middleName = output.middleName;
}
}
return outputObj;
}
function processOptions(options) {
const optionsObj = {
libName: '',
options: {}
};
if(options.libName) {
if(!TEST_LIBRARIES.includes(options.libName)) {
error(`暂不支持${options.libName}的测试库,请从${TEST_LIBRARIES.join('、')}中选择一个填写`)
throw new Error(`暂不支持${options.libName}的测试库,请从${TEST_LIBRARIES.join('、')}中选择一个填写`)
} else {
optionsObj.libName = options.libName
}
}
if(options.libConfig) {
if(!isNil(options.libConfig)) {
optionsObj.options = clone(options.libConfig)
}
}
return optionsObj;
}
function processPlugins(plugins) {
const pluginsObj = {
middlewares: []
};
if(plugins) {
if(plugins.length > 0) {
// 判断是否是函数
plugins.forEach(plugin => {
if(!isFunction(plugin)) {
error(`${plugin}不是一个函数,请重新填写插件`)
} else {
pluginsObj.middlewares.push(plugin)
}
})
}
};
return pluginsObj;
}
module.exports = (...options) => {
return createDSL(options)
}
# parse.js
const fs = require('fs');
const path = require('path');
const { goTree } = require('./common');
function handleContent(path, item) {
item.content = fs.readFileSync(path, 'utf-8')
return item;
}
module.exports = (args) => {
args.tree = goTree(args.tree, args.originName, handleContent);
return args;
}
# transform.js
/**
* middleware的执行也是在这个阶段
*/
const doctrine = require('doctrine');
const fs = require('fs');
const path = require('path');
const { goTree, transTree } = require('./common');
const { jestTemplateFn, jasmineTemplateFn, karmaTemplateFn } = require('../testus-plugin-jest');
function handleContent(p, item, { middlewares, libName, originName, targetName }) {
let templateFn = jestTemplateFn;
switch (libName) {
case 'jest':
templateFn = jestTemplateFn;
break;
case 'jasmine':
templateFn = jasmineTemplateFn;
break;
case 'karma':
templateFn = karmaTemplateFn;
break;
default:
break;
}
const reg = new RegExp(`${originName}`);
item.content = transTree(
doctrine.parse(fs.readFileSync(p, 'utf-8'), {
unwrap: true,
sloppy: true,
lineNumbers: true
}),
middlewares,
templateFn,
path.relative(p.replace(reg, targetName), p).slice(3)
);
return item;
}
module.exports = (args) => {
args.tree = goTree(args.tree, args.originName, handleContent, {
middlewares: args.middlewares,
libName: args.libName,
originName: args.originName,
targetName: args.targetName
});
return args;
}
# generate.js
const fs = require('fs');
const path = require('path');
const { error, done, isNil, warn } = require('../shared');
const { genTree } = require('./common');
const handleOptions = (libName, options) => {
switch (libName) {
case 'jest':
createJestOptions(options);
break;
case 'jasmine':
createJasmineOptions(options);
break;
case 'karma':
createKarmaOptions(options);
break;
default:
break;
}
};
function createJestOptions(options) {
const name = 'jest.config.js';
if( fs.existsSync( path.join(path.resolve(process.cwd(), '.'), name) ) ) {
warn(`当前根目录下存在${name},会根据testus.config.js中的libConfig进行重写`)
}
const data = `module.exports = ${JSON.stringify(options)}`
fs.writeFileSync( path.join(path.resolve(process.cwd(), '.'), `${name}`) , data )
}
function createJasmineOptions(options) {
const name = 'jasmine.json';
if( fs.existsSync( path.join(path.resolve(process.cwd(), '.'), name) ) ) {
warn(`当前根目录下存在${name},会根据testus.config.js中的libConfig进行重写`)
}
const data = `${JSON.stringify(options)}`
fs.writeFileSync( path.join(path.resolve(process.cwd(), '.'), `${name}`) , data )
}
function createKarmaOptions(options) {
const name = 'karma.conf.js';
if( fs.existsSync( path.join(path.resolve(process.cwd(), '.'), name) ) ) {
warn(`当前根目录下存在${name},会根据testus.config.js中的libConfig进行重写`)
}
const data = `module.exports = function(config) {
config.set(${JSON.stringify(options)})
}`
fs.writeFileSync( path.join(path.resolve(process.cwd(), '.'), `${name}`) , data )
}
module.exports = (args) => {
// 生成批量文件
if( fs.existsSync( path.join(path.resolve(process.cwd(), '.'), args.targetName) ) ) {
error(`${args.targetName}文件目录已存在,请换一个测试文件导出名称或者删除${args.targetName}后再进行操作`)
throw new Error(`${args.targetName}文件目录已存在,请换一个测试文件导出名称或者删除${args.targetName}后再进行操作`)
} else {
fs.mkdirSync(path.join(path.resolve(process.cwd(), '.'), args.targetName))
genTree(args.tree, args.targetName, path.resolve(process.cwd(), '.'), args.middleName)
}
// 生成配置文件
if(!isNil(args.options)) {
console.log('args Options', args.options)
handleOptions(args.libName, args.options)
}
done('自动生成测试文件完成')
}
# common.js
const fs = require('fs');
const path = require('path');
const { EXT_REG, compose, isFunction, error, warn, info } = require('../shared');
/**
* 建立树的基本数据结构
*/
exports.toTree = ( dirPath, originName, extFiles, excludes ) => {
// 绝对路径
const _excludes = excludes.map(m => path.join(process.cwd(), '.', m));
const recursive = (p) => {
const r = [];
fs.readdirSync(p, 'utf-8').forEach(item => {
if(fs.statSync(path.join(p, item)).isDirectory()) {
if(!_excludes.includes(path.join(p, item))) {
const obj = {
name: item,
type: 'directory',
content: undefined,
ext: undefined,
children: []
};
obj.children = recursive(path.join(p, item)).flat();
r.push(obj);
}
} else {
if(!_excludes.includes(path.join(p, item))) {
r.push({
name: item,
type: 'file',
content: '',
ext: item.match(EXT_REG)[1],
children: null
})
}
}
});
return r;
}
return recursive(dirPath);
}
/**
* 对树进行遍历并进行相关的一些函数操作
*/
exports.goTree = ( tree, originName, fn, args ) => {
// 深度优先遍历
const dfs = ( tree, p ) => {
tree.forEach(t => {
if(t.children) {
dfs(t.children, path.join(p, t.name))
} else {
t = fn(path.join(p, t.name), t, args)
}
})
return tree;
}
return dfs(tree, originName)
}
/**
* 对树进行相关数据结构的转化
*/
exports.transTree = ( doctrine, middlewares, templateFn, relativePath ) => {
const next = (ctx) => {
if( ctx.tags.length > 0 ) {
// 过滤@testus中的内容
const positions = [];
ctx.tags.forEach((item, index) => {
if( item.title == 'testus' ) {
positions.push(index)
}
});
if(positions.length % 2 == 0) {
for(let i=0; i< positions.length-1; i+=2) {
// 对导出内容进行判断限定
const end = ctx.tags.filter(f => f.title == 'end' );
if( end.length > 0 ) {
const out = end.pop();
if( out.description.indexOf('exports') == '-1' ) {
warn(`目前仅支持Common JS模块导出`)
} else {
if(out.description.indexOf('module.exports') != '-1') {
info(`使用module.exports请将内容放置在{}中`)
}
}
} else {
error(`未导出所需测试的内容`);
throw new Error(`未导出所需测试的内容`)
}
return templateFn(ctx.tags.slice(positions[i]+1,positions[i+1]), relativePath)
}
} else {
const errorMsg = `注释不闭合,请重新填写`;
error(errorMsg);
throw new Error(errorMsg)
}
}
}
let r = '';
if(middlewares.length > 0) {
middlewares.forEach( middleware => {
if(isFunction(middleware)) {
r = middleware(doctrine, next)
} else {
error(`${middleware}不是一个函数`)
}
});
} else {
r = next(doctrine)
}
return r;
}
/**
* 基于树的数据结构生成相应的内容
*/
exports.genTree = ( tree, targetName, dirPath, middleName ) => {
// 过滤名字
const filterName = ( name, middleName ) => {
const r = name.split('.');
r.splice(r.length - 1,0, middleName)
return r.join('.')
}
const dfs = ( tree, p ) => {
tree.forEach(t => {
if(t.children) {
fs.mkdirSync(path.join(p, t.name))
dfs(t.children, path.join(p, t.name))
} else {
t.content && fs.writeFileSync(path.join(p, filterName(t.name, middleName)), t.content)
}
})
return tree;
}
return dfs(tree, path.join(dirPath, targetName))
}
# shared
公共的共享方法,包括相关的一些常量及函数式编程相关方法
# fn.js
exports.compose = (...args) => args.reduce((prev,current) => (...values) => prev(current(...values)));
exports.curry = ( fn,arr=[] ) => (...args) => (
arg=>arg.length===fn.length
? fn(...arg)
: curry(fn,arg)
)([...arr,...args]);
# utils.js
exports.extend = (to, _from) => Object.assign(to, _from);
exports.clone = obj => {
if(obj===null){
return null
};
if({}.toString.call(obj)==='[object Array]'){
let newArr=[];
newArr=obj.slice();
return newArr;
};
let newObj={};
for(let key in obj){
if(typeof obj[key]!=='object'){
newObj[key]=obj[key];
}else{
newObj[key]=clone(obj[key]);
}
}
return newObj;
}
# is.js
exports.isNil = obj => JSON.stringify(obj) === '{}';
exports.isFunction = fn => typeof fn === 'function';
# log.js
const chalk = require('chalk');
exports.log = msg => {
console.log(msg)
}
exports.info = msg => {
console.log(`${chalk.bgBlue.black(' INFO ')} ${msg}`)
}
exports.done = msg => {
console.log(`${chalk.bgGreen.black(' DONE ')} ${msg}`)
}
exports.warn = msg => {
console.warn(`${chalk.bgYellow.black(' WARN ')} ${chalk.yellow(msg)}`)
}
exports.error = msg => {
console.error(`${chalk.bgRed(' ERROR ')} ${chalk.red(msg)}`)
}
# testus-plugin-jasmine
jasmine相关的一些插件化操作,目前实现了基于jasmine的一些模板转化,后续可进行响应的扩展
const { info } = require('../shared');
info(`jasmine测试库加载`)
const path = require('path');
exports.jasmineTemplateFn = ( args, relativePath ) => {
const map = {
name: '',
description: '',
params: [],
return: ''
};
args.forEach(arg => {
const title = arg.title;
switch (title) {
case 'name':
map[title] = arg.name;
break;
case 'description':
map[title] = arg.description;
break;
case 'param':
map['params'].push(arg.description);
break;
case 'return':
map[title] = arg.description;
break;
default:
break;
}
})
return (
`const {${map.name}} = require('${relativePath}')
describe('${map.description}', function(){
expect(${map.name}(${map.params.join(',')})).toBe(${map.return})
})
`
)
}
# testus-plugin-jest
jest相关的一些插件化操作,目前实现了基于jest的一些模板转化,后续可进行响应的扩展
const { info } = require('../shared');
info(`jest测试库加载`)
const path = require('path');
exports.jestTemplateFn = ( args, relativePath ) => {
// console.log('args', args);
const map = {
name: '',
description: '',
params: [],
return: ''
};
args.forEach(arg => {
const title = arg.title;
switch (title) {
case 'name':
map[title] = arg.name;
break;
case 'description':
map[title] = arg.description;
break;
case 'param':
map['params'].push(arg.description);
break;
case 'return':
map[title] = arg.description;
break;
default:
break;
}
})
return (
`const {${map.name}} = require('${relativePath}')
test('${map.description}', () => {
expect(${map.name}(${map.params.join(',')})).toBe(${map.return})
})
`
)
}
# testus-plugin-karma
karma相关的一些插件化操作,目前实现了基于karma的一些模板转化,后续可进行响应的扩展
const { info } = require('../shared');
info(`karma测试库加载`)
const path = require('path');
exports.karmaTemplateFn = ( args, relativePath ) => {
const map = {
name: '',
description: '',
params: [],
return: ''
};
args.forEach(arg => {
const title = arg.title;
switch (title) {
case 'name':
map[title] = arg.name;
break;
case 'description':
map[title] = arg.description;
break;
case 'param':
map['params'].push(arg.description);
break;
case 'return':
map[title] = arg.description;
break;
default:
break;
}
})
return (
`const {${map.name}} = require('${relativePath}')
describe('${map.description}', function(){
expect(${map.name}(${map.params.join(',')})).toBe(${map.return})
})
`
)
}
# 总结
单元测试对于前端工程来说是不可获取的步骤,通常对于公共模块提供给其他同学使用的方法或者暴露的组件等希望都进行相关的单测并覆盖,其他相关的最好也能进行相应的单元测试,但是作为前端也深刻理解编写单测用例的繁琐,因而基于这个前端开发痛点,通过借鉴后端同学使用注解方式进行读取代码的思路,这里想到了基于注释的一些解析实现操作(ps:前端装饰器的提案目前好像已经进入Stage3的阶段,但是考虑到注解的一些限制,这里就采用了注释的方案进行解析),对于简单的批量操作可以后续通过定制模板来实现响应的批量操作。前端工程领域不仅要关注 UX
用户体验,更要关注 DX
开发体验的提升,在2D(to Develop)领域,前端还是有一些蓝海空间存在的,对2D领域有想法的同学也可以在此上寻找一些机会,也为前端开发建设提供更多的支持和帮助。(ps:https://github.com/vee-testus/testus,欢迎star,哈哈哈)