最近得到了一个新的需求,如何通过单元测试代码自动导出测试用例文档,我大概网上搜了搜,并没有什么现成的比较好用的工具,于是决定自己写代码来实现该需求
1.思路
主要使用的就是反射,通过反射获取测试类中的方法信息,以及一系列自定义的注解,生成用例文档
2.使用说明
单元测试示例代码如下
@UnitName("测试管理1")
public class Test1 {
private static final Logger log = LoggerFactory.getLogger(Test1.class);
@Mock
private ClassesEsServiceImpl classesEsService;
@InjectMocks
private ClassesServiceImpl classesService;
@BeforeEach
void setup() {
//初始化
MockitoAnnotations.openMocks(this);
}
@Test
@CaseType("等价类")
@CaseDesignMethod("语句覆盖")
void testGet_BASE_04_001_Test_1() {
when(classesEsService.queryAll()).thenReturn(new ArrayList<>());
classesService.queryAll();
log.info("param: test_1_param");
log.info("result: test_1_result");
}
@Test
@CaseType("等价类")
@CaseDesignMethod("语句覆盖")
void testGet_BASE_04_001_Test_2() {
List<String> list = Arrays.asList("mai", "da", "da", "wlx", "hello world");
log.info("param: " + JSON.toJSONString(list));
log.info("result: test1_2");
}
示例代码未给全,导出excel示例如下
以存在值的这几个字段为例,讲解一下获取的原理:其中用例编号、Unit ID、Test Case Name都是从测试方法名中获取的,像方法名 “testGet_BASE_04_001_Test_1”,用例编号就是Test_1,Unit ID是BASE_04_001,Test Case Name是类名.Get,具体如何处理这几个值是下方代码实现中的processMethodName中处理的,可以根据自身需求处理。而测试用例方法和用例类型是从注解中获取的值,输入参数和预期结果则是通过打印日志到控制台,然后拦截日志内容,根据日志字符串处理获取的,解析日志字符串需要一定的规则,我就是以 “result:” 后面的值为预期结果,“param:” 后面的值为输入参数,注意冒号是英文冒号,最后sheet名是从类上的自定义注解@UnitName中获取的
3.代码实现
首先是自定义的几个注解,可以根据自身需求来决定注解
/**
* 测试用例设计方法
*
* @author WuLinXuan
* @date 2024-01-30 15:12
*/
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CaseDesignMethod {
String value() default "语句覆盖";
}
/**
* 用例类型
*
* @author WuLinXuan
* @date 2024-01-30 15:12
*/
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CaseType {
String value() default "等价类";
}
/**
* 单元名称
*
* @author WuLinXuan
* @date 2024-01-30 15:19
*/
@Target(value = ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface UnitName {
String value();
}
Excel导出模版类,导出使用的是EasyExcel,根据自身需求和对应文档格式决定要有哪些字段,我也只给出了对应文档部分字段
@Data
public class TestCase {
@ExcelProperty("用例编号")
private String caseNumber;
@ExcelProperty("Unit ID")
private String unitID;
@ExcelProperty("Test Case Name")
private String caseName;
@ExcelProperty("测试用例设计方法")
private String caseDesignMethod;
@ExcelProperty("用例类型")
private String caseType;
@ExcelProperty("输入参数")
private String inputParam;
}
注解扫描处理类,运行main方法即可,注意该类会扫描该类所在包下所有的包下的类,也就是说和该类同级的类和上级的类不会扫描
/**
* 测试注解扫描类
*
* @author WuLinXuan
* @date 2024-01-30 16:22
*/
public class Test {
// 导出sheet编号,每一个测试类为一个sheet
public static int sheetCount = 0;
// 导出excel文件路径
public static String fileOutputPath = "d:/desktop/doc/test/test.xlsx";
public static String line = "---------------------";
public static void main(String[] args) throws Exception{
// 获取当前类所在文件夹
String packagePath = Test.class.getResource("").getPath();
packagePath = URLDecoder.decode(packagePath, "UTF-8").replace('.', '\\');
File packageDir = new File(packagePath);
// 与该类同级的类及自身不考虑,只选择文件夹,获取Class
List<Class<?>> classList = new ArrayList<>();
for (File file : packageDir.listFiles()) {
if (file.isDirectory()) {
classList.addAll(getClassFiles(file, file.getName() + "."));
}
}
processClassList(classList);
}
/**
* 递归获取文件夹下的类Class
*
* @param dir
* @param packageName
* @return
* @throws Exception
*/
public static List<Class<?>> getClassFiles(File dir, String packageName) throws Exception{
List<Class<?>> list = new ArrayList<>();
if (!dir.isDirectory()) {
return list;
}
for (File file : dir.listFiles()) {
if (file.isFile()) {
String className = packageName + file.getName();
// build过后的类文件是以 class 结尾,去掉就是类名
className = className.substring(0, className.lastIndexOf(".class"));
list.add(Class.forName(className));
} else {
packageName = packageName + file.getName() + ".";
list.addAll(getClassFiles(file, packageName));
}
}
return list;
}
/**
* 处理Class集合
*
* @param classList
* @throws Exception
*/
public static void processClassList(List<Class<?>> classList) throws Exception{
// 输出文件位置
FileOutputStream fileOutputStream = new FileOutputStream(fileOutputPath);
ExcelWriter excelWriter = EasyExcel.write(fileOutputStream).build();
for (Class<?> clazz : classList) {
System.out.println(line + clazz.getName() + line);
UnitName unitName = clazz.getAnnotation(UnitName.class);
String sheetName = unitName.value();
List<TestCase> caseList = new ArrayList<>();
Object instance = clazz.getConstructor().newInstance();
Method[] methods = clazz.getDeclaredMethods();
// 根据方法名中的数字排序,因为默认获取的方法数组是无序的
Arrays.sort(methods, (Comparator.comparing(o -> getMethodNameNumber(o.getName()))));
for (Method method : methods) {
// 方法名需以 test 开头
if (method.getName().indexOf("test") != 0) {
continue;
}
// 开启mock模拟,防止调用单元测试方法报错,实际上是为类中注入有@Mock、@InjectionMocks注解的类
MockitoAnnotations.openMocks(instance);
// 调用方法
String capturedLog = invokeAndCaptureLog(method, instance);
TestCase testCase = new TestCase();
// 日志中存在关键字 param: 和 result: ,则将其后面的字符串视为需要设置的值,注意是英文冒号
String[] logs = capturedLog.split("\n");
for (String s : logs) {
int i = s.indexOf("param:");
int j = s.indexOf("result:");
if (i != -1) {
testCase.setInputParam(s.substring(i + 6).trim());
} else if(j != -1) {
testCase.setExpectResult(s.substring(j + 7).trim());
}
}
caseList.add(processMethodName(method, clazz, testCase));
}
exportList(caseList, sheetName, excelWriter);
}
excelWriter.finish();
}
/**
* 获取方法名中的数字字符串
*
* @param methodName
* @return
*/
public static String getMethodNameNumber(String methodName) {
Pattern pattern = Pattern.compile("\\d+");
Matcher matcher = pattern.matcher(methodName);
StringBuilder numbers = new StringBuilder();
while (matcher.find()) {
numbers.append(matcher.group());
}
return numbers.toString();
}
/**
* 调用方法并拦截log日志内容返回
*
* @param method
* @param instance
* @return
* @throws Exception
*/
public static String invokeAndCaptureLog(Method method, Object instance) throws Exception{
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
PrintStream printStream = new PrintStream(byteArrayOutputStream);
PrintStream originalOut = System.out;
System.setOut(printStream);
method.setAccessible(true);
method.invoke(instance);
System.setOut(originalOut);
return byteArrayOutputStream.toString();
}
/**
* 根据方法名解析对应属性值
* 以 testGet_BASE_04_001_Test_1 为例, 这是一个标准测试用例方法名,首先去除test,然后Base_04_001为Unit ID,Get为测试方法名,
* Test_1为用例编号
*
* @param method
* @param clazz
* @param testCase
* @return
*/
public static TestCase processMethodName(Method method, Class<?> clazz, TestCase testCase) {
// 获取被测试的类名,通常单元测试中该类为打上注解@InjectMocks的类,如有其他特殊情况请自行修改
String testClassName = "";
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
InjectMocks annotation = field.getAnnotation(InjectMocks.class);
if (annotation != null) {
testClassName = field.getType().getSimpleName();
}
}
String methodName = method.getName();
// 去除方法名前的 test
methodName = methodName.substring(4);
String[] split = methodName.split("_");
CaseType caseType = method.getAnnotation(CaseType.class);
CaseDesignMethod caseDesignMethod = method.getAnnotation(CaseDesignMethod.class);
testCase.setCaseNumber(String.join("_", split[4], split[5]));
testCase.setUnitID(String.join("_", split[1], split[2], split[3]));
testCase.setCaseName(testClassName + "." + split[0]);
testCase.setCaseDesignMethod(caseDesignMethod.value());
testCase.setCaseType(caseType.value());
System.out.println(method.getName());
return testCase;
}
/**
* excel导出
*
* @param caseList
* @param sheetName
* @param excelWriter
* @throws Exception
*/
public static void exportList(List<TestCase> caseList, String sheetName, ExcelWriter excelWriter) throws Exception{
WriteSheet writeSheet = EasyExcel.writerSheet(sheetCount++, sheetName).head(TestCase.class).build();
excelWriter.write(caseList, writeSheet);
}
}
4.结尾
注意使用这些代码的前提是编写测试用例时要按照一定的规范编写:测试类上打好@UnitName注解值作为sheet名等,方法上打好注解@CaseType等注解,单元测试方法内部需要使用log并且内容按照一定规则。注意各个测试用例文档格式可能不同,需要自己对代码进行调整,我也并未对一些特殊情况进行处理