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)测试设计文档在团队人员变动、智慧传承方面也会起到显著的作用。
自动化工程
了解完需求以及测试设计文档也经过与产品、开发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);
}
}
- IDE中调试运行:设置运行环境为qa, 执行,测试通过,完美!到
test-outputting
文件夹下查看测试报告:签名有了,详细测试日志也有了, perfect!
- 把项目打成一个可执行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%的测试用例。