一文掌握契约测试(契约问题)

一文掌握契约测试(契约问题)

解决方案goocz2025-02-01 11:15:3035A+A-

领域驱动设计因为微服务的流行而再次火了起来,契约测试也是一样。

为了在微服务开发模式下跨团队协调更有效率,提升持续集成流水线自动化水平,契约测试有效弥补了集成测试的不足,强势C位出镜。

本文将通过逐步介绍契约测试是什么,怎么做,有哪些工具,有哪些最佳实践和经验教训,带您一起彻底掌握契约测试。

什么是契约测试

契约测试(Contract testing)是一种测试技术,它通过以隔离检查集成点上的每个应用的方式,确保应用发送或接收的消息符合调用双方共识,并允许随着时间的推移进行演化。

为什么要做契约测试

契约测试主要解决在存在沟通边界情况下,测试替身(Test Double)与生产代码表现可能不一致的问题。在契约测试中,契约由代码生成,保持与现实同步,而且应用可以独立于其它应用而仅基于契约进行快速测试。

由于集成测试容易受到网络缓慢或不可靠,以及服务不可靠等因素的影响而运行缓慢或失败,所以通常会引入测试替身来代替真实外部服务,以快速完成覆盖度更广的测试,让测试真正起到作用。

但是,这样做的同时,带来了测试替身是否可以持续准确表示外部服务的问题。于是,需要单独补充运行一组契约测试,来检查所有对测试替身调用的返回结果总是与对外部服务调用的返回结果相同。

契约测试的定位

金字塔模型是构建健康、快速、可维护测试集的成熟理论。

契约测试适合归属于服务测试(Service Tests)层,因为它们执行得很快,也不需要和外部服务集成来运行。契约测试运行于发布版本之前,为成功集成提供信心。

契约测试的价值

众所周知,越是在项目生命周期的后期发现Bug,其修复的成本就越高。

不同于端到端(E2E)测试,契约测试可以在开发人员推送代码之前运行,在开发阶段提早发现问题。

契约测试还有很多端到端测试不具备的好处:

  1. 不需要调用其它组件,运行得很快。
  2. 编写测试不需要了解系统全貌,更容易维护。
  3. 问题只存在于被测试组件中,更容易调试和修复。
  4. 极易反复运行。
  5. 每个组件独立测试,不会引发流水线构建时间大幅增长。

引入契约测试,还会带来如下福利:

  1. 在提供者API就绪之前就可以开发消费者应用。
  2. 为提供者供应准确的需求
  3. 会收获一组文档化良好的用例,它们确切地显示了如何使用提供者。
  4. 提供者对API变更更有信息,可以准确知道使用者感兴趣的字段,方便地移除未使用的字段,以及添加新的字段。
  5. 对提供者API进行修改,可以立即看到会影响哪些使用者。

没有两个团队是完全一样的,契约测试也不是万能的,关键要看契约测试可以为团队和项目带来什么。

契约测试适合的场景

契约测试可以用于任何需要通信的两个服务,比如Web前端与后端API服务。

在微服务架构体系中,因为存在更多团队独立、服务间调用及服务单独演进的情形,契约测试有了更好更大的用武之地。良好的契约测试,使得开发人员很容易避免版本地狱,是微服务开发和部署的利器。

概念术语

契约测试主要涉及如下概念术语:

  • 消费者(Consumer):对于调用,发起请求的一方。对于MQ,为接收消息的一方。
  • 提供者(Provider):对于调用,响应请求的一方。对于MQ,为生成消息的一方。
  • 契约(Contract):消费者和提供者之间的共识,是一系列交互的集合。对于HTTP调用,包括描述消费者向提供者发送什么的预期请求,以及描述消费者希望提供者返回的最小期望响应。对于消息交互,则描述消费者希望得到的最小期望消息。

契约测试模式

契约测试分为消费者驱动(consumer-driven)和提供者驱动(Provider-driven)两种模式。

消费者驱动更具哲学意义,将API的消费者置于设计过程的核心,来倡导更好的内部微服务设计。该模式的优点在于,只有消费者正在使用的部分会得到测试,而提供者可以自由地更改消费者不使用的任何其它部分,而不必破坏任何现有测试。

提供者驱动思路较为常规,更适合开放数据或系统的场景。

无论采用哪种风格,关键在于获得契约测试的好处,实现引入契约测试的目的。

契约测试基本步骤

1、消费者驱动

消费者驱动的契约测试运行步骤如下:

  1. 消费者基于提供者的mock编写和执行消费者测试
  2. 消费者方通过消费者测试生成契约,并将契约共享给提供者
  3. 提供者根据契约编写测试

2、提供者驱动

提供者驱动模式由提供者定义契约并驱动整个过程。

契约测试工具

流行的契约测试工具为:

  • Pact:是一个命令行工具,反馈时间更短,有助于消费者和生产者之间更好地沟通。
  • Spring Cloud Contract:主要用于JVM环境,也容易扩展到非JVM环境,主要适用于生产者驱动的契约测试。

利用Pact进行消费者驱动的测试

利用Pact进行契约测试的整个流程示意如下。

1、消费者生产代码

//消费者期望从提供者处获得的User数据类
data class User(
        val name: String,
        val lastName: String,
        val age: String,
)

//消费者处调用提供者获取User对象的客户端类
@Service
class UserClient {
    fun getUser(): User {
        return RestTemplate().exchange(
                providerBaseUrl + "/user",
                HttpMethod.GET,
                HttpEntity(Headers()),
                User::class.java
        ).body!!
    }
}

2、为消费者编写测试

@PactFolder("target/pacts") //存储pact文件的位置
@ExtendWith(PactConsumerTestExt::class, SpringExtension::class)
class ConsumerContractTest {

    //@Pact接受提供者名称、消费者名称两个参数
    @Pact(provider = "user-provider-service", consumer = "user-consume-service") 
    fun userPact(builder: PactDslWithProvider): RequestResponsePact {
        //使用pact DSL创建一个期望的响应体样本
        val responseBody = LambdaDsl.newJsonBody { user ->
            user.stringType("name", "someName")
            user.stringType("age", "20")
            user.stringType("lastName", "someLastName")
        }

        //使用pact DSL构建请求流。当提供者接收到GET /user请求时,使用上面定义的样本进行响应
        return builder
                .given("a user is present") //定义提供者状态
                .uponReceiving("a request to get user")
                .pathFromProviderState("/user", "/user")
                .method("GET")
                .willRespondWith()
                .body(responseBody.build())
                .toPact()
    }
    
    //测试
    //使用者向提供者MockServer发起请求,并对响应体进行断言
    @Test
    fun `should return user`(mockServer: MockServer) {
        val url = mockServer.getUrl() + "/user"

        val user = UserClient().getUser(url)

        //断言key,而不是value,提升健壮性
        assertTrue(user.hasProperty("age")) 
        assertTrue(user.hasProperty("name"))
        assertTrue(user.hasProperty("lastName"))
    }
}

3、生成契约文件

一旦上面的测试通过了,就会在 target/pacts 文件夹中生成一个pact契约文件,文件名称为
user-consume-service-user-provider-service.json,文件内容如下:

{
  "provider": {
    "name": "user-provider-service"
  },
  "consumer": {
    "name": "user-consume-service"
  },
  "interactions": [
    {
      "description": "a request to get user",
      "request": {
        "method": "GET",
        "path": "/user",
        "generators": {
          "path": {
            "type": "ProviderState",
            "expression": "/user",
            "dataType": "STRING"
          }
        }
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json; charset=UTF-8"
        },
        "body": {
          "lastName": "someLastName",
          "name": "someName",
          "age": "20"
        },
        "matchingRules": {
          "body": {
            "$.name": {
              "matchers": [
                {
                  "match": "type"
                }
              ],
              "combine": "AND"
            },
            "$.age": {
              "matchers": [
                {
                  "match": "type"
                }
              ],
              "combine": "AND"
            },
            "$.lastName": {
              "matchers": [
                {
                  "match": "type"
                }
              ],
              "combine": "AND"
            }
          },
          "header": {
            "Content-Type": {
              "matchers": [
                {
                  "match": "regex",
                  "regex": "application/json(;\\s?charset=[\\w\\-]+)?"
                }
              ],
              "combine": "AND"
            }
          }
        }
      },
      "providerStates": [
        {
          "name": "a user is present"
        }
      ]
    }
  ],
  "metadata": {
    "pactSpecification": {
      "version": "3.0.0"
    },
    "pact-jvm": {
      "version": "4.1.9"
    }
  }
}

4、利用Broker共享契约

Pact Broker是一个用于共享消费者驱动的契约,并验证结果的应用程序,对于Pact创建的契约做了优化,但也可以用于任何可以序列化为JSON的契约。

Pact Broker既支持在云上使用,也可以在本地部署。

以下是一个利用Docker Compose来本地部署Pack Broker的描述文件。

version: '3'

services:
  postgres:
    image: postgres
    healthcheck:
      test: psql postgres --command "select 1" -U postgres
    ports:
      - "5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: postgres

  broker_app:
    image: dius/pact-broker
    ports:
      - "80:80"
    links:
      - postgres
    environment:
      PACT_BROKER_DATABASE_USERNAME: postgres
      PACT_BROKER_DATABASE_PASSWORD: password
      PACT_BROKER_DATABASE_HOST: postgres
      PACT_BROKER_DATABASE_NAME: postgres
      PACT_BROKER_LOG_LEVEL: DEBUG

以下是Pact Broker启动后的界面样子。

接下来就可以使用pack-jvm的pactPublish命令将契约文件发布到Broker。

首先在build.gradle文件中添加需要的配置。

pact {
   publish {
      pactBrokerUrl = "http://localhost:80"
      pactDirectory = "target/pacts"
   }
}

然后,运行如下命令发布契约。

./gradlew pactPublish

命令执行成功后,即可在Pact Broker上看到已发布的契约。

同时,可以在Broker上查看契约细节。

至此,消费者方已完成契约创建、发布等全部工作。

5、提供者端验证

如下为提供者的生产代码。

@RestController
class UserService {
    @GetMapping("/user")
    fun getUser(): Map {
        return mapOf(
                "name" to "Foo",
                "lastName" to "Bar",
                "age" to "22"
        )
    }
}

以下代码用于提供者对契约的验证。

@RunWith(SpringRestPactRunner::class)
@Provider("user-provider-service") //提供者名称
@PactBroker(
        host = "localhost",
        port = "80",
        scheme = "http",
        consumers = ["user-consume-service"],
) //Pact Broker及消费者信息
@SpringBootTest(classes = [PactproviderApplication::class], webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, properties = ["server.port=8080"])
class UserServiceProviderTests {
    @TestTarget
    val target: HttpTarget = HttpTarget("localhost", 8080)

    @MockkBean
    lateinit var userService: UserService

    @Before
    fun setup() {
        //如下配置用于让 pact 在测试完成后,将测试结果发送到 broker
        System.setProperty("pact.verifier.publishResults", "true")
    }

    @Test
    @State("a user is present") //对应于契约中的提供者状态,以及消费者测试中的 given
    fun `should have a customer`() {
        //以下用于定义 userService 的 mock 行为
        every { userService.getUser() } returns mapOf(
                "name" to "someName",
                "lastName" to "someLastName",
                "age" to "22"
        )
    }
}

以下为测试运行结果的样子。

至此,已完成提供者测试,并证实了契约被正确履行。

在Broker上,可以看到契约被验证通过。

至此,消费者、提供者一起完成了整个契约测试。

利用SCC进行提供者驱动的测试

利用Spring Cloud Contract进行契约测试的过程示意如下。

SCC各组件相互关系描述如下。

1、提供者添加SCC依赖和maven插件


  org.springframework.cloud
  spring-cloud-starter-contract-verifier
  test



  org.springframework.cloud
  spring-cloud-contract-maven-plugin
  true

2、提供者定义契约

SCC允许通过groovy、yaml、代码等多种方式定义契约。

契约中包含在body中定义的消息示例及在matcers中定义的字段匹配器等。

request:
  method: PUT
  url: /customers
  body:
    name: John Don
    Phone: 1234567890
  headers:
    Content-Type: application/json
  matchers:
    body:
      - path: $.['name']
        type: by_regex
        regexType: as_string
      - path: $.['phone']
        type: by_regex
        value: "[0-9]{10}"
response:
  status: 200
  body:
    reference: 1122334455
  headers:
    Content-Type: application/json
  matchers:
    body:
      - path: $.['reference']
        type: by_regex
        value: "[0-9]{5}"

3、提供者验证契约

首先创建一个测试基类,以便使用命令来生成测试代码。

测试基类的作用是在每次测试运行之前初始化提供者API和其他配置。

public class BaseTestClass {
    @BeforeEach
    public void setup() {
        RestAssuredMockMvc.standaloneSetup(new CustomerRestController());
    }
}

接下来,框架将在maven插件被构建过程调用后,基于契约生成测试代码。

测试代码将向提供者发送带有契约中示例数据的请求,并解析响应,根据契约验证响应。

@Test
void validate_shouldCreateCustomer() throws Exception {
    // given:
        MockMvcRequestSpecification request = given()
                .header("Content-Type", "application/json")
                .body("{\"name\":\"John Don\",\"phone\":1234567890}");

    // when:
        ResponseOptions response = given().spec(request)
                .put("/customers");

    // 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("['reference']").matches("[0-9]{5}");
}

4、存储契约

提供者生成的契约jar文件,可以发布到Nexus、JFrog等构建库中存储,同时和其他maven构建一样,可以通过group Id、artifact id、version等识别和获得。

5、消费者验证契约

在消费者端,SCC框架为其提供了一个Stub Runner,它可以获取存根定义并将其注册到WireMock服务器。而该Mock服务器将模拟测试用例提供者的API,以支持消费者验证契约。

因此,首先需要为消费者应用添加stub runner的maven依赖。


  org.springframework.cloud
  spring-cloud-starter-contract-stub-runner
  test

以下为消费者契约测试代码。

其中@AutoConfigureStubRunner用于指定契约存根的maven组件信息,包括group id、artifact id和端口号。然后stub runner就可以从本地或远程存储库获取存根定义。

如下测试代码将调用端口为6565的mock服务器,并对响应进行断言。

@SpringJUnitConfig(CustomerClient.class)
@AutoConfigureStubRunner(ids = {"space.gavinklfong.demo:customer-service:+:stubs:6565"},
        stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class CustomerClientIntegrationTests {
    
   private CustomerClient customerClient;
   @BeforeEach
   void setup() {
      customerClient = new CustomerClient(“http://localhost:6565”);
   }

   @Test
   void testCustomerCreation() {
      Customer customer = new Customer(“Peter Pan”, 2233445566);
      String ref = customerClient.create(customer);
      assertNotNull(ref);
      assertEquals(5, ref.length());
      assertTrue(isNumeric(ref));
   }

}

最佳实践

契约测试有如下最佳实践。

  1. 契约测试的关注点应该是请求和响应的消息,而不是其行为
  2. 契约测试应该与数据无关
  3. 基于Broker将整个过程与CI集成
  4. pact用于契约测试,而不是功能测试
  5. 只针对那些一旦发生变化就会影响消费者的事情进行断言
  6. 将最新的契约提供给提供者

经验教训

1、让所有人都上船

契约测试需要跨团队协作,尽快让各方都加入进来,就模式和工具等各方面达成一致,否则很快就会遇到麻烦。

2、不要低估学习曲线

契约测试是一种新型的测试方法,即使拥有丰富的单元测试、集成测试等其它测试类型的丰富经验,也并不能代表可以编写有价值、可维护的契约测试,真正的挑战在于如何处理API和契约随时间的变化。

3、沟通仍然是必要的

工具并不能代替彼此之间的交流,契约测试也是一样,至少在初始阶段。而在后期,消费者更新契约之前,仍然有必要事先与提供者进行讨论。

4、Pact更好用

Spring Cloud Contract不太适合消费者驱动的契约测试,总是需要消费者等待提供者完成相关工作,而Pact则不需要这种等待。

另外,SCC使用Groovy编写的契约需要手动与消费者代码保持同步,而Pact的API则相当成熟,会自动化生成各种代码和文件。同时SCC在提供者端提供的的设置和断言选项较少。Pact的社区支持也相对较好。

Pact对多语言的支持也更好。

小技巧

  1. 可以使用swagger-diff工具比较两个版本的API

swagger-diff工具可以用来比较两个Swagger API规范,并将结果输出到HTML或Markdown文件中。

点击这里复制本文地址 以上内容由goocz整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!

果子教程网 © All Rights Reserved.  蜀ICP备2024111239号-5