头图

foreword

图片

In the process of front-end development, we often ignore the function and importance of unit testing. A good test coverage is the premise and guarantee for the stable operation of software. As an unobtainable step in the field of software engineering research and development, it can usually be divided into 单元测试 according to the test granularity. 单元测试 , 集成测试 , E2E测试(UI测试) , the usual test will locate the last granularity as the system test, but for the front-end company, it is usually UI or E2E test The E2E test is taken out separately for layering. Here we only use a simple three-layer model to distinguish it. According to the number, there are positive triangles and inverted triangles. Usually, for development testing, there are mostly positive triangle test structures, that is, units. Tests are more common.

图片

In order to improve the development efficiency of the front-end and reduce the tedious work of writing unit test code in the front-end, the testus test suite is designed to facilitate the development of front-end testing. This article aims to introduce some design concepts and implementation solutions of testus, hoping to give the front-end In the infrastructure construction, students who have related work related to test construction provide some help and ideas.

Architecture

图片

The whole idea of architecture is the use of functional programming ideas were combined to build the whole process contains 预处理(preprocess) , 解析(parse) , 转换(transform) , 生成(generate) four In this stage, the content of the testus.config.js file configured by the user is parsed and converted by the method of scaffolding construction, among which:

  1. Preprocessing stage: mainly by parsing the configuration file provided by the user to construct the relevant custom transmission data structure DSL;
  2. Parsing phase: mainly to read the file content related to the directory tree structure built in the generated DSL, and modify the content in the tree;
  3. Conversion stage: mainly to perform related template conversion and annotation analysis on the existing configuration content, in which the plug-in configuration in the user configuration will also undergo corresponding middleware conversion;
  4. Generation phase: mainly to generate corresponding files and folders for the converted DSL

Finally, a composite construction function is exposed externally through compose functional programming, that is, the result of exporting something like: f(g(h(e(x)))) can be written elegantly through the compose function .

图片

For the plug-in configuration of the extended application, the middleware processing scheme is adopted here. The front-end middleware is different from the idea provided by the back-end middleware for upstream and downstream, and its essence is actually an invoker. Common middleware processing methods usually include facet-type middleware, also called serial-type middleware, and onion-type middleware. The aspect method is used here to implement the middleware scheduling scheme, which is different from the redux middleware's ingenious design of the context context. The core business logic here is not affected, and it mainly provides extensions for users in the form of aspects.

content

  • 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

source code

core

The core module provides the main core design in the architecture, among which common.js extracts the common methods required by the four modules, mainly for the operations related to the directory tree. The defined DSL performs related processing and implementation. The structure of the DSL design here is roughly as follows:

 DSL = {
    tree: [
        
    ],
    originName: 'src',
    targetName: 'tests',
    middleName: 'spec',
    libName: 'jest',
    options: {

    },
    middlewares: [

    ]
};

Among them, the definition of tree is the most important, and it is also the key to generating directory files. The basic node structure designed here is:

 {
    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

Public shared methods, including some related constants and methods related to functional programming

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

For some plug-in operations related to jasmine, some template conversions based on jasmine are currently implemented, and the response can be extended later

 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

For some plug-in operations related to jest, some template conversions based on jest are currently implemented, and subsequent extension of the response can be carried out

 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

Some plug-in operations related to karma, some template conversions based on karma are currently implemented, and subsequent extension of the response can be carried out

 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})
})
`
    )
}

Summarize

Unit testing is an unobtainable step for front-end engineering. Usually, it is hoped that the methods provided by public modules to other students or exposed components should be tested and covered, and other related ones can also be related to the corresponding unit. However, as a front-end, I also deeply understand the tediousness of writing single test cases. Therefore, based on this front-end development pain point, by borrowing the idea of reading code by using annotations from back-end students, here are some analysis and implementation operations based on annotations (ps: The proposal of the front-end decorator seems to have entered the stage of Stage 3, but considering some limitations of annotations, the annotation scheme is adopted here for analysis). In the field of front-end engineering, we should not only pay attention to UX user experience, but also pay attention to DX the improvement of development experience. In the field of 2D (to Develop), there is still some blue ocean space in the front end. Students with ideas in the field can also look for some opportunities here, and also provide more support and help for front-end development and construction. (ps: https://github.com/vee-testus/testus , welcome star, hahaha)

refer to


维李设论
1.1k 声望4k 粉丝

专注大前端领域发展