Contract testing
1. Pact与其他工具的对比
主要有:
Spring Cloud Contract
Accurest
Nock
VCR
Webmock
Pacto
2. 支持的语言
JS
Java
Net
Go
Python
Swift
Scala
PHP
Ruby
C++
3. 依赖
3.1 Consumer
au.com.dius
pact-jvm-consumer-junit5
4.0.4
test
au.com.dius
pact-jvm-consumer-java8
4.0.4
3.2 Provider
au.com.dius
pact-jvm-provider-junit5
${pact.version}
test
4. annotation
4.1 Consumer
4.1.1 @ExtendWith(PactConsumerTestExt.class)
JUnit5
加在consumer unit test的文件上
用于替代JUit 4的PactRunner
@ExtendWith(PactConsumerTestExt.class)
class ExampleJavaConsumerPactTest {
4.1.2 @Pact(provider="ArticlesProvider", consumer="test_consumer")
对于每个测试,需要定义一个用 @Pact 注释的方法。
4.1.3 @PactTestFor(providerName = "ArticlesProvider")
通过 @PactTestFor 链接 mock server 与 test 交互。
此方法可以加到测试类上,也可以加到测试方法上。
hostname不填的话,默认是:localhost
port不填的话,默认是:随机端口号
4.2 Provider
4.2.1 @TestTemplate
这个注解会在consumer生成的契约文件中,找到所有的交互,并且为provider生成一个个对应的测试。
需配合 @ExtendWith(PactVerificationSpringProvider.class) 一起使用
官方例子
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Provider("Animal Profile Service")
@PactBroker
public class ContractVerificationTest {
@TestTemplate
@ExtendWith(PactVerificationSpringProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
}
4.2.2 @Provider("Animal Profile Service")
设置用于测试的Provider的名称,与Consumer test中 @Pact(provider = "Animal Profile Service") 对应
4.2.3 @PactFolder("pacts")
指定consumer test生成契约的位置,通常是:../target/pacts/
4.2.4 @State("query user")
对应consumer test中DSL的.given的值。
此方法会在调用我们程序API之前先被调用,这里面可以做一些mock数据的操作等。
4.2.5 @State("SomeProviderState", action = StateChangeAction.TEARDOWN)
在前面的基础上,加多了:action = tateChangeAction.TEARDOWN,次方法会在调用完我们程序API后做一些额外的操作
@State("SomeProviderState", action = StateChangeAction.TEARDOWN)
public void someProviderStateCleanup() {
// Do what you need to to teardown the state
}
5. DSL - Consumer 代码
5.1 不同类型的校验方式
LambdaDsl.newJsonBody(o -> o
// value值层面上做比较
.numberValue("id", 1)
.stringValue("company", "Tencent")
.booleanValue("flag", true)
// 数据类型上做限制,不在乎对应的value值
.numberType("phoneNumber")
.stringType("address")
.booleanType("delete")
// 用正则表达式匹配value值
.stringMatcher("code", "[A-Z]{3}\\d{2}")
).build()
consumer完整的例子
此例子对应的Object Json为
{
"flag":true,
"phoneNumber":100,
"address":"string",
"code":"PKV92",
"company":"Tencent",
"id":1,
"delete":true
}
@ExtendWith({PactConsumerTestExt.class})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Tag("ContractTest")
public class ConsumerTest {
RestTemplate restTemplate;
@BeforeEach
public void initialRestTemplate() {
restTemplate = new RestTemplate();
}
private Map jsonHeader() {
Map map = new HashMap<>();
map.put("Content-Type", "application/json;charset=UTF-8");
return map;
}
@Pact(provider = "user", consumer = "queryUser")
public RequestResponsePact retrieveUserTask(PactDslWithProvider builder) {
return builder
.given("query user") // 对应provider的@State("query user")
.uponReceiving("for query user testing")
.path("/user/1") // 请求路径
.method("GET") // 请求方式
.willRespondWith() // 设定预期的请求返回值
.status(200)
.body(
LambdaDsl.newJsonBody(o -> o
.numberValue("id", 1)
.stringValue("company", "Tencent")
.booleanValue("flag", true)
.numberType("phoneNumber")
.stringType("address")
.booleanType("delete")
.stringMatcher("code", "[A-Z]{3}\\d{2}")
).build())
.headers(jsonHeader())
.toPact();
}
@Test
@PactTestFor(providerName = "user", port = "8585")
public void runTestRetrieveUserTask() {
restTemplate.getForObject("http://localhost:8585/user/{id}", UserInformationDto.class, 1);
}
}
执行测试后,在target/pacts/目录下会生成对应的契约文件
{
"provider":{
"name":"user"
},
"consumer":{
"name":"queryUser"
},
"interactions":[
{
"description":"for query user testing",
"request":{
"method":"GET",
"path":"/user/1"
},
"response":{
"status":200,
"headers":{
"Content-Type":"application/json;charset\u003dUTF-8"
},
"body":{
"flag":true,
"phoneNumber":100,
"address":"string",
"code":"PKV92",
"company":"Tencent",
"id":1,
"delete":true
},
"matchingRules":{
"body":{
"$.phoneNumber":{
"matchers":[
{
"match":"number"
}
],
"combine":"AND"
},
"$.address":{
"matchers":[
{
"match":"type"
}
],
"combine":"AND"
},
"$.delete":{
"matchers":[
{
"match":"type"
}
],
"combine":"AND"
},
"$.code":{
"matchers":[
{
"match":"regex",
"regex":"[A-Z]{3}\\d{2}"
}
],
"combine":"AND"
}
},
"header":{
"Content-Type":{
"matchers":[
{
"match":"regex",
"regex":"application/json(;\\s?charset\u003d[\\w\\-]+)?"
}
],
"combine":"AND"
}
}
},
"generators":{
"body":{
"$.phoneNumber":{
"type":"RandomInt",
"min":0,
"max":2147483647
},
"$.address":{
"type":"RandomString",
"size":20
},
"$.code":{
"type":"Regex",
"regex":"[A-Z]{3}\\d{2}"
}
}
}
},
"providerStates":[
{
"name":"query user"
}
]
}
],
"metadata":{
"pactSpecification":{
"version":"3.0.0"
},
"pact-jvm":{
"version":"4.0.4"
}
}
}
5.2 某个对象属性是List
此例子对应的Object Json为
{
"userInformationDtoList":[
{
"phoneNumber":100,
"address":"string",
"code":"string",
"flag":true,
"company":"string",
"id":100,
"delete":true
},
{
"phoneNumber":100,
"address":"string",
"code":"string",
"flag":true,
"company":"string",
"id":100,
"delete":true
}
]
}
其他的和上面例子一样,就是.body中的校验逻辑进行更改
/**
* 要求:请求返回的对象中,属性名是:userInformationDtoList的List,至少有两个以上的对象要符合以下条件,否则校验失败
* 有:minArrayLike、maxArrayLike、eachLike 三种方式
*/
new PactDslJsonBody()
.minArrayLike("userInformationDtoList", 2) // maxArrayLike, eachLike
.numberType("id")
.numberType("phoneNumber")
.stringType("company")
.stringType("address")
.stringType("code")
.booleanType("flag")
.booleanType("delete")
5.3 返回的是List
此例子对应的Object Json为
[
{
"orderId":100,
"ifPay":true,
"orderName":"string"
}
]
/**
* 要求:请求返回的数组中,包含的每一个对象要符合以下条件,否则校验失败
* 有:arrayEachLike、arrayMinLike、arrayMaxLike三种方式
*/
PactDslJsonArray.arrayEachLike() // arrayMinLike, arrayMaxLike
.numberType("orderId")
.stringType("orderName")
.booleanType("ifPay")
5.4 List包含List
此例子对应的Object Json为
[
{
"goodList":[
{
"goodName":"string",
"goodId":100,
"goodPrice":100
}
],
"orderId":100,
"storeName":"string"
}
]
// 关键在于.array & .object
PactDslJsonArray.arrayEachLike()
.numberType("orderId")
.stringType("storeName")
.array("goodList")
.object()
.numberType("goodId")
.stringType("goodName")
.numberType("goodPrice")
5.5 Post请求校验
上面的demo都是Get请求的,Post请求如下:
大体类似,主要不同点在于,DSL中需要加入请求的参数。
// 请求的对象
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RequestDto {
private int id;
private String name;
}
// 返回的对象
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResponseDto {
private int id;
private String name;
private int phoneNumber;
}
测试
@ExtendWith({PactConsumerTestExt.class})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Tag("ContractTest")
public class ConsumerTest5 {
RestTemplate restTemplate;
@BeforeEach
public void initialRestTemplate() {
restTemplate = new RestTemplate();
}
private Map jsonHeader() {
Map map = new HashMap<>();
map.put("Content-Type", "application/json;charset=UTF-8");
return map;
}
@Pact(provider = "userInfo", consumer = "queryUserInfo")
public RequestResponsePact retrieveUserInfo(PactDslWithProvider builder) {
RequestDto requestDto = RequestDto
.builder()
.id(1)
.name("Dwayne")
.build();
return builder
.given("retrieveUserInfo 1")
.uponReceiving("UserInfo of 1 is returned")
.path("/findUserInfoById")
.method("POST")
.body(JSONObject.toJSONString(requestDto)) // 这里比GET请求多了一个存放请求参数的body
.willRespondWith()
.status(200)
.body(LambdaDsl
.newJsonBody(o -> o
.numberValue("id", 1)
.stringValue("name", "Dwayne")
.numberType("phoneNumber")
).build())
.headers(jsonHeader())
.toPact();
}
@Test
@PactTestFor(providerName = "userInfo", port = "8585")
public void runTestRetrieveUserInfo() {
RequestDto requestDto = RequestDto
.builder()
.id(1)
.name("Dwayne")
.build();
// restTemplate的请求方式也需要改变
restTemplate.postForObject("http://localhost:8585/findUserInfoById", requestDto, ResponseDto.class);
}
}
5.6 请求路径的匹配方式
// before
.path("/findUserById/{id}")
// after
.matchPath("/findUserById/[0-9]+")
5.7 请求头的匹配方式
// before
.headers("Location", "/hello/1234")
// after
.matchHeaders("Location", "*/hello/[0-9]+", "/hello/1234")
6. Provider 代码
6.1 不同类型的校验方式
Provider完整的代码
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("user")
@Tag("ContractTest")
@PactFolder("D:\\eclipse-workspace\\Pact Practice\\Pact Demo\\target\\pacts")
public class ProviderTest {
@LocalServerPort
int localServerPort;
@MockBean
UserTaskService userTaskService;
@BeforeEach
void setupTestTarget(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", localServerPort, "/"));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context, HttpRequest request) {
context.verifyInteraction();
}
@State("query user")
public void retrieveUserTaskVerify() {
UserInformationDto expectUserTaskDto = UserInformationDto.builder()
.id(1)
.company("TEST")
.flag(true)
.phoneNumber(123456)
.address("address test")
.delete(false)
.code("ABC01")
.build();
doReturn(expectUserTaskDto).when(userTaskService).findById(1);
}
}
6.2 某个对象属性是List
其他都一样,就是mock数据不同
@State("retrieveUserTask 1")
public void retrieveUserTaskVerify() {
UserInformationDto expectUserTaskDto = UserInformationDto.builder()
.id(1)
.company("TEST")
.flag(true)
.phoneNumber(123456)
.address("address test")
.delete(false)
.code("ABC01")
.build();
UserListDto userListDto = UserListDto
.builder()
.userInformationDtoList(Arrays.asList(expectUserTaskDto, expectUserTaskDto))
.build();
doReturn(userListDto).when(userTaskService).findAll();
}
6.3 Post请求校验
@State("retrieveUserInfo 1")
public void retrieveUserTaskVerify() {
RequestDto requestDto = RequestDto
.builder()
.id(1)
.name("Dwayne")
.build();
ResponseDto responseDto = ResponseDto
.builder()
.id(1)
.name("Dwayne")
.phoneNumber(123)
.build();
doReturn(responseDto).when(postService).findUserInfoById(requestDto);
}
7. 参考资料
7.1 为什么要使用contract testing
8. 完整代码
ConsumerTest 对应 ProviderTest
ConsumerTest2 对应 ProviderTest2
以此类推
完整代码