Junit 与 TestNG 并无本质性差别,都是测试基础框架
重庆欢迎你
我们用的测试网管 + 测试 MOCK 的思路,所有服务请求到第三方的请求都会通过我们的测试网管(修改服务的配置,将第三方服务的域名更改为测试网管的域名)。然后根据一些白名单配置,决定对应接口是否请求到第三方服务/测试 MOCK。按照这个逻辑,就可以决定特定第三方服务/特定接口/特定数据是否需要走第三方还是 MOCK。
我们当前用的是 TestNG,只是之前某些测试用例由于某些无法抗拒的原因用的 junit5。目前用的 allure,使用监听的方式生成测试报告没详细了解过,网上应该有很多的 demo 的,可以去了解一下。
5.0 相对 4.0 做了很大的改动,应该不支持你那种(你可以了解看看有没有兼容方案)。在 4.0 上也是可以做数据驱动的,没有 junit5 好用,你可以参考一下这种,http://www.51testing.com/html/44/316844-3483525.html
@CsvSource是 junit5 里面的一个注解,是以 csv 格式提供数据的,你可以很明显的看到一组测试数据里的逗号分隔。junit5 里面还有其他的数据提供/参数化的注解,可以有空了解一下。整体上和 testNG 里面的@DataProvider大差不差。
不好意思,没太明白你这边的问题点。数据驱动?
其实,思路才是测试过程最重要的。虽然这个文档/demo 是三年前的的,但其实我们当前也只是在这个上面根据业务内容的调整不断优化而已。相关的功能点都在上面的回帖中提及到了的。置于新的 demo,我这边目前没有太多时间来将新的实践整理出 demo 来,抱歉!
一、展示一个流程数据构造的例子:
1、如下是一个完整的业务流程
@RepeatedTest(1) // 为当前用例执行的次数
@Execution(CONCURRENT) // CONCURRENT表示支持多线程
@DisplayName("申请 + 商户录单 + 风控初审 + 风控复审 + 绑卡 + 在线签约 + 面签合同 + 抵押办理 + GPS安装 + 请款/等待放款成功")
public void applyWorkFlowCase6() throws Exception {
String userMobile = MobileUtil.generate();
String agentMobile = agentDefaultMobile;
String productNo = "379968089799786497";
// 借款提交
String bizNo = applyService.loanApply(userMobile, agentMobile, productNo);
// 商户录单
merchantAppDealOrderService.merchantReplenish(bizNo);
// 初审
approvalService.firstTrial(bizNo);
// 复审
approvalService.retrial(bizNo);
// 绑卡
bindBankService.bindBank(bizNo);
// 签约
signService.sign(bizNo);
// 面签合同
contractService.signContractFace2Face(bizNo);
// 办理抵押
mortgageService.registerUpload(bizNo);
// GPS安装
contractService.gpsInstall(bizNo);
// 等待请款/放款
fundingService.waitFundingSuccess(bizNo);
}
从这上面看我们是看不到任何的接口调用,它是一个业务流程
2、我们展开上面的 “借款提交” applyService.loanApply
public String loanApply(Boolean caseMockHF,
String userMobile, String agentMobile, String productNo) throws InterruptedException {
// 注册/登录
LoginDataVO loginDataVO = loginService.appUserLoginBySms(userMobile);
String authorization = loginDataVO.getAuthorization();
String userId = loginDataVO.getUserId();
// 身份信息认证
userInfoService.identifySave(authorization, userId);
// 用户信息
userInfoService.userInfoSave(authorization, userId);
// 征信授权
CreditDataVO creditDataVO = userInfoService.creditAuth(authorization, agentMobile, productNo);
String bizNo = creditDataVO.getBizNo();
// 提交
apply(authorization, caseMockHF, userId, bizNo, agentMobile, productNo);
return bizNo;
}
从这上面我们也看不到任何的接口调用,他是完整业务流程中的某一个节点
3、我们再展开上面的 “身份信息认证” userInfoService.identifySave
public void identifySave(String authorization, String userId) {
Response response;
String name;
String certNo;
String bankCardNo;
response = identifyApi.identifyStatus(authorization, null);
Assertion.isSuccess(response);
// 认证未通过的需要进行认证
if (!response.then().extract().path("data.identifyStatus").equals("2")) {
// 姓名生成
name = NameUtil.generate();
// 身份证号生成
certNo = CerNoUtil.generate();
// todo 银行类型可选择
bankCardNo = BankCardNoUtil.generate("621700");
// todo mock适配
// 卡bin
response = bankApi.bankCardBin(authorization, bankCardNo);
Assertion.isSuccess(response);
// todo mock适配
// 协议绑卡发送短信验证码
BaofooSmsRequestVO baofooSmsRequestVO = new BaofooSmsRequestVO()
.setName(name)
.setCertificateNo(certNo)
.setBankCardNo(bankCardNo)
.setBankMobile(bankMobile);
response = orderBankApi.baoFooSms(authorization, baofooSmsRequestVO);
Assertion.isSuccess(response);
// 协议号
String platformBindApplyNo = response.then().extract().path("data");
// todo 四要素验证
// 可暂时不调用,可mock
// 保存身份认证信息
IdentifySaveRequestVO identifySaveRequestVO = new IdentifySaveRequestVO()
.setName(name)
.setCertificateNo(certNo)
.setBankCardNo(bankCardNo)
.setMobile(bankMobile)
// todo 地址生成
.setAddress("这只是一个地址")
.setBirthday(certNo.substring(6, 14))
.setExpiryDate("20210802")
.setIdCardBackUri("uri")
.setIdCardFrontUri("uri")
.setIssue("公安局")
.setIssueDate("20110802")
.setNation("汉")
.setPlatformMsgCode("111111")
// 根据身份证号来判定
.setSex(CerNoUtil.isMen(certNo) ? "1" : "2")
.setPlatformBindApplyNo(platformBindApplyNo);
response = identifyApi.identifySave(authorization, identifySaveRequestVO);
Assertion.isSuccess(response);
}
}
从上面看 identifyApi.identifySave 才算是一个接口调用,而我们整个业务流程中有很多节点,而每个节点可能都有多个接口调用才最终推动流程的开展。所以,通过相应接口的组合,就可以完成业务流程数据的构造。
二、再展示一个接口测试的例子
1、如下是一个接口测试的用例
@ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}")
@CsvSource({
"【机构案件有效期为长期,单机构】, true, false",
"【机构案件有效期非长期,多机构】, false, true"
})
@DisplayName("案件认领(单个机构/多个机构认领,机构有效期长期/非长期)")
@Tag("CaseHandleApi.claim")
public void testAppOutsourceCaseClaimInterfaceCase1(String des, boolean forever, boolean multiOrg) {
RecoverModeEnum recoverModeEnum = RecoverModeEnum.SHARED;
RecoverWayEnum recoverWayEnum = RecoverWayEnum.AMOUNT;
OutsourceCasePreconditionVO outsourceCasePreconditionVO = appOutsourceCaseHandleService.outsourceLoginInfo(
authorization, forever, recoverModeEnum, recoverWayEnum);
String bizNo = outsourceCasePreconditionVO.getOutsourceCase().getApplyBizNo();
String caseNo = outsourceCasePreconditionVO.getOutsourceCase().getCaseNo();
String outsourceAdminAuth = outsourceCasePreconditionVO.getOutsourceAdmin().getAuthorization();
// 机构案件认领
BindResult<Void> result = caseHandleApi.claim(outsourceAdminAuth, new ClaimRequestVO().setCaseNo(caseNo));
Assertion.isSuccess(result);
// 认领后的案件数据
OutsourceCase outsourceCaseClaim = outsourceCaseDataSourceBiz.getCaseByBizNo(bizNo);
// 数据验证
assertCaseClaim(outsourceCasePreconditionVO.getOutsourceCase(), outsourceCaseClaim,
outsourceCasePreconditionVO.getOrganizationResponseVO());
}
caseHandleApi.claim 才是该接口的调用,在此调用之前的 appOutsourceCaseHandleService.outsourceLoginInfo 其目的只是为了构造前置数据(而在这里面,其实也是不同接口调用/SQL 等的数据构造而已),assertCaseClaim 就是接口测试的验证了
通过上面的两个例子,应该可以说明我所说的了
我们的测试流程其本质就是一个接口一个接口的调用,从而推进流程。所以,你需要根据对应的业务场景,通过组合不同的接口调用来完成对应的流程,在对应过程中加上各个接口调用后相应数据的验证。这样的话,就实现了通过接口来开展业务流程的测试
问题 1:
1、当前 github 上的代码是根据两年前的接口测试的思路编写的 annotation,如果有需要可以自行添加对应的 annotation,我们当前项目中是添加了其他 annotation 的,对应的 annotation 里面的属性如果有特殊使用场景也可以在 HttpUtils 里面添加对应的属性作用
2、Junit 我用得不是很多,一般来说,可以通过制定测试类或者测试方法进行,你那边可以通过 Jenkins 传入对应参数来区分这些来达到这个目的
是的
或者说大家不应该把这种思路当做接口测试自动化的思路,而是以接口自动化的思路达到整体流程的自动化(我认为这才是我这样做的一个最终目的)
好久没有更新过这个项目了,大家看到的这个项目与我当前在公司使用的项目比较上已经升级了好多了。
1、你这边提到了测试结果显示清晰问题,我这边截图一个测试报告
2、我始终认为,使用代码维护的测试用例在保持最大灵活的前提下(可以写出任何场景的测试用例,数据构造足够方便并不依赖于测试平台是否提供对应功能),可以方便我们根据不同测试条件在代码层面提供对应的测试数据从而最大程度达到稳定性。举个例子,对于贷款业务,还款计划是一个变化的东西,在代码层面我可以根据不同的参数生成还款计划,而非在测试用例级别和测试平台级别两个地方分别处理
看接口参数要求,一般情况是 json 串传过去,服务端自己解析
我在 github 上更新了一下,你按照类似的方法试试呢
试一下这个
https://testerhome.com/topics/7060
类似下面的方式,具体的你要看 REST Assured 的具体配置方法
不好意思,没使用过。你可以找找,macaca 应该是提供了对应方法的
刚才添加了对可变 URL 的支持,github 上的代码可以直接使用
比如,你需要测试这样一个接口,其中 path 中{}是变化的,这个时候你需要在接口方法中对应的用来替换可变 path 的参数加上@PathVariable这个注解
@SERVER(GlobalVar.DOUBAN_MOVIE_SERVER)
public interface MovieApi {
@GET(path = " /celebrity/{}", description = "影人条目信息")
Response celebrity(@PathVariable String id);
}
public class TestMovieApi extends BaseRunner {
@Test
public void testCelebrity() throws Exception {
response = movieApi.celebrity("1031931");
}
}
原理是这样的,你带了@PathVariable这样的注解,我在 ProxyUtils 中会解析这个注解,然后将你传过来的参数用来替换 path 中的{}
public class ProxyUtils {
private static Logger logger = LoggerFactory.getLogger(ProxyUtils.class);
@SuppressWarnings("unchecked")
public static <T> T create(Class<T> clazz) {
// 获取接口上的HOST
Annotation annotation = clazz.getAnnotation(SERVER.class);
if (annotation == null) {
throw new RuntimeException(String.format("接口类%s未配置@SERVER注解",
clazz.getName()));
}
String host;
switch (clazz.getAnnotation(SERVER.class).value()){
case GlobalVar.DOUBAN_MOVIE_SERVER:
host = GlobalVar.DOUBAN_MOVIE_SERVER_URL;
break;
default:
throw new RuntimeException(String.format("未查找到接口类%s配置的@HOST(%s)注解中的%s接口服务器地址",
clazz.getName(),
clazz.getAnnotation(SERVER.class).value(),
clazz.getAnnotation(SERVER.class).value()));
}
HttpUtils httpUtils = new HttpUtils(host);
return (T) Proxy.newProxyInstance(clazz.getClassLoader(),
new Class[]{clazz},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 方法上的注释及对应的值
Annotation[] annotations = method.getAnnotations();
if (annotations.length == 0) {
throw new RuntimeException(String.format("%s方法未配置请求类型注解,如@POST、@GET等",
method.getName()));
}
HttpType httpType;
String path;
String description;
// 当前只需要解析一个注解
if (annotations[0] instanceof POST) {
httpType = HttpType.POST;
path = ((POST) annotations[0]).path();
description = ((POST) annotations[0]).description();
} else if (annotations[0] instanceof GET) {
httpType = HttpType.GET;
path = ((GET) annotations[0]).path();
description = ((GET) annotations[0]).description();
} else {
throw new RuntimeException(String.format("暂不支持%s方法配置的请求类型注解%s",
method.getName(),
annotations[0].annotationType()));
}
// 方法上参数对应的注解
Annotation[][] parameters = method.getParameterAnnotations();
Integer length = parameters.length;
TestStep testStep = new TestStep();
if (length != 0) {
Map<String, Object> map = new HashMap<>();
for (Integer i = 0; i < length; i++) {
// 参数注解类型
Annotation[] annos = parameters[i];
if (annos.length == 0) {
throw new RuntimeException(String.format("方法%s中缺少参数注解,如@Param",
method.getName()));
}
if (annos[0] instanceof Param) {
map.put((((Param) annos[0]).value()), args[i]);
} else if (annos[0] instanceof PathVariable) {
path = path.replaceFirst("\\{\\}", args[i].toString());
}
else {
throw new RuntimeException(String.format("暂不支持方法%s中配置的参数注解%s",
method.getName(),
annos[0].annotationType()));
}
}
testStep.setParams(map);
}
testStep.setType(httpType);
testStep.setPath(path);
logger.info("[" + path + "]" + description);
return httpUtils.request(testStep);
}
});
}
}
原来是模板原作者,谢谢提供模板!
我抽空抽离出来吧
发现我们用的是同一个测试报告模板呢
测试报告这里,是加了一个监听器里监听 JUnit 的测试结果,从而得到测试数据,下面的代码在我提供的 github 项目上都有(github 提供的代码是一个完整可用的项目举例):
1、继承 BaseRunner
public class TestMovieApi extends BaseRunner {}
2、BaseRunner 加注解@RunWith(ListenerRunner.class)
@RunWith(ListenerRunner.class)
public abstract class BaseRunner {
protected final Logger logger = LoggerFactory.getLogger(getClass());
protected Response response;
@BeforeClass
public static void beforeClass() {
}
@AfterClass
public static void afterClass() {
}
// 失败重试
@Rule
public RetryUtils retryUtils = new RetryUtils(GlobalVar.RETRY_COUNTS);
}
3、ListenerRunner 继承 JUnit 自己的 Runner
public class ListenerRunner extends BlockJUnit4ClassRunner {
public ListenerRunner(Class<?> cls) throws InitializationError {
super(cls);
}
@Override
public void run(RunNotifier notifier){
notifier.removeListener(GlobalVar.listenerUtils);
notifier.addListener(GlobalVar.listenerUtils);
notifier.fireTestRunStarted(getDescription());
super.run(notifier);
}
}
4、ListenerUtils 负责装载测试结果生成测试报告
public class ListenerUtils extends RunListener {
private Logger logger = LoggerFactory.getLogger(getClass());
private List<String> throwableLog;
private TestResult testResult;
public TestCaseResult testCaseResult;
private List<TestCaseResult> testCaseResultList;
private static String testBeginTime;
private static long testCaseBeginTime;
// 测试多个类的时候,计第一个测试类开始测试的时间
private static Long testClassCount = 0L;
private static Long testBeginTimeMills;
public ListenerUtils() {
testResult = new TestResult();
testCaseResultList = new ArrayList<>();
}
public void testRunStarted(Description description) throws Exception {
logger.info("--> {}", description.getClassName());
// 第一个测试类开始测试的时间
if(++testClassCount == 1){
testBeginTime = new Date().toString();
testBeginTimeMills = System.currentTimeMillis();
}
}
public void testRunFinished(Result result) throws Exception {
Long totalTimes = System.currentTimeMillis() - testBeginTimeMills;
logger.info("执行结果 : {}", result.wasSuccessful());
logger.info("执行时间 : {}", totalTimes);
logger.info("用例总数 : {}", (result.getRunCount() + result.getIgnoreCount()));
logger.info("失败数量 : {}", result.getFailureCount());
logger.info("忽略数量 : {}", result.getIgnoreCount());
testResult.setTestName("接口自动化测试");
testResult.setTestAll(result.getRunCount() + result.getIgnoreCount());
testResult.setTestPass(result.getRunCount() - result.getFailureCount());
testResult.setTestFail(result.getFailureCount());
testResult.setTestSkip(result.getIgnoreCount());
testResult.setTestResult(testCaseResultList);
testResult.setBeginTime(testBeginTime);
testResult.setTotalTime(totalTimes + "ms");
createReport();
}
public void testStarted(Description description) throws Exception {
logger.info("----> {}.{}", description.getClassName(), description.getMethodName());
testCaseBeginTime = System.currentTimeMillis();
testCaseResult = new TestCaseResult();
}
public void testFinished(Description description) throws Exception {
logger.info("<---- {}.{}", description.getClassName(), description.getMethodName());
if (testCaseResult.getThrowableLog().isEmpty()) {
testCaseResult.setStatus("成功");
}
testCaseResult.setClassName(description.getClassName());
testCaseResult.setMethodName(description.getMethodName());
testCaseResult.setSpendTime(System.currentTimeMillis() - testCaseBeginTime + "ms");
testCaseResultList.add(testCaseResult);
}
public void testFailure(Failure failure) throws Exception {
logger.info("Execution of test case failed : " + failure.getMessage());
testCaseResult.setStatus("失败");
throwableLog = new ArrayList<>();
Throwable throwable = failure.getException();
if (throwable != null) {
throwableLog.add(throwable.toString());
StackTraceElement[] st = throwable.getStackTrace();
for (StackTraceElement stackTraceElement : st) {
throwableLog.add("      " + stackTraceElement);
}
}
testCaseResult.setThrowableLog(throwableLog);
}
public void testIgnored(Description description) throws Exception {
}
private void createReport() {
String filePath = null;
URL fileURL = this.getClass().getClassLoader().getResource("template.html");
if (fileURL != null) {
filePath = fileURL.getPath();
}
String template = FileUtils.read(filePath);
String jsonString = JSON.toJSONString(testResult);
template = template.replace("${resultData}", jsonString);
FileUtils.write(template, GlobalVar.REPORT_PATH + "result.html");
}
}
测试报告是使用的一个模板,测试完成后测试结果数据会写进这个模板,githug 上的代码生成的测试报告在\target\reports\result.html
public class ListenerUtils extends RunListener {
private void creadReport() {
String filePath = null;
URL fileURL = this.getClass().getClassLoader().getResource("template");
if (fileURL != null) {
filePath = fileURL.getPath();
}
String template = FileUtils.read(filePath);
String jsonString = JSON.toJSONString(testResult);
template = template.replace("${resultData}", jsonString);
FileUtils.write(template, GlobalVar.REPORT_PATH + "result.html");
}
}
关于接口数据这里,因为我们硬编码的数据通常是账号,而其他数据都是在编写测试点的时候通过数据库、缓存构造/查询的,所以需要硬编码的东西不多。我的观点是,通过代码层面来编写测试点更加灵活可靠。所以我们的思路是在接口端的接口编写尽量简单,而测试点编写就需要测试人员好好根据自身实际需要来写(我们提供数据库、缓存的读写操作就是为了在接口数据上不用写死,而是根据实际情况获取对应值),说到底编写测试用例也是一种编码工作。
数据库和缓存的读写操作
我们的接口自动化是使用的 Rest-Assured,跟服务端接口的交互方法是根据接口定义好的,这个层面的东西是很少去改动的,如下为登录接口的封装:
/**
* @Description: 登录
*/
public Response login(String mobile, String password, String mac){
TestStep ts = new TestStep();
Map<String, Object> params = new HashMap<>();
Map<String, Object> sign = new HashMap<>();
params.put("mobile", mobile);
params.put("password", password);
params.put("mac", mac);
sign.put("sign", OldJHJUtils.getJWTSigner(params));
ts.setType(GlobalVar.HttpType.POST);
ts.setPath(Path.LOGIN.getValue());
ts.setParams(sign);
logger.info(Path.LOGIN.toString());
return hu.sendHttpRequest(ts);
}
而具体到某个接口的测试用例上,我们没有使用大家普遍讨论的数据驱动什么的,平时怎么测试就怎么在测试用例中写测试点罢了。所以,为了增加自动化的数据稳定性,我们加了数据库和缓存的读写操作,这样对于测试数据的维护就不需要太多,如下为两个登录接口的测试用例(测试用例中包含多个测试点):
/**
* @Description: 登录接口测试
*/
@Test
public void testLogin() throws Exception {
MemberPo memberPo = memberPoMapper.selectMemberByMobile(mobile);
// 登录成功
res = oldJHJUser.login(mobile, memberPo.getLoginPasswd(), mac);
// 验证登录密码是否设置,密码为空则未设置密码
// 验证用户是否禁用,getIsDisable()==1则用户被禁用
res.then().body(
"data.mobile", equalTo(mobile),
"data.existPassord", equalTo(!memberPo.getLoginPasswd().isEmpty()),
"data.isDisable", equalTo(memberPo.getIsDisable()==1));
// 登录失败-密码错误
res = oldJHJUser.login(mobile, memberPo.getLoginPasswd()+"123", mac);
res.then().body("msg", equalTo("密码错误"));
// 登录失败-未注册用户登录
res = oldJHJUser.login(noRegisterMobile, "password", mac);
res.then().body("msg", equalTo("用户不存在"));
}
/**
* @Description: 验证码登录接口测试
*/
@Test
public void testLoginByCode() throws Exception {
MemberPo memberPo = memberPoMapper.selectMemberByMobile(mobile);
// 向Redis中插入验证码
jhjRedisService.setSecurityCode(SmsModeEnum.LOGIN_VERIFY_CODE.getValue(), mobile);
String code = jhjRedisService.getSecurityCode(SmsModeEnum.LOGIN_VERIFY_CODE.getValue(), mobile);
// 用户未注册
res = oldJHJUser.loginByCode(noRegisterMobile, code, mac);
res.then().body("msg", equalTo("用户不存在"));
// 验证码错误
res = oldJHJUser.loginByCode(mobile, "000000", mac);
res.then().body("msg", equalTo("短信验证码不正确"));
// 验证码正确
res = oldJHJUser.loginByCode(mobile, code, mac);
// 验证登录密码是否设置,密码为空则未设置密码
// 验证用户是否禁用,getIsDisable()==1则用户被禁用
res.then().body(
"data.mobile", equalTo(mobile),
"data.existPassord", equalTo(!memberPo.getLoginPasswd().isEmpty()),
"data.isDisable", equalTo(memberPo.getIsDisable()==1));
// 验证码过期
jhjRedisService.deleteSecurityCode(SmsModeEnum.LOGIN_VERIFY_CODE.getValue(), mobile);
res = oldJHJUser.loginByCode(mobile, code, mac);
res.then().body("msg", equalTo("短信验证码已过期"));
}