输入输出无依赖型函数的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 的插件。