API 自动化的快速实施与落地

内容简介:通过一个简单的实际案例,介绍如何使用PnxTest快速的实施API自动化测试,以及API自动化过程中一些常见的问题处理方法

使用框架:开源的PnxTest测试框架

阅读对象: API自动化测试人员、测试管理人员

阅读时长: 约5分钟

理解需求

在正式开始测试之前,深刻理解我们的测试目标、具体的技术实现方案是必须的,有助于我们设计出覆盖全面的测试用例。通常有以下几种途径:

  • 产品设计文档

  • API接口设计文档

  • 时序图

所有的接口必须有签名以及token,否则验签与token权限不通过,请求将失败。认证使用jwt token, Bearer方式;签名规则如下:

/* API请求签名
 * ===============
 * 签名原文规则:
 * ===============
 * URI(去域名(baseUrl),去左右空格,全小写) +
 * QueryString(key&value, 按key升序,去左右空格,全小写) +
 * Header(key&value,只取[device-id,timestamp,token]升序,去左右空格,全小写) +
 * Body raw(去左右空格,全小写)
 * 或者 form-data(key&value,按key升序,去左右空格,全小写)
 * 或者 x-www-form-urlencoded(key&value,按key升序,去左右空格,全小写)
 * ===============
 * 签名算法:
 * ===============
 * 根据自己公司的接口签名算法来进行加密处理(这里的例子我使用简单的base64来演示,没有私用私钥)
 *
 */

用例设计

在正式开始编码之前,先进行用例设计并输出测试设计文档(test specification)。从测试管理和流程上看,这将是一个非常重要的环节:1)测试设计文档作为输出物,体现了测试人员/团队的产出与智慧。作为具体自动化代码的指导,Review(用例评审)通过后才能进行具体的代码实施。

2)测试设计文档在团队人员变动、智慧传承方面也会起到显著的作用。

test-specification-sample

自动化工程

了解完需求以及测试设计文档也经过与产品、开发review通过后,就进入具体的自动化coding阶段了。这里我选用PnxTest框架来开展,简洁&操作流畅。

1、打开Intellij Idea, 创建一个新的Maven项目:项目名称根据自己的需要填写,这里我用api-testing-example

2、pom文件中增加pnxtest依赖

3、根据官方推荐的项目文件结构,创建相关文件夹

4、配置环境

环境 描述 Base Url Database
qa 测试环境 http://10.10.21.10/api mysql数据库, 地址&端口:10.10.20.5:3306
pre 预发布环境 https://pre.pnxtest.com/api mysql数据库, 地址&端口:10.20.30.6:3306
prod 生产环境 https://pnxtest.com/api mysql数据库, 地址&端口:10.30.40.7:3306

这里有三个环境, 其中qa环境不需要https. 在test-config文件夹下创建三个properties文件:qa.env.properties pre.env.properties prod.env.properties 写入相应的配置内容,以qa测试环境为例:

pnx.http.baseUrl = http://10.10.21.10/api 

pnx.db.url=jdbc:mysql://10.10.20.5:3306/db_account
pnx.db.driver=com.mysql.cj.jdbc.Driver
pnx.db.user=pnxtest
pnx.db.password=secret.sXI4yXOv1TC5nfH4
pnx.db.timeout=5
pnx.db.timezone=GMT+8

https.required = false;

4、由于所有接口都要验证签名与token权限,因此使用@Configuration进行统一网关配置。

创建一个类,名字可以任意。我这里为HttpGatewayConfig:

@Configuration
public class HttpGatewayConfig implements IHttpConfig {
    @Override
    public com.pnxtest.http.HttpConfig accept() {
        CustomHttpGateway myHttpGateway = new CustomHttpGateway();

        return HttpConfig.builder()
                .header("clientId", "PnxTest") //添加一个header
                    .header("token", "aeasfasfa.udfadsfafasfdasfdsfsfsdf") //添加token认证
                .connectionTimeout(5000) //设置连接超时时间5s
                .socketTimeout(5000) //设置读取内容超时时间
                .gateway(myHttpGateway) //设置统一网关
                .build();
    }
}  
class CustomHttpGateway extends HttpGateway{
  //request做统一拦截处理 
  @Override
  public void process(HttpRequest httpRequest, HttpContext httpContext) throws HttpException, IOException {
    String url = httpRequest.getRequestLine().getUri();
    String reqMethod = httpRequest.getRequestLine().getMethod();

    try{
      StringBuilder signPlainData = new StringBuilder();
      URI uri = new URI(url);

      //handle uri
      String path = uri.getPath();
      String pathWithoutApi = path.replaceFirst("/api", "");
      signPlainData.append(pathWithoutApi);

      //handle queryString
      Map<String, List<String>> queryStrings = parseQueryString(uri.getRawQuery());
      List<String> queryStringKeys = new ArrayList<>(queryStrings.keySet());
      Collections.sort(queryStringKeys); //升序排列
      for(String key: queryStringKeys){
        for(String v: queryStrings.get(key)) {
          signPlainData.append(key);
          signPlainData.append(v);
        }
      }

      //handle headers
      Header[] headers = httpRequest.getAllHeaders();
      Arrays.sort(headers, new Comparator<Header>() {
        public int compare(Header o1, Header o2){
          return o1.getName().compareToIgnoreCase(o2.getName());
        }
      });

      for(Header header:headers){
        String hName = header.getName();
        String hValue = header.getValue();
        if(hName.equalsIgnoreCase("device-id")
           || hName.equalsIgnoreCase("timestamp")
           || hName.equalsIgnoreCase("token")){
          signPlainData.append(hName);
          signPlainData.append(hValue);
        }
      }

      //handle body
      if (!reqMethod.equalsIgnoreCase(HttpMethod.GET.name())) {
        HttpEntity reqEntity = ((HttpEntityEnclosingRequest) httpRequest).getEntity();
        if (reqEntity != null && reqEntity.getContentLength() > 0) {
          String reqBody = convertInputStreamToString(reqEntity.getContent());
          if(!StringUtil.isEmpty(reqBody)) {
            String contentType = null;
            if(reqEntity.getContentType() != null){
              String[] contentTypes = reqEntity.getContentType().getValue().split(";", 2);
              contentType = contentTypes[0];
            }

            if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType)) {
              Map<String, List<String>> formdataMap = parseQueryString(reqBody.trim());
              List<String> fieldKeyList = new ArrayList<>(formdataMap.keySet());
              Collections.sort(fieldKeyList);
              for (String key : fieldKeyList) {
                for (String v : formdataMap.get(key)) {
                  signPlainData.append(key);
                  signPlainData.append(v);
                }
              }
            } else {
              signPlainData.append(reqBody);
            }
          }
        }
      }

      String plainText = signPlainData.toString(); //签名原文
      String sign = calculateSign(plainText); //加密签名
      httpRequest.addHeader("sign", sign); //添加签名到http请求头

    }catch (IOException | URISyntaxException e){
      //
    }
  }

  @Override
  public void process(HttpResponse httpResponse, HttpContext httpContext) throws HttpException, IOException {
    //这里可以response做统一拦截处理
  }


  private String calculateSign(String plainText) {
    //根据自身公司算法来计算,这里只是一个简单base64加密, 并没有使用私钥
    return Base64.getEncoder().encodeToString(plainText.toLowerCase().getBytes(StandardCharsets.UTF_8));

  }

  private Map<String, List<String>> parseQueryString(String queryString) throws UnsupportedEncodingException {
    if(StringUtil.isEmpty(queryString) || StringUtil.isBlank(queryString)){
      return new LinkedHashMap<String, List<String>>(0);
    }

    final Map<String, List<String>> queryPairs = new LinkedHashMap<String, List<String>>();
    final String[] pairs = queryString.split("&");

    for (String pair : pairs) {
      final int idx = pair.indexOf("=");
      final String key = idx > 0 ? URLDecoder.decode(pair.substring(0, idx), "UTF-8") : pair;
      if (!queryPairs.containsKey(key)) {
        queryPairs.put(key, new LinkedList<String>());
      }

      final String value = (idx > 0 && pair.length() > idx + 1) ? URLDecoder.decode(pair.substring(idx + 1), "UTF-8") : null;
      queryPairs.get(key).add(value);
    }

    return queryPairs;
  }

  private String convertInputStreamToString(InputStream inputStream){
    try {
      ByteArrayOutputStream result = new ByteArrayOutputStream();
      byte[] buffer = new byte[1024];
      for (int length; (length = inputStream.read(buffer)) != -1; ) {
        result.write(buffer, 0, length);
      }
      result.close();
      return result.toString("UTF-8");
    }catch (IOException e){
      //ignore
    }

    return "";
  }
}

5、一切准备就绪,开始编写测试用例:

@Controller(module = "项目模块", maintainer = "陈鹏")
public class ProjectCategoryTest {
    @Test
    @DisplayName("获取指定项目下的所有分类列别,树形结构")
    void projectCategoryListingTest(){
        HttpResponse<String> response = PnxHttp.get("/api/v1/tms/{programId}/testCategory/")
                .routeParam("programId", "1")
                .asString();

        PnxAssert.assertThat(response.getStatus())
                .as("非法token请求")
                .isEqualTo(401);
    }

}
  1. IDE中调试运行:设置运行环境为qa, 执行,测试通过,完美!到test-outputting文件夹下查看测试报告:签名有了,详细测试日志也有了, perfect!

api-example-result1 api-example-result2

  1. 把项目打成一个可执行jar, 放到CI(如jenkins)中运行。

总结说明

1、该演示工程代码已push到GitHub:https://github.com/pengtech/api-testing-example 里面的Base URL地址,数据库链接地址,请修改为自己内部可用的地址

2、上面的示例演示了如何统一签名、统一认证, 以及如何忽略或者开启https认证,动态路由等自动化测试中常见的问题

3、 数据库的连接密码属于敏感信息,上面的示例直接在配置文件中暴露密码原文,那如何进行加密处理呢?可以参考官方文档

4、上面的示例只演示了一个测试用例,当测试的接口和用例比较多时,怎么组织呢? 使用@Repository @Steps

最后的彩蛋

人工智能与机器学习 这几年在测试领域也得到了不错的应用,效果显著。作为一个与时俱进的自动化测试框架,使用AI/ML来增强自动化测试不可或缺,对于API自动化这块,后续版本将提供 “通过ML生成自动化测试场景用例”功能,目前的进展和内部的测试效果,ML可以覆盖大约30%-40%的测试用例。