输入输出无依赖型函数的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 的插件。
输入输出无依赖型函数的GroovySpock单测模板的自动生成工具(上)的更多相关文章
- 使用Java函数接口及lambda表达式隔离和模拟外部依赖更容易滴单测
概述 单测是提升软件质量的有力手段.然而,由于编程语言上的支持不力,以及一些不好的编程习惯,导致编写单测很困难. 最容易理解最容易编写的单测,莫过于独立函数的单测.所谓独立函数,就是只依赖于传入的参数 ...
- 无依赖简单易用的Dynamics 365公共视图克隆工具
本人微信公众号:微软动态CRM专家罗勇 ,回复279或者20180818可方便获取本文,同时可以在第一间得到我发布的最新博文信息,follow me!我的网站是 www.luoyong.me . Dy ...
- 如何利用pip自动生成和安装requirements.txt依赖
在查看别人的Python项目时,经常会看到一个requirements.txt文件,里面记录了当前程序的所有依赖包及其精确版本号.这个文件有点类似与Rails的Gemfile.其作用是用来在另一台PC ...
- [python] [转]如何自动生成和安装requirements.txt依赖
[转]如何自动生成和安装requirements.txt依赖 在查看别人的Python项目时,经常会看到一个requirements.txt文件,里面记录了当前程序的所有依赖包及其精确版本号.这个文件 ...
- 如何自动生成和安装requirements.txt依赖
在查看别人的Python项目时,经常会看到一个requirements.txt文件,里面记录了当前程序的所有依赖包及其精确版本号.这个文件有点类似与Rails的Gemfile.其作用是用来在另一台PC ...
- 自动生成和安装requirements.txt依赖
在查看别人的Python项目时,经常会看到一个requirements.txt文件,里面记录了当前程序的所有依赖包及其精确版本号.这个文件有点类似与Rails的Gemfile.其作用是用来在另一台PC ...
- 使用Groovy+Spock轻松写出更简洁的单测
当无法避免做一件事时,那就让它变得更简单. 概述 单测是规范的软件开发流程中的必不可少的环节之一.再伟大的程序员也难以避免自己不犯错,不写出有BUG的程序.单测就是用来检测BUG的.Java阵营中,J ...
- 【spock】单测竟然可以如此丝滑
0. 为什么人人都讨厌写单测 在之前的关于swagger文章里提到过,程序员最讨厌的两件事,一件是别人不写文档,另一件就是自己写文档.这里如果把文档换成单元测试也同样成立. 每个开发人员都明白单元测试 ...
- 上传图片,多图上传,预览功能,js原生无依赖
最近很好奇前端的文件上传功能,因为公司要求做一个支持图片预览的图片上传插件,所以自己搜了很多相关的插件,虽然功能很多,但有些地方不能根据公司的想法去修改,而且需要依赖jQuery或Bootstrap库 ...
随机推荐
- js生成唯一的id
1.生成[0,1)的随机数的Math.random Math.random().toString().replace(".", "");// 生成唯一的id 2 ...
- 【SQL】SQL Between用法
- SVN安装部署
svn安装版本用的是1.8 SVN属于功能性软件,yum安装即是最佳实践. 安装svn yum install subversion 检查svn是否安装完毕 [root@mysql ~]# rpm - ...
- 安卓端数据导出成txt文件
toExport() { if (this.dataList == false) { this.$createDialog({ type: "alert", content: &q ...
- js将一位数组分割成每三个一组
var data = [ {name:'Liming',age:'25'}, {name:'Liming',age:'25'}, {name:'Liming',age:'25'}, { ...
- Koala ===》编译工具 ==》Less和Sass
官网下载网址:http://koala-app.com/index-zh.html 安装时:必须装在c盘,否则会编译报错,切记要装在c盘使用,把整体目录拖动到软件中,执行编译(success)即可 整 ...
- Python中常用的模块
模块,用一砣代码实现了某个功能的代码集合. 类似于函数式编程和面向过程编程,函数式编程则完成一个功能,其他代码用来调用即可,提供了代码的重用性和代码间的耦合.而对于一个复杂的功能来,可能需要多个函数才 ...
- [py][mx]django的cookie和session操作-7天免登录
浏览器同源策略(same-origin policy) csrf攻击防御核心点总结 django的cookie和session操作-7天免登录 flask操作cookie&django的see ...
- [py]super调用父类的方法---面向对象
super()用于调用父类方法 http://www.runoob.com/python/python-func-super.html super() 函数是用于调用父类(超类)的一个方法. clas ...
- 编辑文件 vi,vim的基本操作
vim 文件名字进入文件后 按i 进行编辑编辑确认后 按 Esc 停止编辑然后 按: 输入 wq (是root权限时才行:若是强制修改 需要 ...