一文让你了解微服务契约测试

谈到微服务,大家都想到契约测试,到底什么是契约测试呢,为什么要使用契约测试呢,关于这样的文章很多,本文将结合Spring Boot让你了解微服务契约测试。

首先我们来看一下微服务,微服务是一种分布式结构,对于一种服务一方为服务的提供者,另一方为服务的消费者。我们用一个虚拟的阿里产品体系做个对比,比如登录这个接口,对于许多电商模块(我们称为服务)要使用,比如:天猫、淘宝、飞猪、盒马。这些登录接口可以由用户服务中台来提供,大家使用统一的登录接口,防止重复开发。

在没有契约测试之前,当用户接口没有提供天猫、淘宝、飞猪、盒马登录使用用户服务中台提供的Stub,用户服务中台提供的Stub由用户服务中台开发团队来维护,当用户服务中台开发完毕,天猫、淘宝、飞猪、盒马登录才使用真正的登录模块。由此可以看到每一个服务提供者模块在开发自身的业务模块之前,还要维护服务Stub模块,这样大大增加了开发成本。    

有了契约测试,只要服务提供者提供契约文件及基于契约文件自动产生的stub模块给服务消费者,服务消费者利用契约文件也产生同样的Stub模块,在服务提供者没有开发可用的真正的程序之前,利用Stub模块进行调试。以上是契约测试的一个优点,把维护stub模块变为了维护契约文件,这样大大地节约开发成本;另外还可以发现接口变动问题。

这是最初服务生产者提供的接口body接口

  1. {

  2. "年龄":"37"

  3. "性别":"男"

  4. "姓名":"王睿"

  5. }

服务消费者A提供的接口是

  1. {

  2. "年龄":"37"

  3. "性别":"男"

  4. }

没有“姓名”,这个是允许的。

服务消费者B提供的接口是

  1. {

  2. "年龄":"37"

  3. "性别":"男"

  4. "姓名":"王睿"

  5. }

这个与提供者完全一致,当然是允许的。

服务消费者C提供的接口是

  1. {

  2. "性别":"男"

  3. "姓名":"王睿"

  4. }

没有“年龄”,这个是允许的。

某一天,服务消费者C由于业务要求,需要把姓名中的姓与名拆成两部分,修改了body格式

  1. {

  2. "性别":"男"

  3. "姓":"王"

  4. "名":"睿"

  5. }

并提给服务生产者这个接口需求变更,生产者接受了这个请求,将契约文件改为

  1. {

  2. "性别":"男"

  3. "姓":"王"

  4. "名":"睿"

  5. }

当这个契约文件分发给各个服务消费者,由于服务消费者A提供的接口是

  1. {

  2. "年龄":"37"

  3. "性别":"男"

  4. }

由于没有“姓名”变更不受影响,而服务消费者B提供的接口是

  1. {

  2. “年龄”:"37"

  3. “性别”:"男"

  4. “姓名”:"王睿"

  5. }

姓名没有拆分,所以测试失败,告知大家,线下协商策略,决定 “姓名”是否修改。

在这里知道相关的服务消费者只有3个,而在实际的产品中服务多达成百上千个,有的是服务生产者。有的是服务消费者,大部分既是服务生产者又是服务消费者。当某一个接口发生变化,不运行契约测试不知道哪些模块会受到变动的影响,另外最后决定接口是否修改,也是根据fail接口的数量及fail接口的优先等级来决定的。

下面我们用一个具体的Spring Boot的案例来进行介绍,在这个案例中,流程是这样的。

  1. 服务生产者开发契约文件程序,自动形成契约文件。

  2. 将形成的契约文件打包上传到GitHub中。

  3. 服务消费者开发之前从GitHub中下载本地契约文件到本地目录下。

  4. 运行测试文件,验证测试是否满足现在的契约文件。

在这里我使用Spring Boot+cucumber+契约测试文章中的案例

服务生产者Spring Boot pom.xml文件如下:

 

<?xml version="1.0"

encoding="UTF-8"?>

<project

xmlns="http://maven.apache.org/POM/4.0.0"

xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

xsi:schemaLocation="http://maven.apache.org/POM/4.0.0

https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>

<parent>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-parent</artifactId>

<version>3.2.3</version>

<relativePath/>

<!-- lookup parent from repository -->

</parent>

<groupId>com.example</groupId>

<artifactId>ATMService</artifactId>

<version>0.0.1-SNAPSHOT</version>

<name>ATMService</name>

<description>Demo project for Spring Boot</description>

<properties>

<java.version>11</java.version>

<cucumber.version>6.8.1</cucumber.version>

<spring-cloud.version>4.1.0</spring-cloud.version>

<spring-cloud.version>2023.0.0</spring-cloud.version>

<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<snippetsDirectory>${project.build.directory}/generated-snippets</snippetsDirectory>

</properties>

<dependencies>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-web</artifactId>

</dependency>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-test</artifactId>

<scope>test</scope>

</dependency>


<dependency>

<groupId>org.springframework.cloud</groupId>

<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>

<scope>test</scope>

</dependency>


<dependency>

<groupId>org.springframework.cloud</groupId>

<artifactId>spring-cloud-contract-wiremock</artifactId>

<scope>test</scope>

</dependency>


<dependency>

<groupId>org.springframework.cloud</groupId>

<artifactId>spring-cloud-starter-contract-verifier</artifactId>

<scope>test</scope>

</dependency>


<dependency>

<groupId>org.junit.vintage</groupId>

<artifactId>junit-vintage-engine</artifactId>

<scope>test</scope>

</dependency>

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-test</artifactId>

<scope>test</scope>

</dependency>

<dependency>

<groupId>org.springframework.restdocs</groupId>

<artifactId>spring-restdocs-mockmvc</artifactId>

</dependency>

<dependency>

<groupId>org.springframework.cloud</groupId>

<artifactId>spring-cloud-dependencies</artifactId>

<version>${spring-cloud.version}</version>

<type>pom</type>

</dependency>

<dependency>

<groupId>io.cucumber</groupId>

<artifactId>cucumber-java</artifactId>

<version>${cucumber.version}</version>

<scope>test</scope>

</dependency>

<dependency>

<groupId>io.cucumber</groupId>

<artifactId>cucumber-spring</artifactId>

<version>${cucumber.version}</version>

<scope>test</scope>

</dependency>

<dependency>

<groupId>io.cucumber</groupId>

<artifactId>cucumber-junit-platform-engine</artifactId>

<version>${cucumber.version}</version>

<scope>test</scope>

</dependency>

</dependencies>

<dependencyManagement>

<dependencies>

<dependency>

<groupId>org.springframework.cloud</groupId>

<artifactId>spring-cloud-dependencies</artifactId>

<version>${spring-cloud.version}</version>

<type>pom</type>

<scope>import</scope>

</dependency>

</dependencies>

</dependencyManagement>

<build>

<plugins>

<plugin>

<groupId>org.springframework.cloud</groupId>

<artifactId>spring-cloud-contract-maven-plugin</artifactId>

<version>4.1.0</version>

<extensions>true</extensions>

<configuration>

<testFramework>JUNIT5</testFramework>

</configuration>

</plugin>

<plugin>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-maven-plugin</artifactId>

</plugin>

</plugins>

</build>

</project>

 在test下建立StubsGenerator.java

 

package com.example.ATMService;


import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;


import org.springframework.cloud.contract.wiremock.restdocs.SpringCloudContractRestDocs;


import org.junit.jupiter.api.Test;

import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;

import org.springframework.boot.test.context.SpringBootTest;

import org.springframework.test.web.servlet.MockMvc;

import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;


import org.springframework.beans.factory.annotation.Autowired;


@AutoConfigureRestDocs(outputDir = "target/stubs/META-INF/com.example")

@AutoConfigureMockMvc

@SpringBootTest

public class StubsGenerator {

@Autowired

private MockMvc mockMvc;

@Autowired

private MockMvc mockMvc;

@Test

public void verify_pin_ok()throws Exception {

mockMvc.perform(MockMvcRequestBuilders.get("/verify_pin/1111222233?pin=123456")

.contentType(MediaType.APPLICATION_JSON)

.accept(MediaType.APPLICATION_JSON))

.andExpect(status().isOk())

.andDo(document("verify_pin",SpringCloudContractRestDocs.dslContract()));

}


@Test

public void verify_pin_fail()throws Exception {

mockMvc.perform(MockMvcRequestBuilders.get("/verify_pin/1111222233?pin=654321")

.contentType(MediaType.APPLICATION_JSON)

.accept(MediaType.APPLICATION_JSON))

.andExpect(status().isOk())

.andDo(document("verify_pin_fail",SpringCloudContractRestDocs.dslContract()));

}

}

程序将模拟一个HTTP请求,并且建立契约文件

  • @AutoConfigureRestDocs(outputDir = "target/stubs/META-INF/com.example")//定义契约文件位置

  • 当/verify_pin/1111222233?pin=123456为Get请求时.andExpect(status().isOk()) //返回状态码为200;返回内容在Controller程序中定义。

  • .andDo(document("verify_pin",SpringCloudContractRestDocs.dslContract()));//建立名为verify_pin的契约文件

  • 当/verify_pin/1111222233?pin=654321为Get请求时

  • .andExpect(status().isOk()) //返回状态码为200;返回内容在Controller程序中定义。

  • .andDo(document("verify_pin_fail",SpringCloudContractRestDocs.dslContract()));//建立名为verify_pin_fail的契约文件

接下来我们看一下main中建立的pageController.java。

 

package com.example.ATMService.Controller;


import org.springframework.web.bind.annotation.GetMapping;

import org.springframework.web.bind.annotation.RestController;


@RestController

public class pageController {

@GetMapping("/verify_pin/1111222233")

public String list(String pin){

String list = "";

if (!pin.equals("123456")){

list = list + "{\"result\":\"Your PlN is apnalnd\"}";

}else {

list = list + "{\"result\":\"OK\"}";


}

return list;

}

}

@GetMapping("/verify_pin/1111222233")获得路径为"/verify_pin/1111222233"

当参数pin!="123456",返回body体为"{\"result\":\"Your PlN is apnalnd\"}"

否则返回body体为"{\"result\":\"OK\"}"。

用JUnit运行StubsGenerator.java,测试通过    

在@AutoConfigureRestDocs(outputDir = "target/stubs/META-INF/com.example")生成契约文件(如果pageController存在错误,系统将产生不了契约文件)。

verify_pin_fail.groovy与verify_pin.groovy是产生的契约文件。

 

import org.springframework.cloud.contract.spec.Contract


Contract.make {

request {

method 'GET'

urlPath('/verify_pin/1111222233') {

queryParameters {

parameter('''pin''', '''123456''')

}

}

headers {

header('''Accept''', '''application/json''')

header('''Content-Type''', '''application/json;charset=UTF-8''')

}

}

response {

status 200

body('''{"result":"OK"}''')

headers {

header('''Content-Type''', '''application/json''')

}

}

}

verify_pin_fail.groovy

 

import org.springframework.cloud.contract.spec.Contract


Contract.make {

request {

method 'GET'

urlPath('/verify_pin/1111222233') {

queryParameters {

parameter('''pin''', '''654321''')

}

}

headers {

header('''Accept''', '''application/json''')

header('''Content-Type''', '''application/json;charset=UTF-8''')

}

}

response {

status 200

body('''{"result":"Your PlN is apnalnd"}''')

headers {

header('''Content-Type''', '''application/json''')

}

}

}

verify_pin.json与verify_pin_fail.json是verify_pin_fail.groovy与verify_pin.groovy目标文件,也是真正起作用的文件。
verify_pin.json

 

{

"id" : "a8c7b023-e2dc-4105-956c-f6b46e65d447",

"request" : {

"urlPath" : "/verify_pin/1111222233",

"method" : "GET",

"headers" : {

"Content-Type" : {

"equalTo" : "application/json;charset=UTF-8"

},

"Accept" : {

"equalTo" : "application/json"

}

},

"queryParameters" : {

"pin" : {

"equalTo" : "123456"

}

}

},

"response" : {

"status" : 200,

"body" : "{\"result\":\"OK\"}",

"headers" : {

"Content-Type" : "application/json"

}

},

"uuid" : "a8c7b023-e2dc-4105-956c-f6b46e65d447"

}

verify_pin_fail.json

 

{

"id" : "ccf996bb-0388-4a3b-83d7-d2891a303c80",

"request" : {

"urlPath" : "/verify_pin/1111222233",

"method" : "GET",

"headers" : {

"Content-Type" : {

"equalTo" : "application/json;charset=UTF-8"

},

"Accept" : {

"equalTo" : "application/json"

}

},

"queryParameters" : {

"pin" : {

"equalTo" : "654321"

}

}

},

"response" : {

"status" : 200,

"body" : "{\"result\":\"Your PlN is apnalnd\"}",

"headers" : {

"Content-Type" : "application/json"

}

},

"uuid" : "ccf996bb-0388-4a3b-83d7-d2891a303c80"

}

将产生的verify_pin_fail.groovy与verify_pin.groovy契约文件(注意:不是verify_pin_fail.json与verify_pin.json)拷贝到src/test/resources/contracts下,运行mvn spring-cloud-contract:convert&&mvn spring-cloud-contract:run命令,当出现:

 

Host: []

Content-Length: [538]

Content-Type: [text/plain; charset=UTF-8]

Connection: [keep-alive]

User-Agent: [Apache-HttpClient/5.1.3 (Java/17.0.10)]

{

"id" : "16d3118b-27c6-48fb-b020-83dfd89ef7db",

"request" : {

"urlPath" : "/verify_pin/1111222233",

"method" : "GET",

"queryParameters" : {

"pin" : {

"equalTo" : "654321"

}

}

},

"response" : {

"status" : 200,

"body" : "{\"result\":\"Your PlN is apnalnd\"}",

"headers" : {

"Content-Type" : "application/json;charset=UTF-8"

},

"transformers" : [ "response-template", "spring-cloud-contract" ]

},

"uuid" : "16d3118b-27c6-48fb-b020-83dfd89ef7db"

}


[INFO] Started stub server for project [C:\Code\MyJava\javawork\card\target\stubs:+:stubs] on port 8080 with [2] mappings

[INFO] All stubs are now running RunningStubs [namesAndPorts={C:\Code\MyJava\javawork\card\target\stubs:+:stubs=8080}]

[INFO] Press ENTER to continue...

在浏览器中输入:http://127.0.0.1:8080/verify_pin/1111222233?pin=123456

输入:http://127.0.0.1:8080/verify_pin/1111222233?pin=654321

接下来将契约文件上传到GitHub中( 由于我没有GitHub Server,所以没有实现)。

第一次服务消费者从GitHub下载契约文件到本地,在target/generated-test-sources/contracts/org/springframework/cloud/contract/verifier/tests自动形成ContractVerifierTest.java文件。

 

package org.springframework.cloud.contract.verifier.tests;


import com.jayway.jsonpath.DocumentContext;

import com.jayway.jsonpath.JsonPath;

import org.junit.jupiter.api.Test;

import org.junit.jupiter.api.extension.ExtendWith;

import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification;

import io.restassured.response.ResponseOptions;


import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat;

import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*;

import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;

import static io.restassured.module.mockmvc.RestAssuredMockMvc.*;


@SuppressWarnings("rawtypes")

public class ContractVerifierTest {

@Test

public void validate_verify_pin() throws Exception {

// given:

MockMvcRequestSpecification request = given();

// when:

ResponseOptions response = given().spec(request)

.queryParam("pin","123456")

.get("/verify_pin/1111222233");


// then:

assertThat(response.statusCode()).isEqualTo(200);

assertThat(response.header("Content-Type")).isEqualTo("application/json;charset=UTF-8");


// and:

DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());

assertThatJson(parsedJson).field("['result']").isEqualTo("OK");

}


@Test

public void validate_verify_pin_fail() throws Exception {

// given:

MockMvcRequestSpecification request = given();


// when:

ResponseOptions response = given().spec(request)

.queryParam("pin","654321")

.get("/verify_pin/1111222233");


// then:

assertThat(response.statusCode()).isEqualTo(200);

assertThat(response.header("Content-Type")).isEqualTo("application/json;charset=UTF-8");


// and:

DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());

assertThatJson(parsedJson).field("['result']").isEqualTo("Your PlN is apnalnd");

}

}

启动本地服务,编写然后运行测试文件CardApplicationTests.java

 

import org.junit.jupiter.api.Assertions;

import org.junit.jupiter.api.Test;

import org.springframework.boot.test.context.SpringBootTest;

import org.springframework.web.client.RestTemplate;


@SpringBootTest

class CardApplicationTests {

private RestTemplate restTemplate = new RestTemplate();

@Test

void pass() throws Exception{

String result = restTemplate.getForObject("http://localhost:8080/verify_pin/1111222233?pin=123456", String.class);

Assertions.assertEquals(result, "{\"result\":\"OK\"}");


}


@Test

void fail() throws Exception{

String result = restTemplate.getForObject("http://localhost:8080/verify_pin/1111222233?pin=654321", String.class);

Assertions.assertEquals(result,"{\"result\":\"Your PlN is apnalnd\"}");

}

}

保证测试通过

以后每次运行CardApplicationTests.java之前,都从GitHub上下载契约文件,启动本地服务。如果测试fail,说明接口发生了变化,通过团队线下解决。 

最后感谢每一个认真阅读我文章的人!作为一位过来人也是希望大家少走一些弯路,如果你不想再体验一次学习时找不到资料,坚持几天便放弃的感受的话,在这里我给大家分享一些软件测试的学习资源,这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,希望能给你前进的路上带来帮助。如果你用得到的话可以直接拿走:

软件测试资料领取:[内部资源] 想拿年薪40W+的软件测试人员,这份资料必须领取~

软件测试面试刷题工具领取:软件测试面试刷题【800道面试题+答案免费刷】

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你! 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值