Blog about software - ordep.dev 10月02日
微服务合同测试详解
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

微服务架构中,服务间的合同测试至关重要。本文探讨了常见合同破坏场景(如端点重命名、参数变更),并介绍了Spring Cloud Contract和Consumer-Driven Contracts如何通过预定义合同测试确保服务兼容性。通过模拟生产者行为和生成消费者存根,合同测试在开发阶段就能快速捕获破坏性变更,避免生产环境问题。

🔍合同测试的核心是验证生产者与消费者之间的协议一致性,通过预定义的合同规范确保服务兼容性,防止因接口变更导致的集成失败。

🔄Consumer-Driven Contracts采用消费者驱动模式,先由消费者定义合同预期(如请求/响应格式),生产者需实现匹配该合同的功能,测试自动验证是否遵守约定。

🛠️Spring Cloud Contract支持多种测试方式(HTTP/消息交互),结合WireMock存根技术,无需启动真实生产服务即可执行消费者端测试,实现快速反馈。

⚠️合同测试的关键场景包括端点重命名、参数增删改、验证规则变更等,这些变更若未在合同中明确,会导致消费者测试失败,从而在开发早期捕获问题。

🚀通过设置workOffline标志,可在本地仓库测试合同,待合同合并后切换至远程仓库,确保开发环境与生产环境合同同步,实现快速修复迭代。

Monoliths vs Microservices

In a monolithic application we don’t need to worry about breaking contracts between different services because thecompiler will do that job for us. If a given method signature changes, the contract between shared services is broken,and the build will automatically fail.

In a microservices approach, different services are deployed in different runtimes and don’t know anything about eachother. We don’t have the compiler to detect those breaking changes and they became hard to detect and manage. Usually,they’re found during end-to-end testing in a pre-production environment.

Integration testing

When performing integration tests on services that depends on other external services, we tend to build our own stubs toassert the answers that we need in order to successfully run the test suite, not the answers that the producer servicemight deliver. By running integration tests against these predefined answers, it can lead to passing tests within theconsumer service boundary and failing tests in a pre-production environment.

End-to-end testing

These tests are designed to test the full application from top to bottom by simulating a real scenario. Despite beingvery useful in order to verify that a given scenario is working as expected across multiple applications, they tend tobe very hard to write and maintain, slow to execute, and usually demanding of a dedicated pre-production environment.They also provide very late feedback, usually being the last tests being implemented.

Common Contract Breaking Scenarios

    Renaming endpoints Adding new mandatory parameters Removing existing parameters Changing validations of existing parameters Changing the response types or status code

Contracts Testing

“An integration contract test is a test at theboundary of an external service verifying that itmeets the contract expected by a consuming service”

Martin Fowler

A contract is a set of expectations shared between a service that acts as a consumer and another service that acts likea producer. They focus the specification and delivery of service functionality around key business value drivers. Thecompatibility of a contract remains stable and immutable for a particular set of consumer contracts and expectations.

Spring Cloud Contract

Provides support for Consumer Driven Contracts and service schemas in Spring applications, covering a range of optionsfor writing tests, publishing them as assets, asserting that a contract is kept by producers and consumers, for HTTPandmessage-based interactions.

Consumer-Driven Contracts Git Flow

Defining the Contract

As a consumer we need to exactly define our expectations. In this case, when sending a request from the consumer to theproducer, we want a successful response that matches with our request. Remember that the purpose of contract testing isnot to start writing business features in the contracts. Stay focused and limit yourself to testing contracts betweenapplications and avoid full behaviour simulation.

Contract.make {    request {        method "POST"        url "/validate"        body([                size: "SMALL"        ])        headers {            contentType(applicationJson())        }    }    response {        status 200        body([                message: "Size is valid."        ])        headers {            contentType(applicationJson())        }    }}

Producer Test Generation

As a producer, the goal is to implement a feature that matches with the defined contract. In order to ensure that theapplication behaves in the same way as we defined above, an acceptance test is generated and it will run on every build,providing the feedback that we’re chasing. The test calls the /validate endpoint with body {"size":"SMALL"} and runthe assertions from the request section of our contract.

@Testpublic void validate_validSizeShouldReturnHttpOk()    throws Exception {  // given:  MockMvcRequestSpecification request = given()      .header("Content-Type", "application/json")      .body("{\"size\":\"SMALL\"}");  // when:  ResponseOptions response = given().spec(request)      .post("/validate");  // then:  assertThat(response.statusCode()).isEqualTo(200);  assertThat(response.header("Content-Type"))      .matches("application/json.*");  // and:  DocumentContext parsedJson = JsonPath      .parse(response.getBody().asString());  assertThatJson(parsedJson).field("['message']")      .isEqualTo("Size is valid.");}

If we change the endpoint from /validate to something like /item/{uuid}/validate or change the response code from200 to 400, the test will fail. These kind of testing prevents us from, silently, breaking changes on the consumerside.

Stub Generation and Consumer Testing

As a consumer, the goal is to perform tests against the defined contract. In order to be able to perform those tests, aWireMock stub is also generated.

WireMock is an HTTP mock server. At its core it is web server that can be primed to serve canned responses toparticular requests (stubbing) and that captures incoming requests so that they can be checked later (verification).

The WireMock instance that is simulating the producer, will expose this stub every time that we trigger the /validateendpoint.

{  "id": "4ae4d36d-3c8a-4f06-b9d2-215f73cc9f10",  "request": {    "url": "/validate",    "method": "POST",    "headers": {      "Content-Type": {        "matches": "application/json.*"      }    },    "bodyPatterns": [      {        "matchesJsonPath": "$[?(@.['size'] == 'SMALL')]"      }    ]  },  "response": {    "status": 200,    "body": "{\"message\":\"Size is valid.\"}",    "headers": {      "Content-Type": "application/json"    },    "transformers": [      "response-template"    ]  },  "uuid": "4ae4d36d-3c8a-4f06-b9d2-215f73cc9f10"}

Our testing class should look like this:

@RunWith(SpringRunner.class)@SpringBootTest(webEnvironment = MOCK)@AutoConfigureMockMvc@AutoConfigureStubRunner(workOffline = true,    ids = "me.ordepdev.contracts:+:stubs:8080")public class ConsumerTest {  @Test  public void validate_withValidSize_shouldReturnHttpOk()      throws Exception {    HttpHeaders headers = new HttpHeaders();    headers.add("Content-Type", "application/json");    ResponseEntity<Response> response = restTemplate.exchange(        "http://localhost:8080/validate", HttpMethod.POST,        new HttpEntity<>("{\"size\":\"SMALL\"}", headers),        Response.class    );    assertThat(response.getStatusCode())        .isEqualByComparingTo(HttpStatus.OK);    assertThat(response.getBody().toString())        .isEqualTo("{\"message\":\"Size is valid.\"}");  }}

This test is responsible for making a request to our producer endpoint http://localhost:8080/validate and assert thatthe response is valid. Notice that we don’t need to start up our producer service thanks to @AutoConfigureStubRunner.This annotation is responsible to start up a WireMock server at port 8080 with the stubs from the latest version ofme.ordepdev.contracts package. If the contract is changed, our consumer side test that rely on it, will fail. That’sthe beauty of gluing these pieces together: all contract changes leads to failed builds during development.

Working online

Note that when the flag workOffline is set to true, it assumes that you have the contract stubs installed on yourlocal maven repository. Since we’re working based on a consumer-driven git flow, when the proposed contracts get merged,this flag must be set to false in order to download the contracts from the remote maven repository.

Why contract testing matters?

This approach gives you the ability to always test against a synced and shared contract between producer andconsumer, instead of testing against exclusively consumer stubs, that may always differ from the producer ones.With a failing fast approach, you’ll be always able to catch breaking changes during development phase.

Resources

[1] https://cloud.spring.io/spring-cloud-contract

[2] https://github.com/spring-cloud/spring-cloud-contract

[3] http://wiremock.org/docs

[4] https://martinfowler.com/articles/consumerDrivenContracts.html

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

微服务架构 合同测试 Spring Cloud Contract Consumer-Driven Contracts WireMock
相关文章