接口测试 一种使用 Rest-Assured 框架做接口自动化测试的思路

王华林 · January 21, 2018 · Last by Suffer replied at February 18, 2019 · 6464 hits

这篇文章简单介绍我们做接口自动化测试的一个思路(GitHub地址: https://github.com/Hualiner/Api-Auto-Test

我们的接口自动化测试工程结构如下图所示(从我们的项目中抽离出最基础的部分,以豆瓣电影举例),main文件下面放置的是公共类(commom)、接口类(如douban),test文件下面方式的测试类(如douban),通过这样的工程接口,开展接口自动化测试需要三步:编写接口、编写接口测试用例、执行测试用例。

1、编写接口
基于这样的工程结构,我们在测试某个服务某个接口类的时候,接口部分的编写如下(如MovieApi):

@SERVER(GlobalVar.DOUBAN_MOVIE_SERVER)
public interface MovieApi {

/**
* @param start
*/

@GET(path = "/top250", description = "豆瓣电影 top 250")
Response top250(@Param("start") Integer start,
@Param("count") Integer count);
}

2、编写测试用例
在完成接口类的编写后,就开始编写我们的某个接口类(如TestMovieApi)的接口测试用例(我的思路是在某个接口对应的测试方法里根据需要的测试数据构造多个测试点,下面代码随便写了两个点就当是该接口的测试点):

public class TestMovieApi extends BaseRunner {

private MovieApi movieApi = ProxyUtils.create(MovieApi.class);

@Before
public void before() throws Exception {
}

@After
public void after() throws Exception {
}

/**
*
*/

@Test
public void testTop250() throws Exception {
response = movieApi.top250(0, 1);
response.then().body("subjects[0].title", equalTo("肖申克的救赎"));

response = movieApi.top250(0, 2);
response.then().body("subjects[1].title", equalTo("霸王别姬"));
}
}

3、执行测试用例
到这里我们的接口编写、接口测试用例的编写就结束了,接下来就是执行测试用例,我们在jenkins上执行的时候是使用诸如mvn test -Dtest=TestMovie*等命令来执行的,执行完后生成如下图的测试报告:

接下来简单介绍一下原理:
1、注解
在我们编写接口的时候,为了简化接口部分的编写工作,我们在该处使用了java的注解功能,如下代码,@SERVER指定该接口类的HOST,@GET/@POST则标注每个接口的后续的URL,@Param则标注每个接口的参数

@SERVER(GlobalVar.DOUBAN_MOVIE_SERVER)
public interface MovieApi {

/**
* @param start
*/

@GET(path = "/top250", description = "豆瓣电影 top 250")
Response top250(@Param("start") Integer start,
@Param("count") Integer count);
}

2、动态代理
基于上述的注解下,若要使用该接口类下的接口,则需要为该接口类创建一个动态代理,有了该接口类的动态代理,调用接口就可以进行下去:

private MovieApi movieApi = ProxyUtils.create(MovieApi.class);

Response response = movieApi.top250(0, 1);

而动态代理的类实现如下,某接口类的接口方法都是从这个代理类中出去的,也就是说在这里我们就可以对上面编写接口类、接口方法、接口参数使用到的注解进行解释:

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 {
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);
}
});
}
}

3、http请求
如上面的动态代理类中的HttpUtils类,该类中封装了Rest-Assured,到了这一步我就不多说,大家可以参考Rest-Assured相关资料

4、测试报告
由于我们每个接口测试类都继承了BaseRunner(包括失败重试),而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);
}

在ListenerRunner 中添加了我们自己的监听器(用于获取测试报告数据),继承了JUnit自己的Runner(BlockJUnit4ClassRunner )

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);
}
}

到这里我们接口自动化测试项目里面最基础的东西就介绍完了,至于为什么采用这样的方式来做接口自动化测试,而不是采用大家普遍采用的Excel、后台编写测试用例的形式,是以因为我们的接口自动化测试项目中引入了对数据库、缓存的读写操作,这样一来在数据层面来说就更加稳定可靠,下面简单说这一块儿(由于这部分的配置都是数据库的用户数据,不能公开配置部分,这里列出和公开部分的不同的地方)
1、下图是我们项目里面对数据库的配置

2、BaseRunner、ListenerRunner的差异
BaseRunner 添加了@ContextConfiguration注解来加载上面的数据库、缓存配置文件

@RunWith(ListenerRunner.class)
@ContextConfiguration({"classpath:spring/applicationContext*.xml"})
public abstract class BaseRunner {

protected final Logger logger = LoggerFactory.getLogger(getClass());
protected Response res;

@BeforeClass
public static void beforeClass() {
PropertyUtils pu = new PropertyUtils(GlobalVar.CONFIG_NAME);
pu.initConfig();
}

@AfterClass
public static void afterClass() {
}

// 失败重试
@Rule
public RetryUtils ru = new RetryUtils(GlobalVar.RETRY_COUNTS);
}

ListenerRunner 继承自Spring Test的Runner(而Spring的Runner继承于JUnit自己的Runner BlockJUnit4ClassRunner )

public class ListenerRunner extends SpringJUnit4ClassRunner {

public JHJRunner(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);
}
}

3、在编写测试用例上的差异
在很多测试数据我们依赖于对数据库、缓存,从而在构造测试数据、验证结果时更加稳定

public class TestOldJHJUser extends BaseRunner {

private OldJHJUser oldJHJUser = new OldJHJUser();

private String mobile = GlobalVar.OLD_JHJ_TEST_MOBILE;
private String password = GlobalVar.OLD_JHJ_TEST_PASSWORD;
private String noRegisterMobile = GlobalVar.NO_REGISTER_MOBILE;
private String mac = GlobalVar.OLD_JHJ_TEST_MAC;

@Autowired
private SmsPoMapper smsPoMapper;

@Autowired
private MemberPoMapper memberPoMapper;

@Autowired
private JHJRedisService jhjRedisService;

@Before
public void before() throws Exception {
}

@After
public void after() throws Exception {
}

/**
* @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("短信验证码已过期"));
}
}
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 30 条回复 时间 点赞

大神,带我们飞,你这测试报告用的什么插件?还有就是接口数据这样硬编码写进去?下版本能否用yaml 或者mysql 提取测试接口数据?

bill 回复

测试报告是使用的一个模板,测试完成后测试结果数据会写进这个模板,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");
}
}

关于接口数据这里,因为我们硬编码的数据通常是账号,而其他数据都是在编写测试点的时候通过数据库、缓存构造/查询的,所以需要硬编码的东西不多。我的观点是,通过代码层面来编写测试点更加灵活可靠。所以我们的思路是在接口端的接口编写尽量简单,而测试点编写就需要测试人员好好根据自身实际需要来写(我们提供数据库、缓存的读写操作就是为了在接口数据上不用写死,而是根据实际情况获取对应值),说到底编写测试用例也是一种编码工作。

王华林 回复

大神思路不错,用到了JDK动态代理与注解。另外,大神,能否提供一下JUNIT对那个测试报告的支持的代码?我在ztest上加友链!非常感谢!

再见理想 回复

发现我们用的是同一个测试报告模板呢

测试报告这里,是加了一个监听器里监听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("&nbsp&nbsp&nbsp&nbsp&nbsp&nbsp" + 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");
}
}
王华林 回复

这个测试报告模板的作者是我哦,现在有TESTNG版的,有PYTHON版的,差个JUNIT版的,你如果单独整理好了,可以丢在GITHUB上,然后我加上友链!https://github.com/zhangfei19841004/ztest

再见理想 回复

原来是模板原作者,谢谢提供模板!

我抽空抽离出来吧

想请教一下豆瓣上 /v2/movie/celebrity/:id/photos 这种类型的请求支持吗?

kell 回复

刚才添加了对可变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);
}
});
}
}
王华林 回复

@hualin 这速度,好感谢😀

TestOldJHJUser 楼主,这个测试用例的代码,可以上传吗?想看下

shell · #12 · May 04, 2018
Author only
王华林 #13 · May 07, 2018 作者
shell 回复

https://testerhome.com/topics/7060

类似下面的方式,具体的你要看REST Assured的具体配置方法

shell · #14 · May 09, 2018
Author only


写了指定application/json,为什么还是按照application/x-www-form-urlencoded;charset=ISO-8859-1,不知道什么地方不对?

王华林 #16 · May 12, 2018 作者
shuman 回复

试一下这个

shuman · #17 · May 13, 2018
Author only
王华林 #18 · May 13, 2018 作者
shuman 回复

shell · #19 · May 26, 2018
Author only
王华林 #20 · May 26, 2018 作者
shell 回复

我在github上更新了一下,你按照类似的方法试试呢

shell · #21 · May 30, 2018
Author only

初xue rest-assured, 请教各位前辈,post 的数据,如果是json数据,应该怎么操作呢?我在官网上看到用map的.但是如果是复杂的json,嵌套好几层的那种,应该怎么处理呢?

donly 回复

看接口参数要求,一般情况是json串传过去,服务端自己解析

simple 专栏文章:[精华帖] 社区历年精华帖分类归总 中提及了此贴 13 Dec 14:44

这里下载下来的工程跑不起来啊,报错.

用idea打开就没问题了,谢谢楼主

跑完了,感觉跟自己实际项目不太合适.
自己的项目还是用Excel编写测试用例,用Python来跑接口好些.
用例的维护,测试结果都显示清晰,不比这个差.

另外, 关于用rest-assured 的好处文中提到是测试数据稳定, 我就纳闷了 ,我用Excel保存数据,怎么就不稳定了,还请大神赐教.

sun 回复

好久没有更新过这个项目了,大家看到的这个项目与我当前在公司使用的项目比较上已经升级了好多了。
1、你这边提到了测试结果显示清晰问题,我这边截图一个测试报告

2、我始终认为,使用代码维护的测试用例在保持最大灵活的前提下(可以写出任何场景的测试用例,数据构造足够方便并不依赖于测试平台是否提供对应功能),可以方便我们根据不同测试条件在代码层面提供对应的测试数据从而最大程度达到稳定性。举个例子,对于贷款业务,还款计划是一个变化的东西,在代码层面我可以根据不同的参数生成还款计划,而非在测试用例级别和测试平台级别两个地方分别处理

sun 回复

或者说大家不应该把这种思路当做接口测试自动化的思路,而是以接口自动化的思路达到整体流程的自动化(我认为这才是我这样做的一个最终目的)

Author only
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up