输入输出无依赖型函数的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("\n\n")

        def engine = new SimpleTemplateEngine()
        def imports = allInfo.methodInfos.collect { it.classNamesToImported }
                .flatten().toSet()
                .findAll { isNeedImport(it) }
                .collect { "import " + it } .join("\n")
        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 的插件。
posted @ 2018-06-30 13:47  琴水玉  阅读(808)  评论(0编辑  收藏  举报