• 输入输出无依赖型函数的GroovySpock单测模板的自动生成工具(上)


    目标###

    《使用Groovy+Spock轻松写出更简洁的单测》 一文中,讲解了如何使用 Groovy + Spock 写出简洁易懂的单测。 对于相对简单的无外部服务依赖型函数,通常可以使用 expect-where 的形式。

    本文尝试自动生成无外部服务依赖型函数的Spock单测模板,减少编写大量单测的重复工作量,只需要构造相应的测试数据集即可。

    分析与思路###

    首先,需要仔细分析下无外部服务依赖型函数的Spock单测模板的组成。 比如

    class BinarySearchTest extends Specification {
    
        def "testSearch"() {
            expect:
            BinarySearch.search(arr as int[], key) == result
    
            where:
            arr       | key | result
            []        | 1   | -1
            [1]       | 1   | 0
            [1]       | 2   | -1
            [3]      | 2   | -1
            [1, 2, 9] | 2   | 1
            [1, 2, 9] | 9   | 2
            [1, 2, 9] | 3   | -1
            //null      | 0   | -1
        }
    
    }
    

    使用模板替换的思路最为直接。我们将使用Groovy的模板机制。分析这个单测组成,可以得到两个模板:

    方法的模板####

    method.tpl

        @Test
        def "test${Method}"() {
            expect:
            ${inst}.invokeMethod("${method}", [${paramListInMethodCall}]) == ${result}
    
            where:
            ${paramListInDataProvider}     | ${result}
            ${paramValues} | ${resultValue}
    
        }
    

    有几点说明下:

    • 之所以用 invokeMethod ,是为了既适应public方法也适应 private 方法,因为只要调用到相应方法返回值即可。当然,这样增加了点击进去查看方法的麻烦程度。可以做个优化。
    • 之所以有 Method 和 method 变量,是因为 test${Method} 需要有变化,比如考虑到重载方法,这里就不能生成同名测试方法了。
    • paramListInMethodCall,paramListInDataProvider 只有分隔符的区别。通过解析参数类型列表来得到。

    类的模板####

    spocktest.tpl

    package ${packageName}
    
    import org.junit.Test
    import spock.lang.Specification
    
    ${BizClassNameImports}
    
    /**
     * AutoGenerated BY AutoUTGenerator.groovy
     */
    class ${ClassName}AutoTest extends Specification {
    
       def $inst = new ${ClassName}()
    
       ${AllTestMethods}
    
    }
    

    BizClassNameImports 需要根据对所有方法的解析,得到引入的类列表而生成。

    现在,思路很明晰了: 通过对测试类及方法的反射解析,得到相应的信息,填充到模板变量里。

    详细设计###

    数据结构设计####

    接下来,需要进行数据结构设计。尽管我们也能一团面条地解析出所需数据然后填充,那样必然使代码非常难读,而且不易扩展。 我们希望整个过程尽可能明晰,需要增加新特性的时候只需要改动局部。 因此,需要仔细设计好相应的数据结构, 然后将数据结构填充进去。

    根据对模板文件的分析,可以知道,我们需要如下两个对象:

    class AllInfoForAutoGeneratingUT {
    
        String packageName
        String className
        List<MethodInfo> methodInfos
    }
    
    class MethodInfo {
        String methodName
        List<String> paramTypes
        List<String> classNamesToImported
        String returnType
    }
    

    算法设计####

    接下来,进行算法设计,梳理和写出整个流程。

    STEP1: 通过反射机制解析待测试类的包、类、方法、参数信息,填充到上述对象中;

    STEP2: 加载指定模板,使用对象里的信息替换模板变量,得到测试内容Content;

    STEP3:根据待测试类的路径生成对应测试类的路径和文件TestFile.groovy;

    STEP4: 向 TestFile.groovy 写入测试内容 Content;

    STEP5: 细节调优。

    细节调优###

    完整源代码见附录。这里对一些细节的处理做一点说明。

    参数名的处理####

    一个问题是, 如果方法参数类型里含有 int , long 等基本类型,那么生成的单测里会含有这些关键字,导致单测编译有错。比如,若方法是 X.getByCode(Integer code), 那么生成的测试方法调用是 x.invokeMethod("getByCode", [integer]) , 若方法是 X.getByCode(int code) ,那么生成的测试方法调用是 x.invokeMethod("getByCode", [int]) ,就会因为参数名为关键字 int 而报错。解决办法是,生成参数名时统一加了个 val 后缀; 如果是列表,就统一加个 list 后缀; 见 typeMap。

    以小见大: 解决一个问题,不应只局限于解决当前问题,而要致力于发明一种通用机制,解决一类问题。

    另一个问题是,如果方法里含有多个相同类型的参数,那么生成 where 子句的头时,就会有重复,导致无法跑 spock 单测。 见如下:

    public static List<Creative> query(CreativeQuery query, int page, int size ) {
        return new ArrayList();
      }
    
       @Test
        def "testQuery"() {
            expect:
            x.invokeMethod("query", [creativeQuery,intval,intval]) == resultlist
    
            where:
            creativeQuery0 | intval | intval     | resultlist
            new CreativeQuery([:]) | 0 | 0 | []
    
        }
    

    解决的办法是,遍历参数列表生成参数名时,最后再加上参数的位置索引,这样就不会重复了。这个方法其实也可以解决上面的问题。 这就是如下代码的作用。

    def paramTypeList = []
            m.paramTypes.eachWithIndex {
                def entry, int ind -> paramTypeList.add(mapType(firstLowerCase(entry), false) + ind)
            }
    

    生成的单测是:

       @Test
        def "testQuery"() {
            expect:
            x.invokeMethod("query", [creativeQuery0,intval1,intval2]) == resultlist
    
            where:
            creativeQuery0 | intval1 | intval2     | resultlist
            new CreativeQuery([:]) | 0 | 0 | []
    
        }
    

    同名方法的处理####

    如果一个类含有方法重载,即含有多个同名方法,那么生成的测试类方法名称就相同了,导致单测编译有错。由于 groovy 的测试方法名可以是带引号的字符串,因此,这里在生成测试方法名时,就直接带上了参数的类型,避免重复。

        "Method": firstUpperCase(m.methodName) + "(" + m.paramTypes.join(",") + ")",
    

    生成的测试方法名是: "testQuery(CreativeQuery,int,int)" 而不是简单的 "testQuery"

    测试数据构造####

    对于Java语言支持的类型,可以通过 typeDefaultValues 构建一个 类型到 默认值的映射; 对于自定义的类型,怎么办呢 ? 这里,可以通过先构造一个Map,再用工具类转成对应的对象。Groovy 的构造器非常方便,可以直接用 new 类名(map) 来得到对象。

    当然,对于含有嵌套对象的对象的构造,还需要优化。

    完整源代码###

    目录结构####

    ProjectRoot
        templates/
             method.tpl, spocktest.tpl
        sub-module
             src/main/(java,resource,groovy)
             src/test/groovy
                  autout
                       AutoUTGenerator.groovy
                       GroovyUtil.groovy
    

    实际应用中,可以把模板文件放在 src/main/resource 下。

    代码####

    AutoUTGenerator.groovy

    package autout
    
    import groovy.text.SimpleTemplateEngine
    import zzz.study.X
    
    import java.lang.reflect.Method
    
    /**
     * Created by shuqin on 18/6/22.
     */
    class AutoUTGenerator {
    
        def static projectRoot = System.getProperty("user.dir")
    
        static void main(String[] args) {
            ut X.class
            // ut("com.youzan.ebiz.trade.biz")
        }
    
        static void ut(String packageName) {
            List<String> className = ClassUtils.getClassName(packageName, true)
            className.collect {
                ut Class.forName(it)
            }
        }
    
        /**
         * 生成指定类的单测模板文件
         */
        static void ut(Class testClass) {
            def packageName = testClass.package.name
            def className = testClass.simpleName
            def methods = testClass.declaredMethods.findAll { ! it.name.contains("lambda") }
            def methodInfos = methods.collect { parse(it) }
    
            def allInfo = new AllInfoForAutoGeneratingUT(
                    packageName: packageName,
                    className: className,
                    methodInfos: methodInfos
            )
            def content = buildUTContent allInfo
    
            def path = getTestFileParentPath(testClass)
            def dir = new File(path)
            if (!dir.exists()) {
                dir.mkdirs()
            }
            def testFilePath = "${path}/${className}AutoTest.groovy"
            writeUTFile(content, testFilePath)
            println("Success Generate UT for $testClass.name in $testFilePath")
        }
    
        /**
         * 解析拿到待测试方法的方法信息便于生成测试方法的内容
         */
        static MethodInfo parse(Method m) {
            def methodName = m.name
            def paramTypes = m.parameterTypes.collect { it.simpleName }
            def classNamesToImported = m.parameterTypes.collect { it.name }
            def returnType = m.returnType.simpleName
    
            new MethodInfo(methodName: methodName,
                           paramTypes: paramTypes,
                           classNamesToImported: classNamesToImported,
                           returnType: returnType
            )
    
        }
    
        /**
         * 根据单测模板文件生成待测试类的单测类模板
         */
        static buildUTContent(AllInfoForAutoGeneratingUT allInfo) {
            def spockTestFile = new File("${projectRoot}/templates/spocktest.tpl")
            def methodContents = allInfo.methodInfos.collect { generateTestMethod(it, allInfo.className) }
                                                    .join("
    
    ")
    
            def engine = new SimpleTemplateEngine()
            def imports = allInfo.methodInfos.collect { it.classNamesToImported }
                    .flatten().toSet()
                    .findAll { isNeedImport(it) }
                    .collect { "import " + it } .join("
    ")
            def binding = [
                    "packageName": allInfo.packageName,
                    "ClassName": allInfo.className,
                    "inst": allInfo.className.toLowerCase(),
                    "BizClassNameImports": imports,
                    "AllTestMethods": methodContents
            ]
            def spockTestContent = engine.createTemplate(spockTestFile).make(binding) as String
            return spockTestContent
        }
    
        static Set<String> basicTypes = new HashSet<>(["int", "long", "char", "byte", "float", "double", "short"])
    
        static boolean isNeedImport(String importStr) {
            def notToImport = importStr.startsWith('[') || importStr.contains("java") || (importStr in basicTypes)
            return !notToImport
        }
    
        /**
         * 根据测试方法模板文件 method.tpl 生成测试方法的内容
         */
        static generateTestMethod(MethodInfo m, String className) {
            def engine = new SimpleTemplateEngine()
            def methodTplFile = new File("${projectRoot}/templates/method.tpl")
            def paramValues = m.paramTypes.collect { getDefaultValueOfType(firstLowerCase(it)) }.join(" | ")
            def returnValue = getDefaultValueOfType(firstLowerCase(m.returnType))
    
            def paramTypeList = []
            m.paramTypes.eachWithIndex {
                def entry, int ind -> paramTypeList.add(mapType(firstLowerCase(entry), false) + ind)
            }
    
            def binding = [
                    "method": m.methodName,
                    "Method": firstUpperCase(m.methodName) + "(" + m.paramTypes.join(",") + ")",
                    "inst": className.toLowerCase(),
                    "paramListInMethodCall": paramTypeList.join(","),
                    "paramListInDataProvider": paramTypeList.join(" | "),
                    "result": mapType(firstLowerCase(m.returnType), true),
                    "paramValues": paramValues,
                    "resultValue": returnValue
            ]
    
            return engine.createTemplate(methodTplFile).make(binding).toString() as String
    
        }
    
        /**
         * 写UT文件
         */
        static void writeUTFile(String content, String testFilePath) {
            def file = new File(testFilePath)
            if (!file.exists()) {
                file.createNewFile()
            }
            def printWriter = file.newPrintWriter()
    
            printWriter.write(content)
            printWriter.flush()
            printWriter.close()
        }
    
        /**
         * 根据待测试类生成单测类文件的路径(同样的包路径)
         */
        static getTestFileParentPath(Class testClass) {
            println(testClass.getResource("").toString())
            testClass.getResource("").toString() // GET: file:$HOME/Workspace/java/project/submodule/target/classes/packagePath/
                    .replace('/target/test-classes', '/src/test/groovy')
                    .replace('/target/classes', '/src/test/groovy')
                    .replace('file:', '')
        }
    
        /** 首字母小写 */
        static String firstLowerCase(String s) {
            s.getAt(0).toLowerCase() + s.substring(1)
        }
    
        /** 首字母大写 */
        static String firstUpperCase(String s) {
            s.getAt(0).toUpperCase() + s.substring(1)
        }
    
        /**
         * 生成参数列表中默认类型的映射, 避免参数使用关键字导致跑不起来
         */
        static String mapType(String type, boolean isResultType) {
            def finalType = typeMap[type] == null ? type : typeMap[type]
            (isResultType ? "result" : "") + finalType
        }
    
        static String getDefaultValueOfType(String type) {
            def customerType = firstUpperCase(type)
            typeDefaultValues[type] == null ? "new ${customerType}([:])" : typeDefaultValues[type]
        }
    
        def static typeMap = [
                "string": "str", "boolean": "bval", "long": "longval", "Integer": "intval",
                "float": "fval", "double": "dval", "int": "intval", "object[]": "objectlist",
                "int[]": "intlist", "long[]": "longlist", "char[]": "chars",
                "byte[]": "bytes", "short[]": "shortlist", "double[]": "dlist", "float[]": "flist"
        ]
    
        def static typeDefaultValues = [
                "string": """", "boolean": true, "long": 0L, "integer": 0, "int": 0,
                "float": 0, "double": 0.0, "list": "[]", "map": "[:]", "date": "new Date()",
                "int[]": "[]", "long[]": "[]", "string[]": "[]", "char[]": "[]", "short[]": "[]", "byte[]": "[]", "booloean[]": "[]",
                "integer[]": "[]", "object[]": "[]"
        ]
    }
    
    class AllInfoForAutoGeneratingUT {
    
        String packageName
        String className
        List<MethodInfo> methodInfos
    }
    
    class MethodInfo {
        String methodName
        List<String> paramTypes
        List<String> classNamesToImported
        String returnType
    }
    

    改进点###

    • 模板可以更加细化,比如支持异常单测的模板;有参函数和无参函数的模板;模板组合;
    • 含嵌套对象的复杂对象的测试数据生成;
    • 写成 IntellJ 的插件。
  • 相关阅读:
    软工实践寒假作业(1/2)
    javaSprintBoot技术总结
    个人作业——软件工程实践总结&个人技术博客
    个人作业软件评测
    结对第二次作业——某次疫情统计可视化的实现
    结对第一次—疫情统计可视化(原型设计)
    软工实践寒假作业(2/2)
    《软件工程》_寒假作业1_职业生涯规划
    部署前端项目和后端项目到服务器
    软件工程实践总结&个人技术博客
  • 原文地址:https://www.cnblogs.com/lovesqcc/p/9246823.html
Copyright © 2020-2023  润新知