内容简介:通过一个简单的实际案例,介绍如何使用 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);
}
}
6、 IDE 中调试运行:设置运行环境为 qa, 执行,测试通过,完美!到test-outputting
文件夹下查看测试报告:签名有了,详细测试日志也有了, perfect!
7、把项目打成一个可执行 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% 的测试用例。