簡介
使用場景
主要用於在微服務架構下做CDC(消費者驅動契約)測試。下圖展示了多個微服務的調用,如果我們更改了一個模塊要如何進行測試呢?
-
傳統的兩種測試思路
- 模擬生產環境部署所有的微服務,然后進行測試
- 優點
- 測試結果可信度高
- 缺點
- 測試成本太大,裝一整套環境耗時,耗力,耗機器
- 優點
- Mock其他微服務做端到端的測試
- 優點
- 不用裝整套產品了,測的也方便快捷
- 缺點
- 需要寫很多服務的Mock,要維護一大堆不同版本用途的simulate(模擬器),同樣耗時耗力
- 優點
- 模擬生產環境部署所有的微服務,然后進行測試
-
Spring Cloud Contrct解決思路
- 每個服務都生產可被驗證的 Stub Runner,通過WireMock調用,服務雙方簽訂契約,一方變化就更新自己的Stub,並且測對方的Stub。Stub其實只提供了數據,也就是契約,可以很輕量的模擬服務的請求返回。而Mock可在Stub的基礎上增加驗證
契約測試流程
- 服務提供者
- 編寫契約,可以用Groovy DSL 腳本也可以用 YAML文件
- 編寫測試基類用於構建過程中插件自動生成測試用例
- 生成的測試用例會自動運行,這時如果我么提供的服務不能滿足契約中的規則就會失敗
- 提供者不斷完善功能直到服務滿足契約要求
- 發布Jar包,同時將Stub后綴的jar一同發布
- 服務消費者
- 對需要依賴外部服務的接口編寫測試用例
- 通過注解指定需要依賴服務的Stub jar包
- 驗證外部服務沒有問題
簡單案例
服務提供者
模擬一個用戶服務
項目地址
項目依賴
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<extensions>true</extensions>
<configuration>
<!--用於構建過程中插件自動生成測試用例的基類-->
<baseClassForTests>
com.github.freshchen.keeping.RestBaseCase
</baseClassForTests>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
編寫契約
既然是消費者驅動契約,首先需要制定契約, 告訴消費方能提供哪些 stub,並且生成單元測試驗證提供方能不能滿足約定的能力
Contract.make {
description "add user"
request {
method POST()
url "/user"
body([
age: value(
// 消費方想創建任何年齡的用戶,都會得到下面的response.body的返回 "success: true"
consumer(regex(number())),
// 提供方生成的測試會調用接口創建一個年齡20歲的用戶
producer(20)
)
])
}
response {
status OK()
headers {
contentType applicationJson()
}
// 提供給消費者的默認返回
body([
success: true
])
// 提供方在測試過程中,body需要滿足的規則
bodyMatchers {
// 自定義的模型中有 success 字段,byEquality 可以驗證服務端返回json中的 success 是不是 true
jsonPath '$.success', byEquality()
// 當然我們也可以自定義校驗, 可以在基類中實現 assertIsTrue 方法
jsonPath '$.success', byCommand('assertIsTrue($it)')
}
}
}
測試基類
@SpringBootTest
@RunWith(SpringRunner.class)
public abstract class RestBaseCase {
@Autowired
WebApplicationContext webApplicationContext;
@Before
public void setup() {
MockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(webApplicationContext);
RestAssuredMockMvc.standaloneSetup(builder);
}
protected void assertIsTrue(Object object) {
Map map = (Map) object;
assertThat(map.get("success")).isEqualTo(true);
}
}
實現功能
@Data
@ApiModel
public class JsonResult<T> {
@NonNull
@ApiModelProperty("是否成功")
private boolean success;
@ApiModelProperty("響應結果")
private Optional<T> data = Optional.empty();
@ApiModelProperty("錯誤碼")
private Optional<Integer> errCode = Optional.empty();
@ApiModelProperty("錯誤消息")
private Optional<String> errMessage = Optional.empty();
}
@RestController
@RequestMapping("/user")
public class UserController {
@PostMapping
public JsonResult create(@RequestBody User user) {
return JsonResult.ok();
}
}
server.port=8880
測試
實現我們的服務功能,具體代碼邏輯可以在項目地址中查看,然后測試看是否符合契約
mvn clean test
可以在生成(target)目錄中找到 generated-test-sources 這個目錄,插件為我們自動生成並且運行的case就在其中
public class ContractVerifierTest extends RestBaseCase {
@Test
public void validate_addUser() throws Exception {
// given:
MockMvcRequestSpecification request = given()
.body("{\"age\":20}");
// when:
ResponseOptions response = given().spec(request)
.post("/user");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
// and:
assertThat(parsedJson.read("$.success", Boolean.class)).isEqualTo(true);
assertIsTrue(parsedJson.read("$.success"));
}
}
發布
如果一切順利就可以deploy了,解壓發布的 stubs 可以看到定義給消費者的 json
{
"id" : "737fc339-a9c5-41f4-909a-a783dbc0855f",
"request" : {
"url" : "/user",
"method" : "POST",
"bodyPatterns" : [ {
"matchesJsonPath" : "$[?(@.['age'] =~ /-?(\\d*\\.\\d+|\\d+)/)]"
} ]
},
"response" : {
"status" : 200,
"body" : "{\"success\":true}",
"headers" : {
"Content-Type" : "application/json"
},
"transformers" : [ "response-template" ]
},
"uuid" : "737fc339-a9c5-41f4-909a-a783dbc0855f"
}
服務消費者
預約服務,會調用戶服務接口
項目地址
服務調用
服務調用方會去調用 8880 端口,也就是上文的用戶服務
public interface UserApi {
@POST("/user")
Call<JsonResult> create(@Body User user);
}
public class UserClient {
public static JsonResult createUser(User user) throws IOException {
UserApi userApi = new Retrofit.Builder().baseUrl("http://127.0.0.1:8880")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(UserApi.class);
return userApi.create(user).execute().body();
}
}
驗證服務
即使用戶服務沒有開發完成,得到了 stubs 后,預約即使依賴用戶服務接口也可以並行開發並完成測試不被阻塞
@SpringBootTest
@RunWith(SpringRunner.class)
@AutoConfigureStubRunner(
ids = {"com.github.freshchen.keeping:cloud-contract-provider-rest:+:stubs:8880"},
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
public class UserClientTest {
@Test
public void createUser() throws IOException {
User user = new User();
user.setAge(123);
JsonResult user1 = UserClient.createUser(user);
BDDAssertions.then(user1.getSuccess()).isEqualTo(true);
}
}
關注一下日志,確認是 stubs 生效了, 可以看到 stub id 和上文 json中 uuid 吻合
2020-12-12 16:09:08.070 INFO 18224 --- [p1001114349-254] WireMock : Request received:
127.0.0.1 - POST /user
Connection: [keep-alive]
User-Agent: [okhttp/3.14.8]
Host: [127.0.0.1:8880]
Accept-Encoding: [gzip]
Content-Length: [11]
Content-Type: [application/json; charset=UTF-8]
{"age":123}
Matched response definition:
{
"status" : 200,
"body" : "{\"success\":true}",
"headers" : {
"Content-Type" : "application/json"
},
"transformers" : [ "response-template" ]
}
Response:
HTTP/1.1 200
Content-Type: [application/json]
Matched-Stub-Id: [737fc339-a9c5-41f4-909a-a783dbc0855f]