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

王华林 · 2018年01月21日 · 最后由 王华林 回复于 2022年08月10日 · 7796 次阅读

这篇文章简单介绍我们做接口自动化测试的一个思路(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("短信验证码已过期"));
    }
}
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 53 条回复 时间 点赞

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

cooling 回复

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

https://testerhome.com/topics/7060

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

仅楼主可见


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

shuman 回复

试一下这个

仅楼主可见
shuman 回复

仅楼主可见
shell 回复

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

仅楼主可见

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

donly 回复

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

simple 专栏文章:[精华帖] 社区历年精华帖分类归总 中提及了此贴 12月13日 14:44
simple [精彩盘点] TesterHome 社区 2018 年 度精华帖 中提及了此贴 01月07日 12:08

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

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

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

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

sun 回复

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

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

sun 回复

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

仅楼主可见
33楼 已删除

rest assured 支持 dubbo 接口吗?

仅楼主可见
xcxcxxcxcxxc 回复

是的

仅楼主可见
Gaby-blue 回复

问题 1:
1、当前 github 上的代码是根据两年前的接口测试的思路编写的 annotation,如果有需要可以自行添加对应的 annotation,我们当前项目中是添加了其他 annotation 的,对应的 annotation 里面的属性如果有特殊使用场景也可以在 HttpUtils 里面添加对应的属性作用
2、Junit 我用得不是很多,一般来说,可以通过制定测试类或者测试方法进行,你那边可以通过 Jenkins 传入对应参数来区分这些来达到这个目的

我想知道基于流程的测试,为啥我看到的代码是请求接口和返回断言的是分开写的,不能放在一个页面上作为一个完整的用例吗?

tangcheng1 回复

我们的测试流程其本质就是一个接口一个接口的调用,从而推进流程。所以,你需要根据对应的业务场景,通过组合不同的接口调用来完成对应的流程,在对应过程中加上各个接口调用后相应数据的验证。这样的话,就实现了通过接口来开展业务流程的测试

tangcheng1 回复

一、展示一个流程数据构造的例子:
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 就是接口测试的验证了

通过上面的两个例子,应该可以说明我所说的了

仅楼主可见
balance 回复

其实,思路才是测试过程最重要的。虽然这个文档/demo 是三年前的的,但其实我们当前也只是在这个上面根据业务内容的调整不断优化而已。相关的功能点都在上面的回帖中提及到了的。置于新的 demo,我这边目前没有太多时间来将新的实践整理出 demo 来,抱歉!

请教一下,评论中关于参数化相关的你这边是怎么实现的呢 @hualin

波小艺 回复

不好意思,没太明白你这边的问题点。数据驱动?

王华林 回复

是的,数据驱动。

仅楼主可见
波小艺 回复

@CsvSource是 junit5 里面的一个注解,是以 csv 格式提供数据的,你可以很明显的看到一组测试数据里的逗号分隔。junit5 里面还有其他的数据提供/参数化的注解,可以有空了解一下。整体上和 testNG 里面的@DataProvider大差不差。

仅楼主可见
波小艺 回复

5.0 相对 4.0 做了很大的改动,应该不支持你那种(你可以了解看看有没有兼容方案)。在 4.0 上也是可以做数据驱动的,没有 junit5 好用,你可以参考一下这种,http://www.51testing.com/html/44/316844-3483525.html


仅楼主可见
仅楼主可见
波小艺 回复

我们当前用的是 TestNG,只是之前某些测试用例由于某些无法抗拒的原因用的 junit5。目前用的 allure,使用监听的方式生成测试报告没详细了解过,网上应该有很多的 demo 的,可以去了解一下。

仅楼主可见
56楼 已删除
仅楼主可见
南修 回复

Junit 与 TestNG 并无本质性差别,都是测试基础框架

空空 大家的自动化目录结构,接口是怎么分的呢 中提及了此贴 03月01日 10:55
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册