java基础 - 实现一个简单的Http接口功能自动化测试框架(HttpClient + TestNG)

已知现在已经用Spring boot框架搭建了一个简单的web服务,并且有现成的Controller来处理http请求,以之前搭建的图书管理服务为例,BookController的源码如下:

java 复制代码
package org.example.controller;

import org.example.domain.Book;
import org.example.service.BookService;
import org.example.vo.ResultVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping(value = "/books")
public class BookController {

    @Autowired
    private BookService bookService;

    /**
     * 查询所有图书
     *
     * @return
     */
    @GetMapping(value = "/getAll")
    public ResultVo getAll() {
        List<Book> books = bookService.getAllBooks();
        return new ResultVo().success().data(books);
    }

    /**
     * 更新单个图书信息
     *
     * @param book
     * @return
     */
    @PostMapping(value = "/update")
    public ResultVo updateBookMessage(@RequestBody Book book) {
        Boolean result = bookService.updateBookMessage(book);
        ResultVo resultVo = new ResultVo().data(result);
        return result ? resultVo.success() : resultVo.fail();
    }
}

在搭建一个Http接口功能自动化测试框架之前,我们需要思考几个问题:

1、http请求的发送,使用什么实现?

2、接口返回的json数据,用什么校验?

3、测试数据如何统一管理(数据驱动)?比如测试url、post请求需要的请求体、接口预期的返回结果等。

4、失败用例是否支持重试?

1、http请求工具类

目前网上能查到的比较主流的技术方案包括: java.net包中原生的 HttpURLConnection类、apache名下的http组件 HttpClient,以及Spring框架的 RestTemplate。相比之下,HttpURLConnection使用较为复杂,RestTemplate又不熟,就先选择HttpClient。

首先还是要引入HttpClient依赖(slf4j打log用):

xml 复制代码
        <dependency>
            <groupId>org.apache.httpcomponents.client5</groupId>
            <artifactId>httpclient5</artifactId>
            <version>5.1</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.25</version>
        </dependency>

工具类源码如下,暂且先满足get 和 post两类请求的发送:

java 复制代码
package org.example.utils;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.ParseException;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class HttpUtil {

    public static JSONObject doGet(String url) {

        CloseableHttpClient httpClient = HttpClients.createDefault();
        HttpGet httpGet = new HttpGet(url);

        try {
            CloseableHttpResponse response = httpClient.execute(httpGet);
            HttpEntity entity = response.getEntity();
            String responseText = EntityUtils.toString(entity);
            JSONObject responseJson = JSON.parseObject(responseText);
            response.close();
            httpClient.close();
            return responseJson;
        } catch (IOException | ParseException e) {
            throw new RuntimeException(e);
        }
    }

    public static JSONObject doPost(String url, JSONObject requestBody) {

        CloseableHttpClient httpClient = HttpClients.createDefault();
        HttpPost httpPost = new HttpPost(url);

        StringEntity requestEntity = new StringEntity(requestBody.toString(), StandardCharsets.UTF_8);
        httpPost.setEntity(requestEntity);
        httpPost.setHeader("Content-Type", "application/json");

        try {
            CloseableHttpResponse response = httpClient.execute(httpPost);
            HttpEntity httpEntity = response.getEntity();
            String responseText = EntityUtils.toString(httpEntity);
            JSONObject responseJson = JSON.parseObject(responseText);
            response.close();
            httpClient.close();
            return responseJson;
        } catch (IOException | ParseException e) {
            throw new RuntimeException(e);
        }
    }
}

2、接口返回校验

这里主要是校验预期的返回体和实际返回体是否完全一致,也就是两个json数据的解析和比对。

json数据的解析工具很多,比如Google的Gson,阿里的fastjson,以及jackson等,这里随便选一个,使用fastjson;

json的比对,据调研有一个开源的org.skyscreamer名下的 jsonassert工具,不过还没有试用。这里自己写一个json diff的工具类,仅判断两份json数据是否相同,暂不考虑性能和其他功能拓展。

首先引入fastjson依赖:

xml 复制代码
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.32</version>
        </dependency>

json diff 工具类源码如下:

java 复制代码
package org.example.utils;

import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Set;

public class JsonDiffUtil {

    private static final Logger LOG = LoggerFactory.getLogger(JsonDiffUtil.class);

    public static boolean compareJson(JSONObject expect, JSONObject actual) {
        return compareJson(expect, actual, true, false);
    }

    //sorted参数用于判断数组中json object的顺序是否一致
    public static boolean compareJson(JSONObject expect, JSONObject actual, boolean sorted) {
        return compareJson(expect, actual, true, sorted);
    }

    //reportError参数:遍历json数组时,不匹配的json对象不需要打印error日志,故使用此参数控制
    private static boolean compareJson(JSONObject expect, JSONObject actual, boolean reportError, boolean sorted) {
        if (expect == null || actual == null) {
            throw new RuntimeException("预期结果或返回结果不能为空");
        }
        if (expect.toString().equals(actual.toString())) {
            return true;
        }
        Set<String> keySet = expect.keySet();
        for (String key : keySet) {
            if (!actual.containsKey(key)) {
                if (reportError) {
                    LOG.error("key: {}不存在", key);
                    LOG.info("完整返回数据: {}", actual);
                }
                return false;
            }
            Object o1 = expect.get(key);
            Object o2 = actual.get(key);
            if (!isSameType(o1, o2)) {
                if (reportError) {
                    LOG.error("类型不匹配, key: {}, 预期值: {}, 实际值: {}", key, o1.getClass().getTypeName(), o2.getClass().getTypeName());
                    LOG.info("完整返回数据: {}", actual);
                }
                return false;
            }
            if (o1 instanceof JSONArray) {
                JSONArray ja1 = (JSONArray) o1;
                JSONArray ja2 = (JSONArray) o2;
                if (ja1.size() != ja2.size()) {
                    if (reportError) {
                        LOG.error("json数组长度不匹配, key: {}, 预期值: {}, 实际值: {}", key, o1, o2);
                        LOG.info("完整返回数据: {}", actual);
                    }
                    return false;
                }
                for (int i = 0; i < ja1.size(); i++) {
                    JSONObject jo1 = ja1.getJSONObject(i);
                    for (int j = 0; j < ja2.size(); j++) {
                        JSONObject jo2 = ja2.getJSONObject(j);
                        if (compareJson(jo1, jo2, false, sorted)) {
                            break;
                        }
                        if (j == ja2.size() - 1) {
                            LOG.error("key: {}, json数组中找不到预期的json对象{}", key, jo1);
                            LOG.info("完整返回数据: {}", actual);
                            return false;
                        }
                    }
                }
                if (sorted && !ja1.toString().equals(ja2.toString())) {
                    LOG.error("key: {}, json数组中对象顺序不一致, 预期值: {}, 实际值: {}", key, ja1, ja2);
                    LOG.info("完整返回数据: {}", actual);
                    return false;
                }
            } else if (o1 instanceof JSONObject) {
                if (!compareJson((JSONObject) o1, (JSONObject) o2, true, sorted)) {
                    return false;
                }
            } else {
                String s1 = String.valueOf(o1);
                String s2 = String.valueOf(o2);
                if (!s1.equals(s2)) {
                    if (reportError) {
                        LOG.error("key: {}, 预期值: {}, 实际值: {}", key, o1, o2);
                        LOG.info("完整返回数据: {}", actual);
                    }
                    return false;
                }
            }
        }
        return true;
    }

    private static boolean isSameType(Object o1, Object o2) {
        if (o1 == null && o2 == null) {
            return true;
        }
        if (o1 == null || o2 == null) {
            return false;
        }
        return o1.getClass().getTypeName().equals(o2.getClass().getTypeName());
    }
}

3、数据驱动

数据驱动是一个概念,可以简单理解为测试数据和测试用例的分离,也就是分开管理,然后数据通过参数的形式注入到测试用例中。

通常我们可以使用testNG框架的@DataProvider注解来实现这一功能。

首先导入testNG依赖(commons-io,用来处理IO的工具类):

xml 复制代码
        <dependency>
            <groupId>org.testng</groupId>
            <artifactId>testng</artifactId>
            <version>6.11</version>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.8.0</version>
        </dependency>

DataProvider工具类的源码如下:

它的作用是根据当前需要提供数据的Method,找到相关的测试数据(json文件),将文件内容解析为方法参数,传递给测试case。

java 复制代码
package org.example.utils;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.apache.commons.io.FileUtils;
import org.testng.annotations.DataProvider;

import java.io.*;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;

public class DataProviderUtil {

    @DataProvider(name = "dataProvider")
    public static Object[] provide(Method method) throws IOException {

        String className = method.getDeclaringClass().getSimpleName();
        String methodName = method.getName();
        String fileName = "src/main/java/org/example/data/" + className + ".json";

        File file = new File(fileName);
        String data = FileUtils.readFileToString(file, StandardCharsets.UTF_8);

        if (!JSON.isValid(data)) {
            throw new RuntimeException("json格式校验失败, 文件名: " + fileName);
        }
        JSONArray jsonArray = JSON.parseArray(data);
        List<Object> list = jsonArray.stream().filter(obj -> methodName.equals(((JSONObject) obj).getString("caseName"))).collect(Collectors.toList());
        JSONObject jsonObject = (JSONObject) list.get(0);

        String url = jsonObject.getString("url");
        JSONObject requestBody = jsonObject.getJSONObject("requestBody");
        JSONObject expectResponse = jsonObject.getJSONObject("expectResponse");

        if (url == null) {
            throw new RuntimeException("参数 url 不能为空, 用例: " + className + "." + methodName);
        }
        if (expectResponse == null) {
            throw new RuntimeException("参数 expectResponse 不能为空, 用例: " + className + "." + methodName);
        }

        return new Object[][]{{url, requestBody, expectResponse}};
    }
}

然后测试用例方法的@Test注解中需要指定该类作为数据提供者,比如:

@Test(dataProvider = "dataProvider", dataProviderClass = DataProviderUtil.class)

小结:

解决了以上三个核心问题之后,实际上我们已经可以轻松的编写测试用例了,比如要测试BookController相关的http接口,建立一个BookControllerTest作为测试类,里面为每个接口设计一个case,源码如下:

java 复制代码
package org.example.testcase;

import com.alibaba.fastjson2.JSONObject;
import org.example.utils.DataProviderUtil;
import org.example.utils.HttpUtil;
import org.example.utils.JsonDiffUtil;
import org.testng.Assert;
import org.testng.annotations.Test;

public class BookControllerTest {

    @Test(dataProvider = "dataProvider", dataProviderClass = DataProviderUtil.class)
    public void getAllTest(String url, JSONObject requestBody, JSONObject expectResponse) {
        JSONObject response = HttpUtil.doGet(url);
        Assert.assertTrue(JsonDiffUtil.compareJson(expectResponse, response));
    }

    @Test(dataProvider = "dataProvider", dataProviderClass = DataProviderUtil.class)
    public void updateTest(String url, JSONObject requestBody, JSONObject expectResponse) {
        JSONObject response = HttpUtil.doPost(url, requestBody);
        Assert.assertTrue(JsonDiffUtil.compareJson(expectResponse, response));
    }
}

4、testNG对失败重试的支持

失败用例的重试,需要定义两个类,分别需要实现 IAnnotationTransformer监听器接口,来指定重试处理类;以及 IRetryAnalyzer接口,来制定重试规则。源码如下:

java 复制代码
package org.example.listener;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testng.*;
import org.testng.annotations.ITestAnnotation;

import java.lang.reflect.Constructor;
import java.lang.reflect.Method;

public class RetryListener implements IAnnotationTransformer {

    private static final Logger LOG = LoggerFactory.getLogger(RetryListener.class);

    @Override
    public void transform(ITestAnnotation annotation, Class testClass, Constructor testConstructor, Method testMethod) {
        IRetryAnalyzer analyzer = annotation.getRetryAnalyzer();
        if (analyzer == null) {
            annotation.setRetryAnalyzer(Retry.class);
        }
    }

    public static class Retry implements IRetryAnalyzer {

        private int currentRetryCount = 0;

        @Override
        public boolean retry(ITestResult result) {
            //重试2次
            if (currentRetryCount < 2) {
                currentRetryCount++;
                LOG.info("准备第" + currentRetryCount + "次重试");
                return true;
            }
            return false;
        }
    }

}

然后需要在制定测试规则的 testng.xml文件中,使用 listener 标签添加该监听器。

testng.xml文件如下:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">

<suite name="Default Suite">
    <test name="Default Test">
        <classes>
            <class name="org.example.testcase.BookControllerTest">
                <methods>
                    <include name="getAllTest"/>
                    <include name="updateTest"/>
                </methods>
            </class>
        </classes>
    </test>
    <listeners>
        <listener class-name="org.example.listener.RetryListener"/>
    </listeners>
</suite>

到这里,重试功能就添加完毕了。运行效果如下,最后一次重试失败,则最终定性为失败,之前的按 ignored处理。

最后,整个项目的结构图:

相关推荐
m0_571957581 小时前
Java | Leetcode Java题解之第543题二叉树的直径
java·leetcode·题解
魔道不误砍柴功3 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2343 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨3 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
_.Switch4 小时前
高级Python自动化运维:容器安全与网络策略的深度解析
运维·网络·python·安全·自动化·devops
测开小菜鸟5 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity6 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天6 小时前
java的threadlocal为何内存泄漏
java
caridle6 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express
^velpro^6 小时前
数据库连接池的创建
java·开发语言·数据库