在快速迭代的互联网行业,无数的接口提供了各种服务,大到系统级,小到应用级,对于纷繁复杂的接口的测试,无论测试还是线上环境,面向接口测试,变得尤为迫切。实际中测试工程师在做接口验证时,往往面临生产环境权限的掣制,或面临生产环境配置差异而无法像测试环境一样进行接口测试。相比与传统的RPC(远程过程调用,一般关注的是行为和处理),使用合适的客户端对基于面向资源的远程调用REST API,更适合做接口线上验证测试。REST 关注的是要处理的数据。
What
简洁来说,REST就是将资源的状态从最适合客户端或服务端的形式,从服务端转移到客户端(或者反过来),在REST中,资源通过URL进行识别和定位。天然地可通过HTTP的CRUD方法(create:POST,read:GET,update:PUT或PATCH,delete:DELETE)对远程REST API服务进行调用,提交请求和接受响应。而作为测试工程师,测试重点在构建请求,断言响应对象。
How
前面提到REST转移合适的资源格式于客户端和服务端之间。Spring 作为现代流行的软件开发框架,spring mvc对REST提供了良好的支持。若客户端的Accept接受格式为“application/json”,在服务端通过添加控制器注解@RestController到控制器类,就可以自动转换所有处理器方法返回结果为json格式给客户端。为处理器方法参数添加注解@RequestBody,默认的消息转换器MappingJackson2HttpMessageConverter(需要加到类路径中)会自动将客户端请求的资源(这里以json格式为例)自动转换为处理器方法需要传入的参数对象。举如下一个电商促销价格计算接口的测试为例。下面先看下服务端处理器暴露的接口。
@RestController
@RequestMapping("/api/purchaseItemsPromotionService")
public class PurchaseItemsPromotionServiceRestController {
@Resource(name = "purchaseItemsPromotionRemoteImpl")
private PurchaseItemsPromotionService purchaseItemsPromotionService;
@ApiOperation(value="促销计算(结算页接口)", notes="促销计算(结算页接口")
@PostMapping("getPromoPrice")
public PurchaseItemPromotionComposite getPromoPrice(@RequestBody List<PurchaseItem> purchaseItems, @ModelAttribute PurchaseClient purchaseClient) {
return purchaseItemsPromotionService.getPromoPrice(purchaseItems, purchaseClient);
}
}
通过Swagger实现REST API文档化和交互,通过访问swagger-ui,手动入参发送请求并查看返回结果,当然你也可以用postman等工具做同样操作。
显然通过ui或者工具去做接口验证,需要手动设置参数,肉眼断言响应,并没有解放太多生产力。接口测试,天然适合做自动化。通过实践,我总结了如下的几种客户端和json处理方法来做REST api 接口测试。
**
* @FunctionDesc 促销中心价格计算接口线上验证测试 多种方式
* @Author bjyfxuxiaojun
* @CreateDate 2017/12/28
* @Reviewer
* @ReviewDate 2017/12/28
*/
class PurchaseItemsPromotionServiceRestSpec extends Specification {
@Rule
TestName name = new TestName()
void setup() {
println """===================开始执行测试用例:${this.class.name}#$name.methodName==================="""
}
@See(["https://dzone.com/articles/groovy-unmarshalling-json-to-a-specific-object", "http://groovy-lang.org/json.html"])
def "每满M件打N折,验证满足促销条件,价格计算结果正确1 using RestTemplate.postForEntity,groovy.json.*"() {
given:
def rest = new RestTemplate()
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>()
// headers.add("Accept","*/*")
headers.add("Content-Type", "application/json")
HttpEntity postHttpEntity = new HttpEntity(toJson([new PurchaseItem(sku: 100058, basePrice: 1.0, purchaseQuantities: 10)]), headers)
when:
def responseEntity = rest.postForEntity("http://promo.soa.7fresh.com/api/purchaseItemsPromotionService/getPromoPrice?userPin=ceshibu5&storeId=131215&channel=2",
postHttpEntity, String.class)
def composite = responseEntity.getBody()
then:
responseEntity.statusCodeValue == 200
composite
println prettyPrint(composite)
// Josn to object
def compositeMap = new JsonSlurper().parseText(composite)
// def compositeMap = new JsonSlurper().parseText(prettyPrint(composite))
def promotionComposite = new PurchaseItemPromotionComposite(compositeMap)
promotionComposite.promotionSummaryList.size() > 0
promotionComposite.discountTotalPrice == 1.82
}
@See("https://dzone.com/articles/groovy-unmarshalling-json-to-a-specific-object")
def "每满M件打N折,验证满足促销条件,价格计算结果正确2 using RestTemplate.exchange,groovy.json.*"() {
given:
def rest = new RestTemplate()
def headers = new HttpHeaders(contentType: MediaType.APPLICATION_JSON_UTF8, accept: [MediaType.APPLICATION_JSON_UTF8])
def postHttpEntity = new HttpEntity(toJson([new PurchaseItem(sku: 100058, basePrice: 1.0, purchaseQuantities: 10)]), headers)
when:
def response = rest.exchange("http://promo.soa.7fresh.com/api/purchaseItemsPromotionService/getPromoPrice?userPin=ceshibu5&storeId=131215&channel=2", HttpMethod.POST, postHttpEntity, String)
def composite = response.getBody()
then:
response.statusCodeValue == 200
// Josn to object
def compositeMap = new JsonSlurper().parseText(composite)
def promotionComposite = new PurchaseItemPromotionComposite(compositeMap)
promotionComposite.promotionSummaryList.size() > 1
}
@See(["http://www.baeldung.com/jackson-object-mapper-tutorial", "https://www.mkyong.com/java/jackson-2-convert-java-object-to-from-json/"])
def "每满M件打N折,验证满足促销条件,价格计算结果正确3 using RestTemplate.exchange,Jackson 2"() {
given:
def rest = new RestTemplate()
def headers = new HttpHeaders(contentType: MediaType.APPLICATION_JSON_UTF8, accept: [MediaType.APPLICATION_JSON_UTF8])
def postHttpEntity = new HttpEntity(toJson([new PurchaseItem(sku: 100058, basePrice: 1.0, purchaseQuantities: 10)]), headers)
when:
def response = rest.exchange(
"http://promo.soa.7fresh.com/api/purchaseItemsPromotionService/getPromoPrice?userPin=ceshibu5&storeId=131215&channel=2",
HttpMethod.POST,
postHttpEntity,
String)
def composite = response.getBody()
then:
response.statusCodeValue == 200
println prettyPrint(composite)
// Josn to object
def objectMapper = new ObjectMapper()
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
def purchaseItemPromotionComposite = objectMapper.readValue(composite, PurchaseItemPromotionComposite)
purchaseItemPromotionComposite instanceof PurchaseItemPromotionComposite
}
@See("https://dzone.com/articles/groovy-unmarshalling-json-to-a-specific-object")
def "每满M件打N折,验证满足促销条件,价格计算结果正确4 using RestTemplate.postForEntity,MappingJackson2HttpMessageConverter"() {
given:
def rest = new RestTemplate()
def headers = new HttpHeaders(contentType: MediaType.APPLICATION_JSON_UTF8, accept: [MediaType.APPLICATION_JSON_UTF8])
def postHttpEntity = new HttpEntity([new PurchaseItem(sku: 100058, basePrice: 1.0, purchaseQuantities: 10)], headers) // auto convert T body to json.
when:
// MappingJackson2HttpMessageConverter会从类路径中查找Jackson2,若没有找到会抛出没有找到合适的消息转换器异常,所以需要引入artifactId:jackson-databind
// see org.springframework.web.client.RestTemplate#line 49
def compositeResponseEntity = rest.postForEntity(
new URI("http://promo.soa.7fresh.com/api/purchaseItemsPromotionService/getPromoPrice?userPin=ceshibu5&storeId=131215&channel=2"),
postHttpEntity,
PurchaseItemPromotionComposite)
def composite = compositeResponseEntity.getBody()
then:
compositeResponseEntity.statusCodeValue == 200
composite instanceof PurchaseItemPromotionComposite
println prettyPrint(toJson(compositeResponseEntity.body))
}
@See(["http://www.baeldung.com/httpclient-post-http-request", "http://www.baeldung.com/httpclient4"])
def "每满M件打N折,验证满足促销条件,价格计算结果正确5-using httpclient"() {
given:
def httpClient = HttpClients.createDefault()
def post = new HttpPost("http://promo.soa.7fresh.com/api/purchaseItemsPromotionService/getPromoPrice?userPin=ceshibu5&storeId=131215&channel=2")
post.setHeader("Accept", "application/json")
post.setHeader("Content-Type", "application/json")
post.setEntity(new StringEntity(toJson([new PurchaseItem(sku: 100058, basePrice: 1.0, purchaseQuantities: 10)])))
when:
def response = httpClient.execute(post)
then:
response.statusLine.statusCode == 200
response
def body = EntityUtils.toString(response.entity)
println body
}
@See(["https://github.com/abtris/groovy-rest-examples/blob/master/post.groovy", "https://dzone.com/articles/groovys-restclient-spock"])
def "每满M件打N折,验证满足促销条件,价格计算结果正确6-using RESTClient"() {
given:
def client = new RESTClient("http://promo.soa.7fresh.com/api/purchaseItemsPromotionService/getPromoPrice?userPin=ceshibu5&storeId=131215&channel=2")
when:
def response = client.post(body: toJson([new PurchaseItem(sku: 100058, basePrice: 1.0, purchaseQuantities: 10)]), contentType: ContentType.JSON)
then:
response.status == 200
println response.data
println prettyPrint(toJson(response.data))
}
@See("http://www.springboottutorial.com/integration-testing-for-spring-boot-rest-services")
def "每满M件打N折,验证满足促销条件,价格计算结果正确7,TestRestTemplate"() {
given:
def template = new TestRestTemplate()
MultiValueMap headers = new LinkedMultiValueMap()
headers.add("Content-Type", "application/json")
def httpEntity = new HttpEntity(toJson([new PurchaseItem(sku: 100058, basePrice: 1.0, purchaseQuantities: 10)]), headers)
when:
def responseEntity = template.postForEntity(
"http://promo.soa.7fresh.com/api/purchaseItemsPromotionService/getPromoPrice?userPin=ceshibu5&storeId=131215&channel=2",
httpEntity,
PurchaseItemPromotionComposite
)
then:
responseEntity.statusCodeValue == 200
responseEntity.getBody() instanceof PurchaseItemPromotionComposite
}
}
这里用到的是spock测试框架,脚本语言groovy,以上测试用例实例用到不同的客户端发送请求,有Spring的RestTemplate、TestRestTemplate,Apache的HttpClient,groovy语言自带的RESTClient。json与对象之间的转换也用到了不同的类,有Jackson2,groovy的JsonSlurper,spring框架支持自动转换json的MappingJackson2HttpMessageConverter(底层用的还是Jackson2,只不过不用用户处理手动处理json),条条大路通罗马,就看你习惯哪一条。下面看下真实的线上接口验证:
这里用到的是Spring框架的RestTemplate,通过源码分析RestTemplate的构造函数,默认会去查找消息转化器MappingJackson2HttpMessageConverter是否在类路径上,
public class RestTemplate extends InterceptingHttpAccessor implements RestOperations {
private static final boolean jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", RestTemplate.class.getClassLoader()) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", RestTemplate.class.getClassLoader());
private final List<HttpMessageConverter<?>> messageConverters;
// 省略字段 ……
public RestTemplate() {
// 省略代码 ……
if (jackson2Present) {
this.messageConverters.add(new MappingJackson2HttpMessageConverter());
} else if (gsonPresent) {
this.messageConverters.add(new GsonHttpMessageConverter());
}
// 省略代码 ……
}
// 省略代码 ……
}
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.8.7</version>
</dependency>
通过绑定host,运行用例结果如下:
总结:通过以上的实践,不难做线上验证,且绑定不同的环境的host,可以在不同环境做接口测试。通过用例我们看到,我们关注的不在过程调用,而更关心的是接口输入数据的构建,和结构响应的解析验证。通过Jenkins接入CI,线上持续验证不再难。