Maven + TestNG + HttpsURLConnection + Excel接口测试

1、分析业务接口

  1. 一般我们都是把excel表格的设计放在第一个,但我认为先分析好你要测的接口,提前确定好你需要的数据是什么,得到的数据是什么,然后你才能设计相应的框架,并思考技术选型。
  2. 经过分析,我选用Java的HttpsURLConnection库,新建一个请求类,请求方法有get和post两种,get请求的参数放在url后面,post请求参数以json格式放在body中。
  3. 所以excel作为数据源中需要告诉我用例名称、请求方法、参数的key和value、请求预期的状态码,请求结果中需要检查的值。
  4. 下一步就是确定用什么方法读取excel表格,我选的是jxl这个库,它的一个特点是只能读取.xls格式的表格,所以我们的数据源也要保存成这个格式。
  5. 那么读取完数据以后,要把它适当加工成程序更容易理解的格式,这样方便直接用。所以可以设计一个测试数据的类,把每一条测试用例都抽象成一个对象,每次用到数据都是实例化这个类。
  6. 有了测试数据类和请求类,还差检验结果了。请求的类中可以得到请求的结果,并且对结果进行处理获得状态码和结果body,需要校验的值可以从测试数据类中拿,再去进行对比。
  7. 这样分析下来,我们需要另一个类,他做的事情就是实例化请求类,用我们写的工具读取excel中的数据,并且把数据给到测试数据类,得到一个测试数据对象,然后对比结果。那么这个类每次测一条用例的时候都要重复做一些动作,比如取数据、对比数据,很像before test和after test的概念,所以这里我选用了TestNG框架。
  8. 那么我们的项目大概分为几个部分:请求类、测试数据类、发请求的类、各种工具类。这里就很方便去用maven作为一个管理工具

当然,这里只是简单分析思路,后面具体操作的时候,还是会有很多需要思考的细节。

2、设计excel表格

根据上面的分析,我设计的表格的表头有:「Test No.」「enabled」「Testname」「Method」「description」「Protocol」「requestMethod」「Address」「code」「CheckPoint1」「CheckValue1」「CheckPoint2」「CheckValue2」「Key1」「Value1」「Key2」「Value2」「Key3」「Value3」「Key4」「Value4」「Key5」「Value5」

Test No.是序号,给自己看的,enabled表示要不要执行这条用例,这是后来在实践中发现需要这样的开关才加上的,Testname、Method和description是到时候写在测试报告里面当作日志。CheckPoint和CheckValue需要多少个是根据实际需求来的,可以无限扩展。value和key如果是在get方法里,那就用于url后的请求参数,如果是post,就是body中的参数,同样支持无限扩展。我的表格一开始也不是这样的,在实践的过程中慢慢的优化,最后稳定在这样的格式。

3、添加依赖

Maven项目中添加依赖只需要在它的pom.xml文件中添加就可以,最终我的文件是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>apitest2</groupId>
<artifactId>apitest2</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>

<name>apitest2</name>
<url>http://maven.apache.org</url>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.2</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpcore -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.4</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.testng/testng -->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>6.8.8</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.jayway.jsonpath/json-path -->
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<version>2.4.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/net.sourceforge.jexcelapi/jxl -->
<dependency>
<groupId>net.sourceforge.jexcelapi</groupId>
<artifactId>jxl</artifactId>
<version>2.6.12</version>
</dependency>

</dependencies>
</project>

https://mvnrepository.com 中搜索你需要用的库,比如testng,选一个版本号,一般用稳定版本,然后详情页中maven栏下的代码复制到你pom.xml中的dependencies标签中,等待项目自己下载好这个库,就可以使用了。

4、工程结构

配置文件

配置文件和表格等都放在resource文件夹中,配置文件主要包含了host、excel的文件位置、请求所需要的header信息

src/main/java

src/main/java里有三个包:test.com.api、test.com.request、test.com.utils和com.test.testdata,分别放了测试类、请求类、工具类和测试数据类

test.com.api包

包下包含2个类,TestApi是所有测试类的基类,它的的作用是读取配置文件中的token信息,如果没有的话就调用registerToken方法写进去,如果有就去更新一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class TestApi {
protected Properties prop;
protected String excelPath;
protected String host;
protected String url;
protected JSONObject responseBody;
protected int responseCode;
protected String accessToken = "";
protected String refreshToken = "";
protected Map<String, String> tokenHeader;

@BeforeTest
public void setAccessToken() throws Exception{
//先去读一下token文件看有没有
readTokenFile();
if(TextUtils.isEmpty(accessToken)) {
//如果文件里没有内容,就获取2个token写进去
GetToken.registerToken(host);
}else {
//文件里面有内容,要去用旧的token获取新的
GetToken.refreshToken(host, accessToken, refreshToken);
//换完了以后再更新一下TestApi这里的
readTokenFile();
tokenHeader = new HashMap<String, String>();
tokenHeader.put("x-jike-access-token", accessToken);
tokenHeader.put("x-jike-refresh-token", refreshToken);
}
}

public void readTokenFile() {
try (FileReader reader = new FileReader(System.getProperty("user.dir") + "/src/main/resource/x-jike-access-token.txt");
BufferedReader br = new BufferedReader(reader)) {
//建议这里就读2行,分别赋值
accessToken = br.readLine();
if (!TextUtils.isEmpty(accessToken)) {
refreshToken = br.readLine();
}
} catch (IOException e) {
e.printStackTrace();
}
}

//构造函数
public TestApi() {
try {
//数据流的形式读取配置文件
prop = new Properties();
FileInputStream fis = new FileInputStream(System.getProperty("user.dir") + "/src/main/resource/config.properties");
prop.load(fis);
} catch (Exception e) {
e.printStackTrace();
}

host = prop.getProperty("Host");
excelPath = prop.getProperty("testData");
}
}

另一个类是单接口测试的类,继承了TestApi类,它会实例化请求类和测试数据类,实现发送请求和获取响应结果的功能,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
public class TraverseCases extends TestApi{
Object[][] excelData;
TestData testData;
HttpRequest request;
int codeExpected;
List<NameValuePair> bodyParams;
List<TestData> testDatas;
List<NameValuePair> checkPoints;

@BeforeClass()
public void setUp() throws Exception {
//读取excel
excelData = ExcelProcess.processExcel(excelPath);
//实例化请求类
request = new HttpRequest();
}

@DataProvider(name="testData")
private Iterator<Object[]> testDataProvider() throws IOException {
List<Object[]> result= new ArrayList<Object[]>();
List<TestData> alldata = new ArrayList<TestData>();
TestData td = null;
for(int i = 1; i < excelData.length; i++) {
//防止Excel里面有空行
if(excelData[i][0] == null) {
continue;
}
//实例化测试数据
td = new TestData(i, excelData);
if(td.isEnabled()) {
alldata.add(td);
}
}
Iterator it = alldata.iterator();
while(it.hasNext()){
result.add(new Object[] { it.next() });
}
return result.iterator();
}

@Test(dataProvider = "testData")
public void testDifferentCases(TestData testData) throws IOException {
//传入的参数就是每一条测试数据
if (testData != null) {
LogUtil.info("Testcase description:" + testData.getTestDescription());
bodyParams = testData.getKeys();
url = host + testData.getAddress();
LogUtil.info("Request url:" + url);
String body = "";
if(!bodyParams.isEmpty()){
body = TurnListToString.turnBodyToString(bodyParams);
}
//这里去要区分get还是post
//有个需要说明的,在表格里面如果是get方法,那么key和value代表params,如果是post,那它们就代表body里面的东西
String requestMethod = testData.getRequestMethod();
if(requestMethod.equals("post")) {
request.sendPostRequest(url, body, tokenHeader);
LogUtil.info("Request body:" + body);
}else if(requestMethod.equals("get")) {
request.sendGetRequest(url, bodyParams, tokenHeader);
LogUtil.info("Request params:" + body);
}
//verify
responseCode = request.getResponseCode();
LogUtil.info("ResponseCode:" + responseCode);
codeExpected = testData.getCodeExpected();
assertEquals(responseCode, codeExpected);
String checkPoint = "";
checkPoints = testData.getCheckPoints();

for (NameValuePair pair : checkPoints) {
checkPoint = pair.getName();
//如果检查点为空就不检查了
if(!TextUtils.isEmpty(checkPoint)) {
String checkValue = pair.getValue();
String result = request.getStringFromResponseJSON(checkPoint, checkValue.length());
LogUtil.info("CheckPoint:" + checkPoint);
LogUtil.info("CheckValue:" + checkValue);
LogUtil.info("ValueResult:" + result);
assertEquals(result, checkValue);
}
}
}
}
}

上面涉及到TestNG的东西,这里就不叙述了,TestNG的用法可以见我的另一偏文章:TestNG。(所有非java提供的类和方法,都是自己写的工具类,具体就不贴出来了)。如果后面要写多接口的测试,也可以用testng中的depend或者dataprovider注释来设计先后的逻辑。

test.com.request包

这个包下只有一个类HttpRequest,这个类里最主要的两个方法是sendGetRequest和sendPostRequest,分别用于发送get请求和post请求。另外还包含了一些对返回结果的处理,以及一些getter方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
public class HttpRequest {
String content = null;
URL requestUrl;
HttpsURLConnection connection;
int responseCode;
JSONObject responseBody;
PrintWriter out = null;

public void sendGetRequest(String url, List<NameValuePair> params, Map<String, String> header) throws IOException {
//处理请求的参数,参数就是在url后面加上?key1=value1&key2=value2
url = url + TurnListToString.turnKeysToString(params);
requestUrl = new URL(url);
connection = (HttpsURLConnection) requestUrl.openConnection();
// 默认是 GET方式
//设置本次连接是否自动重定向
connection.setInstanceFollowRedirects(true);
//用自己写的工具类,去设置请求header
RequestHeader.setRequestHeader(connection);
//再添加额外的header
RequestHeader.addExtraHeader(connection, header);

// 连接,从postUrl.openConnection()至此的配置必须要在connect之前完成
connection.connect();
InputStream inputStream = getInputStream();

if (null == inputStream) {
inputStream = connection.getInputStream();
}
readResponseContent(inputStream);
connection.disconnect();
}

public void sendPostRequest(String url, String body, Map<String, String> header) throws IOException {
requestUrl = new URL(url);
connection = (HttpsURLConnection) requestUrl.openConnection();
// 设置是否向connection输出,因为这个是post请求,参数要放在http正文内,因此需要设为true
connection.setDoOutput(true);
// Read from the connection. Default is true.
connection.setDoInput(true);
// 默认是 GET方式
connection.setRequestMethod("POST");
// Post 请求不能使用缓存
connection.setUseCaches(false);
//设置本次连接是否自动重定向
connection.setInstanceFollowRedirects(true);

//用自己写的工具类,去设置请求header
RequestHeader.setRequestHeader(connection);
//再添加额外的header
RequestHeader.addExtraHeader(connection, header);

out = new PrintWriter(connection.getOutputStream());
out.print(body);
out.flush();

// 连接,从postUrl.openConnection()至此的配置必须要在connect之前完成,
// 要注意的是connection.getOutputStream会隐含的进行connect。
connection.connect();
InputStream inputStream = getInputStream();

//如果返回的状态码不是200,需要从ErrorStream里读返回数据
if (null == inputStream) {
try {
inputStream = connection.getInputStream();
} catch (IOException e) {
inputStream = connection.getErrorStream();
}
}
readResponseContent(inputStream);
connection.disconnect();
}

public void readResponseContent(InputStream inputStream) throws IOException {
StringBuilder builder = new StringBuilder();
String line = null;
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
while ((line = reader.readLine()) != null) {
builder.append(line).append("\n");
}
content = builder.toString();
responseBody = (JSONObject) JSON.parse(content);
}

public InputStream getInputStream() throws IOException {
InputStream inputStream = null;
if (!TextUtils.isEmpty(connection.getContentEncoding())) {
String encode = connection.getContentEncoding().toLowerCase();
if (!TextUtils.isEmpty(encode) && encode.indexOf("gzip") >= 0) {
inputStream = new GZIPInputStream(connection.getInputStream());
}
}
return inputStream;
}

public JSONObject getResponseBody() {
return responseBody;
}

//传入valueLength是为了防止第一层没有找到key的时候,可以方便从字符串中找到value的值
public String getStringFromResponseJSON(String key, int valueLength) {
String value = responseBody.getString(key);
//如果第一层没有找到key,那么就把responseBody转成String,看看是否包含key,这个方法有点笨,暂时没想到更好的
if (TextUtils.isEmpty(value)) {
if (content.contains(key)) {
int beginIndex = content.indexOf(key) + key.length() + 2;
int endIndex = 0;
Character valueBeginLetter = content.charAt(beginIndex) ;
//如果包含可以,就取出value,一般json字符串的格式是"key":"value"
//如果value是布尔型或者整型就没有引号
if (valueBeginLetter.toString().equals("\"")) {
//如果是引号的话从下一个位置开始找
beginIndex ++;
endIndex = beginIndex + valueLength;
}
value = content.substring(beginIndex, endIndex);
}
}
return value;
}

public int getResponseCode() throws IOException {
responseCode = connection.getResponseCode();
return responseCode;
}

public String getHeaderString(String headerKey) {
String headerValue = connection.getHeaderField(headerKey);
return headerValue;
}

public String getStringFromResponseJSON(String key) {
return responseBody.getString(key);
}
}
test.com.testdata包

包里有一个TestData类,抽象出了一条测试用例,话不多说,来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* TestData类储存的是一条具体的测试用例,构造函数中处理了从Excel表格中读出来的数据
* @author lulian
* address 请求地址
* checkpoint 检查点
* checkValue 检查值
* body 请求body或者params,GET请求时用作params,POST时用作BODY,但在testdata类里面都是List<NameValuePair>类型储存的
* codeExpected 期待的状态码
* testDescription 用例描述,用来写在报告里面的
* requestMethod 请求的方法
* enabled 表示用例是否要被执行
*/
public class TestData {
private String address;
private List<NameValuePair> body;
private List<NameValuePair> checkPoints;
private int codeExpected;
private String testDescription;
private String requestMethod;
private boolean enabled;

public TestData(int caseLine, Object[][] excelData) {
//根据列的名字找到列的序号,然后再获取对应的值
address = excelData[caseLine][ColNumber.getColNumber("Address", excelData)].toString().trim();
String codeContentFromExcel = excelData[caseLine][ColNumber.getColNumber("code", excelData)].toString().trim();
codeExpected = Integer.parseInt(codeContentFromExcel);
testDescription = excelData[caseLine][ColNumber.getColNumber("description", excelData)].toString().trim();
requestMethod = excelData[caseLine][ColNumber.getColNumber("requestMethod", excelData)].toString().trim();
enabled = excelData[caseLine][ColNumber.getColNumber("enabled", excelData)].toString().toString().trim().equals("1") ? true : false;
//用NameValuePair存储所有请求参数
body = new ArrayList<NameValuePair>();
for (int j = ColNumber.getColNumber("Key1", excelData); j < excelData[caseLine].length - 1; j = j + 2){
excelData[caseLine].length);
//因为每种请求的参数个数不确定,在这里进行非空判断
if(excelData[caseLine][j].equals("")){
break;
}
NameValuePair pair = new BasicNameValuePair(excelData[caseLine][j].toString(),excelData[caseLine][j+1].toString());
body.add(pair);
}
checkPoints = new ArrayList<NameValuePair>();
//checkPoint的数量从CheckPoint1开始一直到Key1之前2个
for (int j = ColNumber.getColNumber("CheckPoint1", excelData); j < ColNumber.getColNumber("Key1", excelData); j = j + 2) {
if(excelData[caseLine][j].equals("")){
break;
}
NameValuePair pair = new BasicNameValuePair(excelData[caseLine][j].toString(), excelData[caseLine][j+1].toString());
checkPoints.add(pair);
}
}

public String getAddress() {
return address;
}

public List<NameValuePair> getCheckPoints() {
return checkPoints;
}

public List<NameValuePair> getKeys() {
return body;
}

public int getCodeExpected() {
return codeExpected;
}

public String getTestDescription() {
return testDescription;
}

public String getRequestMethod() {
return requestMethod;
}

public boolean isEnabled() {
return enabled;
}
}
test.com.utils包

这个包里我写了很多工具类,就不一一列出代码了,挑一些个别的,比如从excel中读数据的类ExcelProcess:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ExcelProcess {
public static Object[][] processExcel(String filePath) throws IOException, JXLException{
//数据流读入excel
File file = new File(System.getProperty("user.dir") + filePath);
Workbook wb = Workbook.getWorkbook(file);
//读取特定表单,计算行数、列数
Sheet sheet = wb.getSheet("case");
int numberOfRow = sheet.getRows();
int numberOfCell = sheet.getColumns();
//将表单数据存入dtt对象
Object[][] data = new Object[numberOfRow][numberOfCell];
for (int i = 0; i < numberOfRow; i++) {
if (sheet.getCell(0, i).getContents().isEmpty() || sheet.getCell(0, i).getContents().equals("")) {
continue;
}
for (int j = 0; j < numberOfCell; j++) {
if(sheet.getCell(j, i) == null || sheet.getCell(j, i).equals("")) {
continue;
}
Cell cell = sheet.getCell(j, i);
data[i][j] = cell.getContents();
}
}
return data;
}
}

关于jxl库的使用,这里不赘述了,请参考我的另一篇文章Java Excel API——jxl常用类,里面覆盖到了常用的方法。

5、总结

  • 以上展示的是对单接口进的测试,并没有根据实际业务,写一个全链路的接口测试。
  • 有了这个框架,后面再有新的接口测试,只需要往表格中添加数据,不需要改动代码,可扩展性强
  • 后面会考虑放到Jenkins上定时跑,并且将测试报告发到邮箱中