前端測試套件構建實踐

野林發表於2022-04-21

前言

圖片

前端開發過程中,我們常常忽略單元測試的功能和重要性,一個好的測試覆蓋是軟體穩定執行的前提和保證,作為軟體工程研發領域不可獲取的步驟,通常按照測試粒度可以區分為 單元測試整合測試E2E測試(UI測試),通常的測試會將最後一個粒度定位為系統測試,但是對於前端而言通常就是UI或者E2E測試,有的公司會把E2E測試單獨拿出來進行分層,這裡我們僅僅以簡單的三層模型進行區分,按照數量有正三角和倒三角之分,通常對開發進行測試來說正三角的測試架構居多,也就是單元測試佔比較多。

圖片

為了提升前端的開發效率,同時也為了減少前端編寫單元測試程式碼的繁瑣工作,testus測試套件旨在為前端測試開發工作提供便利,本文旨在介紹testus的一些設計理念及實現方案,希望能給前端基礎建設中有關於測試構建相關工作的同學提供一些幫助和思路。

架構

圖片

整體架構思路是採用函數語言程式設計的思路進行組合式構建,整體流程包含 預處理(preprocess)解析(parse)轉換(transform)生成(generate) 四個階段,通過腳手架構建的方法對使用者配置的 testus.config.js 檔案內容進行解析轉化生成,其中:

  1. 預處理階段:主要是通過解析使用者的提供的配置檔案進行相關的自定義傳輸資料結構DSL的構建;
  2. 解析階段:主要是對生成的DSL中構建的目錄樹結構進行相關的讀取檔案內容操作,並修改樹中的內容;
  3. 轉化階段:主要是對已有配置內容進行相關的模板轉化及註釋解析,其中使用者配置中的外掛配置也會進行相應的中介軟體轉換;
  4. 生成階段:主要是對已轉化後的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

核心模組提供了架構中的主要核心設計,其中 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...,歡迎star,哈哈哈)

參考

相關文章