新年第一篇,祝大家牛年大吉,牛气冲天。
在之前的《录制回放实现测试用例自由》一文中,笔者简单介绍了如何通过切面来录制HTTP接口请求和返回,并实现了用例的回放。 当然,在实际的项目中,对于应用来说,除了来自于前端的HTTP访问请求之外,至少还会有一个数据库,或者对于其他应用的服务间调用。仅仅对HTTP接口进行录制,而不去控制数据库等依赖进行控制的话,并不能保证录制出来的用例有足够的稳定性。 本文中,我们将进一步实现一个更为完整的解决方案。
假设我们是在一个运行的应用中进行录制。通过一个配置可以控制录制功能的开关,默认为关闭。 那我们的测试工具也得作为应用的一部分部署到线上去,而不仅仅是测试阶段。这可能需要说服团队来接受。当然,如果是将此功能作为日志或者审计等通用微服务框架组件的增值功能,可以提高团队接受的可能性。 当然,阿里也开源了JVM-SandBox这一利器,可以动态挂载来实现拦截,感兴趣的同学也可以利用它来实现无侵入的方案。
做过系统测试的覆盖率统计的同学都知道Jacoco提供了JacocoAgent来实现覆盖率统计。运行时通过指定javaagent的方式进行挂载。
java -javaagent:/tmp/jacoco/lib/jacocoagent.jar=includes=*,
output=tcpserver,port=6300,address=localhost,
append=true -jar demo-1.0-SNAPSHOT.jar
通过这行命令,可以在启动jacocoagent并进行插桩的同时,启动一个tcpserver,外部使用者可以利用maven-jacoco-plugin等工具来dump获取到覆盖率报告。 考虑通过HTTP API接口触发dump, 可以落在系统指定路径下,按照时间戳分别命名为xxxx_request.json和xxxx_record.json,分别表示http请求以及服务间依赖(db)的数据。 类似的,我们考虑通过HTTP API接口触发dump,并将录制的数据落地到日志文件,供后续使用。 这样,我们的使用场景就变成了
package io.metersphere.aspect.controller;
import io.metersphere.aspect.service.DumpService;
import io.metersphere.controller.ResultHolder;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
@RestController
@RequestMapping
public class DumpController {
@Resource
private DumpService dumpService;
@GetMapping(value = "/dump")
public ResultHolder dump(@RequestParam Boolean append) {
String enabled = System.getProperty("dump","TRUE");
if (StringUtils.equalsIgnoreCase("true", enabled)) {
dumpService.execute(append);
return ResultHolder.success("called");
}
return ResultHolder.success("");
}
}
假定默认是启用录制的情况,还需要一个DumpService来落地文件
package io.metersphere.aspect.service;
import com.google.common.io.Files;
import com.google.gson.Gson;
import io.metersphere.aspect.DBMapperIntercept;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
import java.util.UUID;
@Service
@Transactional(rollbackFor = Exception.class)
public class DumpService {
@Resource
private DBMapperIntercept dbMapperIntercept;
private int sequence=1;
private String REQUEST = "request";
private String RECORD="record";
public void execute(Boolean append) {
String uuid=UUID.randomUUID().toString();
String appeendix=sequence+"_"+uuid+".json";
Gson gson = new Gson();
String dbRecords=gson.toJson(DBMapperIntercept.records);
String requests=gson.toJson(DBMapperIntercept.requests);
String fileName=RECORD+"_"+appeendix;
String requestName=REQUEST+"_"+appeendix;;
File dbfile= new File(fileName);
File requestfile= new File(requestName);
try {
Files.write(dbRecords.getBytes(),dbfile);
Files.write(requests.getBytes(),requestfile);
sequence++;
} catch (IOException e) {
e.printStackTrace();
}
}
}
这样,只要在应用启动,就可以启动录制了。有如下场景
package io.metersphere.aspect.test;
import com.alibaba.fastjson.JSON;
import io.metersphere.TestApp;
import io.metersphere.controller.request.LoginRequest;
import org.junit.jupiter.api.*;
public class DumpControllerTest extends TestApp {
@Test
public void loginSetup() throws Exception {
LoginRequest loginRequest= new LoginRequest();
loginRequest.setUsername("admin");
loginRequest.setPassword("metersphere");
doPost("/signin",JSON.toJSONString(loginRequest));
}
@AfterEach
public void testDump() throws Exception {
doGet("/dump?append=false");
}
}
在这个示例中,我们首先完成了登录用例,然后在@AfterEach中调用了dump接口来触发服务端完成录制结果的落地。 当然这只是一个用自动化测试用例来展示的demo。实际的项目中,可以是测试人员在前端触发接口调用,并在一个场景完成后,通过postman等工具来实现完成录制结果的dump。
如果后期考虑和用例管理平台或者devops平台进行集成,直接生成用例并纳入用例库,则可以在dump接口中直接返回录制结果。
在这里展示一个简单的场景,即通过在用例上注解指明request 和record的文件名称,测试框架将自动解析文件并执行用例(集成测试、MockMvc场景下) 笔者之前也写过另外一篇文章《如何用Junit5玩出参数化测试的新花样?》介绍如何将整个目录作为用例集来执行,感兴趣的读者也可以了解下。这样,就不再需要去编写一个个脚手架用例了。
@Slf4j
@ExtendWith(ScenarioExtension.class)
public class ScenarioPlayTest extends TestBase {
@Scenario(request ="src/test/resources/dbmock/request.json",value="src/test/resources/dbmock/record.json")
public void testAddProject() throws Exception {
}
}
这里的request/value代表了HTTP请求和服务调用。 为了实现@Scenario这一效果,我们来编写一个ScenarioExtension注解。其逻辑为: 1)判断测试用例中是否存在@Scenario注解 2)如果存在则判断是否存在服务调用文件,即value,存在则通过它来提供依赖服务的测试桩 3)读取request文件,并调用执行器Runner来执行。这里我们使用了MockMvcRunner,也就是通过MockMVC来执行HTTP请求。
package io.metersphere.aspect.junit5;
import java.io.File;
import java.util.List;
import com.alibaba.fastjson.JSON;
import io.metersphere.aspect.MapperRecord;
import io.metersphere.aspect.MockDBUtils;
import io.metersphere.aspect.Modes;
import io.metersphere.aspect.junit5.runner.MockMvcRunner;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.platform.commons.util.AnnotationUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@Component
public class ScenarioExtension
implements BeforeTestExecutionCallback {
protected MockMvcRunner runner;
// EXTENSION POINTS
public void beforeTestExecution(ExtensionContext context) throws Exception{
// get the dataSource bean from the spring context
ApplicationContext springContext = SpringExtension.getApplicationContext(context);
runner=springContext.getBean(MockMvcRunner.class);
Scenario scenario = AnnotationUtils.findAnnotation(context.getRequiredTestMethod(), Scenario.class).orElse(null);
if (scenario == null) {
scenario = AnnotationUtils.findAnnotation(context.getRequiredTestClass(), Scenario.class).orElse(null);
}
String request=scenario.request();
File requestFile= new File(request);
String filename = scenario.value();
File recordFile= new File(filename);
if(recordFile.exists()){ //提供依赖服务
MockDBUtils.setMode(Modes.SIM);
MockDBUtils.provideStubs(filename);
}
if(requestFile.exists()) {
//文件不存在,不执行用例
List<MapperRecord> recordList = MockDBUtils.getMapperRecords(request);
assertThat(recordList).isNotEmpty();
for (MapperRecord record : recordList
) {
runner.run(record);
// assertThatJson(result).when(Option.IGNORING_EXTRA_FIELDS).isEqualTo(record.getReturning());
}
}
}
}
@Scenario的注解如下,
package io.metersphere.aspect.junit5;
import java.lang.annotation.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(ScenarioExtension.class)
@Test
public @interface Scenario {
String value() default "";
String request() default "";
}
这个注解可以在类或者方法上使用,目前不支持叠加。另外,额外带了@Test注解,在测试用例中就可以不再标注了。 至于MockMVCRunner,则是对于MockMvc中调用HTTP GET/POST请求的简单封装,限于篇幅就不再展示源码了。感兴趣的读者可以参考笔者的另外一篇文章《MockMvc -你需要一个测试基类》来了解更多。
在之前的文章中,笔者已经介绍了对Controller接口的拦截。在原先的基础上,我们需要额外增加对于服务间依赖调用的拦截,此处以数据库为例。
package io.metersphere.aspect;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Type;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import static java.util.stream.Collectors.joining;
/**
*
* @author Antony
* 通过aop拦截后执行具体操作
*/
@Aspect
@Order(9)
@Component
@Slf4j
public class DBMapperIntercept {
public static List<MapperRecord> records=new ArrayList<>();
public static List<MapperRecord> requests=new ArrayList<>();
MapperRecord requestRecord = new MapperRecord();
public static Modes mode=Modes.REC;
@Pointcut("execution(public * io.metersphere.base.mapper..*.*(..))")
public void recordLog(){}
@Around("recordLog()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
//1.获取className+methdoName+args
MethodSignature signature = (MethodSignature) pjp.getSignature();
String className=pjp.getSignature().getDeclaringType().getSimpleName();
String methodName = pjp.getSignature().getName();
Object[] args=pjp.getArgs();
Type returnType =signature.getMethod().getAnnotatedReturnType().getType();
MapperRecord mapperRecord = new MapperRecord();
mapperRecord.setClassName(className);
mapperRecord.setMethodName(methodName);
mapperRecord.setArgs(args);
//进入播放模式
if(mode.equals(Modes.SIM)){
MapperRecord record=MockDBUtils.getMockData(records,mapperRecord);
MockDBUtils.print(record);
if(record==null) { return null; }
Object data=record.getReturning();
return GsonUtil.fromJson(data,returnType);
}
//2.执行程序并获取结果
Object proceed = pjp.proceed();
mapperRecord.setReturning(proceed);
//保存记录
if(mode.equals(Modes.REC)) {
records.add(mapperRecord);
}
return proceed;
}
}
我们利用切面获取到了Mapper调用的类、方法、入参和返回,并且将这些记录保存进了 List<MapperRecord> requests, 供dump时导出使用。 在原先录制Controller方法的基础上,再叠加对Mapper的拦截,我们就具备了录制请求和(数据库)依赖的能力。
通过上述实践,我们可以按照如下的方式进行测试用例的开发和执行 1)启用录制功能,对应用的请求和依赖进行录制,形成测试用例(文件) 2)将录制的文件作为测试用例来执行(集成测试) 涉及的技术点