Spring Cloud Contract 微服務契約測試


簡介

使用場景

主要用於在微服務架構下做CDC(消費者驅動契約)測試。下圖展示了多個微服務的調用,如果我們更改了一個模塊要如何進行測試呢?

  • 傳統的兩種測試思路

    • 模擬生產環境部署所有的微服務,然后進行測試
      • 優點
        • 測試結果可信度高
      • 缺點
        • 測試成本太大,裝一整套環境耗時,耗力,耗機器
    • Mock其他微服務做端到端的測試
      • 優點
        • 不用裝整套產品了,測的也方便快捷
      • 缺點
        • 需要寫很多服務的Mock,要維護一大堆不同版本用途的simulate(模擬器),同樣耗時耗力
  • Spring Cloud Contrct解決思路

    • 每個服務都生產可被驗證的 Stub Runner,通過WireMock調用,服務雙方簽訂契約,一方變化就更新自己的Stub,並且測對方的Stub。Stub其實只提供了數據,也就是契約,可以很輕量的模擬服務的請求返回。而Mock可在Stub的基礎上增加驗證

契約測試流程

  • 服務提供者
    • 編寫契約,可以用Groovy DSL 腳本也可以用 YAML文件
    • 編寫測試基類用於構建過程中插件自動生成測試用例
    • 生成的測試用例會自動運行,這時如果我么提供的服務不能滿足契約中的規則就會失敗
    • 提供者不斷完善功能直到服務滿足契約要求
    • 發布Jar包,同時將Stub后綴的jar一同發布
  • 服務消費者
    • 對需要依賴外部服務的接口編寫測試用例
    • 通過注解指定需要依賴服務的Stub jar包
    • 驗證外部服務沒有問題

簡單案例

服務提供者

模擬一個用戶服務

項目地址

cloud-contract-provider-rest

項目依賴

<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"
}

服務消費者

預約服務,會調用戶服務接口

項目地址

cloud-contract-consumer-rest

服務調用

服務調用方會去調用 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]


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM