微服务环境下的集成测试探索


【编者的话】现在微服务很流行,企业架构微服务化的确能解决不少问题,但是在微服务环境下,服务之间的依赖以及由此造成的开发、测试和集成的问题,一直都是微服务最大的痛点。

传统的解决方案是,除了测试、预发布和生产环境,还会部署多套用于开发和集成的环境。这样存在的问题是,只要有一组服务出现问题,就会影响其他使用该环境的团队的日常开发和测试。而且常常出现问题后,需要耗费很多时间定位,结果还常常是因为某个服务的版本没有同步。并且多套环境维护起来也是一个麻烦重重,即使有了容器。

这次我们一起来探索一下 API 模拟工具以及基于契约的测试,也许会是解决这个问题的一个方案。

WireMock 介绍

我们开发应用也好、服务也好,常常需要依赖后端或者服务的接口。例如开发移动应用 App,可能后端接口还在开发中,这时 App 的开发因为无法调用后端,很不方便。又或者程序会依赖第三方的接口,例如微信支付,在本地开发时不能直接调用。

这时我们就会需要一个工具来模拟这些服务,WireMock 就是这样的一个工具,主要针对的是最常见的 HTTP 服务。

WireMock 用于开发调试

WireMock 首先自身就是一个可以独立运行的服务。下载 Standalone Jar 文件后,即可可以直接运行。
java -jar wiremock-standalone-2.11.0.jar

此时可以通过 Json 映射文件来定义 Stub 服务。例如下面是一个映射文件,request 部分设置匹配的 Url 路径、请求方法及参数,如果匹配到了,则会返回 response 部分设置的内容。把该文件放到 WireMock 同路径下的 mappings目录下即可。
{
"request" : {
"urlPath" : "/api/order/find",
"method" : "GET",
"queryParameters" : {
  "orderId" : {
    "matches" : "^[0-9]{16}$"
  }
}
},
"response" : {
"status" : 200,
"bodyFileName" : "body-order-find-1.json",
"headers" : {
  "Content-Type" : "application/json;charset=UTF-8"
}
}


Response 的内容可以直接在映射文件里设置,也可以引用了另一个文件。这里是引用了一个名为 body-order-find-1.json 的文件,该文件放置在 WireMock 同路径下的 __files 目录下。
{
"success": true,
"data": {
    "id": 781202,
    "buyerId": -2,
    "status": 0,
    // 略...
}


下面我们用 curl 测试一下。第一次我们请求的参数 orderId 无法匹配指定的正则,WireMock 会返回 Request was not matched,而且还会很贴心的告诉你最接近的匹配是什么。
$ curl http://localhost:8080/api/order/find?orderId=abcdefghijklmnop
       Request was not matched
       =======================

----------------------------------------------
| Closest stub         | Request 
----------------------------------------------
GET                    | GET
/api/order/find        | /api/order/find
----------------------------------------------

第二次我们参数 orderId 匹配的话,WireMock 会直接返回设置的结果。
$ curl http://localhost:8080/api/order/find?orderId=9999999999999999
{
"success": true,
"data": {
    "id": 781202,
    "buyerId": -2,
    "status": 0
}


上面的例子是 WireMock 最基本的用法,除了请求匹配响应,WireMock 也能支持:
  • 通过 RESTFul 的接口提交和管理请求映射和相应。
  • 支持响应模板,返回内容时会将变量填充到响应模板中。当然,这里的模板功能是比较简单的,但对于大部分 Stub 的场景应该是足够了。
  • 支持模拟异常返回,例如设置有一定比例的超时返回等等,这个功能用于测试非常方便。


为了方便编写请求映射文件,WireMock 还可以运行在代理模式,只需要运行时添加 --enable-browser-proxying 参数即可。此时 WireMock 匹配到请求后,不是返回指定的内容,而是把请求 Forword 到指定的 URL,获得 Response 后再返回给调用方。同时,WireMock 会记录请求和返回的内容,生成 Json 映射文件。使用时只要根据需求对这些映射文件做一定修改,既可以用来模拟目标服务。

WireMock 用于集成测试

除了独立运行,WireMock 也可以直接嵌入到代码中。最方便的就是在 JUnit 中使用,WireMock 提供了 WireMockRule, 可以很方便的在测试时嵌入一个 Stub 服务。

下面是一个支付相关的集成测试,被测试方法会调用微信的支付服务。stubForUnifiedOrderSuccess 设置了一个很简单的 Stub,一旦匹配到请求的 URL 为 /pay/unifiedorder,那就返回指定的 XML 内容。这样我就可以在集成测试里测试整个支付流程,而不必依赖真正的微信支付。当然,测试时微信支付接口的 Host 也要改成 WireMockRule 配置的本地端口。并且,通过这种方式也很容易测试一些异常情况,根据需要修改 Stub 返回的内容即可。
public class OrderTest {
@Rule
public WireMockRule wireMockRule = new WireMockRule(9090);

/**     
 * 统一下单 Stub     
 * 参考 https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1     
 *     
 * @param tradeType 交易类型,可以是JSAPI、NATIVE或APP     
 */
public void stubForUnifiedOrderSuccess(String tradeType) {
    String unifiedOrderResp = "<xml>\n" +
            "    <return_code><![CDATA[SUCCESS]]></return_code>\n" +
            "    <return_msg><![CDATA[OK]]></return_msg>\n" +
            "    <appid><![CDATA[wxxxxxxxxxxxxxxxxx]]></appid>\n" +
            "    <mch_id><![CDATA[9999999999]]></mch_id>\n" +
            "    ...... \n" +
            "    <trade_type><![CDATA[" + tradeType + "]]></trade_type>\n" +
            "</xml>";
    stubFor(post(urlEqualTo("/pay/unifiedorder"))
            .withHeader("Content-Type", equalTo("text/xml;charset=UTF-8"))
            .willReturn(aResponse()
                    .withStatus(200)
                    .withHeader("Content-Type", "text/plain")
                    .withBody(unifiedOrderResp)));
}

@Test
public void test001_doPay() {
   stubForUnifiedOrderSuccess("JSAPI");
   payServices.pay();
    // 测试代码...
}


有时候在集成测试里,我们还需要验证系统的行为,例如是否调用了某个 API,调用了几次,调用的参数和内容是否符合要求等。区别于前面说的 Stub,其实这就是常说的 Mock 功能。WireMock 对此也有很强大的支持。
verify(postRequestedFor(urlEqualTo("/pay/unifiedorder"))
    .withHeader("Content-Type", equalTo("text/xml;charset=UTF-8"))
    .withQueryParam("param", equalTo("param1"))
    .withRequestBody(containing("success"));

这样,有了 WireMock,集成测试时处理第三方的依赖就非常方便了。不需要直接调用依赖的服务,也不需要专门创建用于集成测试的 Stub 或 Mock,直接代码中根据需要设置即可。

WireMock 总结

总结一下, WireMock 可以:
  • 作为代理运行,此时可以录制请求和返回的脚本,用于后继 Stub 和 Mock 使用。
  • 独立运行,作为一个 Stub 服务,根据匹配的请求返回数据。
  • 作为 Stub,通过代码嵌入 HTTP 模拟服务,在指定端口监听,并根据匹配的请求返回数据。
  • 作为 Mock,在单元测试和集成测试中,验证请求逻辑。例如是否进行了调用、参数是否正确等。


这里再强调下 Stub 和 Mock 的区别,很多人经常搞混。Stub 就是一个纯粹的模拟服务,用于替代真实的服务,收到请求返回指定结果,不会记录任何信息。Mock 则更进一步,还会记录调用行为,可以根据行为来验证系统的正确性。

我们可以用 WireMock 来优化开发和集成的流程。
  • 在外部服务尚未开发完成时,模拟服务,方便开发。
  • 在本地开发时,模拟外部服务避免直接依赖。
  • 在单元测试中模拟外部服务,同时验证业务逻辑。


契约式测试

本文主要以 WireMock 为例介绍了 API 模拟工具的使用方法。其实除了 WireMock,还有不少类似的工具,例如最早的 MounteBank,以及 MockServer、Moco 等也都是很强大的工具。

不过,在微服务环境下,光有 API 模拟工具还不够。对于 WireMock,首先必须考虑如何来管理大量的映射文件。一个方法是开发一个专用的 Stub 平台,来管理所有的映射文件,同时作为 Stub 运行。另外一个方法是通过 Git 来管理映射文件,需要的时候同步下来运行 WireMock 即可。

另外,我们上面提到 WireMock 的两大作用,调用方模拟服务以及服务方集成测试,是否可以统一两者呢?也就是说,调用方和服务方约定好接口,生成映射文件,这个文件即可以用于客户端模拟服务,也可以用于服务方集成测试,这样双方开发也好、集成也好都会方便很多。下面我们来研究一下 Spring Cloud Contract,它就是基于 WireMock 实现了契约式的测试,上文中双方约定好的接口,其实就是双方的契约。

微服务的集成

前面已经提到,传统方式下,微服务的集成以及测试都是一件很头痛的事情。其实在微服务概念还没有出现之前,在 SOA 流行的时候,就有人提出了消费者驱动契约(Consumer Driven Contract,CDC)的概念。微服务流行后,服务的集成和集成测试成了不得不解决问题,于是出现了基于消费者驱动契约的测试工具,最流行的应该就是 Pact,还有就是今天我们要说的 Spring Cloud Contract。

消费者驱动契约

熟悉敏捷开发的同学应该知道,敏捷开发提倡测试先行,相应的提出了不少方法和流程,例如测试驱动开发(Test Driven Design,TDD)、验收测试驱动开发(Acceptance Test Driven Development,ATDD)、行为驱动设计(Behavior Driven Design,BDD )、实例化需求(Specification By Example)等等。它们的共同特点在开发前就约定好了各种形式的契约。如果是单元测试作为契约,就是 TDD;如果是验收测试作为契约,就是 ATDD;如果是形式化语言甚至图表定义的业务规则,那就是 BDD 或者实例化需求。

对于基于 HTTP 的微服务来说,它的契约就是指 API 的请求和响应的规则。对于请求,包括请求 URL 及参数,请求头,请求内容等;对于响应,包括状态码,响应头,响应内容等。

在 Spring Cloud Contract 里,契约是用一种基于 Groovy 的 DSL 定义的。例如下面是一个短信接口的契约(省略了部分内容,例如 Content-Type 头等)。
org.springframework.cloud.contract.spec.Contract.make {
// 如果消费方发送了一个请求
request {                 
    // 请求方法是 POST               
    method 'POST'    
    // 请求 URL 是 `/sendsms`                   
    url '/sendsms'       
    // 请求内容是 Json 文本,包括电话号码和要发送的文本                
    body([         
           // 电话号码必须是13个数字组成                      
           phone: $(regex('[0-9]{13}')),  
               
           // 发送文本必须为"您好"
           content: "您好"                 
    ])
}
response {
    // 那么服务方应该返回状态码 200
    status 200        
    // 响应内容是 Json 文本,内容为 { "success": true }                   
    body([                               
           success: true
    ])
}} 

使用 CDC 开发服务的大致过程是这样的。
  1. 业务方和服务方相关人员一起讨论。业务方告知服务方接口使用的场景、期望的返回是什么,服务方考虑接口方案和实现,双方一起定下一个或多个契约。
  2. 确定了契约之后,Spring Cloud Contract 会给服务方自动生成验收测试,用于验证接口是否符合契约。服务方要确保开发完成后,这些验收测试都能够通过。
  3. 业务方也可以基于这个契约开始开发功能。Spring Cloud Contract 会基于契约生成 Stub 服务,这样业务方就不必等接口开发完成,可以通过 Stub 服务进行集成测试。


1.png

所以 CDC 和行为驱动设计(BDD)很类似,都是从使用者的需求出发,双方订立契约,测试先行的开发方法。不过一个是针对系统的验收,一个是针对服务的集成。CDC 的好处有以下几点:
  • 让服务方和调用方有充分的沟通,确保服务方提供接口都是以调用方的需求出发,并且服务方的开发者也可以充分理解调用方的使用场景。
  • 解耦和服务方和调用方的开发过程,一旦契约订立,双方都可以并行开发,通过 Mock 和自动化集成测试确保双方都遵守契约,最终集成也会更简单。
  • 通过 Mock 和自动化测试,可以确保双方在演进过程中,也不会破坏已有的契约。


但是要注意一点是,契约不包括业务逻辑,业务逻辑还是需要服务方和调用方通过单元测试、其他集成测试来确保。例如上面的短信服务,可能服务方会有一个逻辑是每天一个号码最多发送一条短信,但这个逻辑并不会包含在契约里,可能契约只有包含成功和错误两种情况。

Spring Cloud Contract 使用方法

服务方

Spring Cloud Contract 支持 Gradle 和 Maven,详细的配置文档就不细述了,请参考文档。对于服务方,Spring Cloud Contract 提供了一个叫 Contarct Verifier 的东西,用于解析契约文件生成测试。

如果使用 Gradle 的话,通过以下命令生成测试。
./gradlew generateContractTests

上面发送短信的契约,生成的测试代码是这样的。
public class SmsTest extends ContractBase {
@Test
public void validate_sendsms() throws Exception {
    // given:
        MockMvcRequestSpecification request = given()
                .body("{\"phone\":\"2066260255168\",\"content\":\"\u60A8\u597D\"}");
    // when:
        ResponseOptions response = given().spec(request)
                .post("/sendsms");
    // then:
        assertThat(response.statusCode()).isEqualTo(200);
    // and:
        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        assertThatJson(parsedJson).field("['success']").isEqualTo(true);
}


可以看到是一个很标准的 JUnit 测试,使用了 RestAssured 来测试 API 接口。其中的 ContractBase 是设置的测试基类,里面可以做一些配置以及 Setup 和 Teardown 操作。例如这里,我们需要用 RestAssured 来启动 Spring 的 webApplicationContext,当然我也可以用 standaloneSetup 设置启动单个 Controller。
@Before
public void setup() {
RestAssuredMockMvc.webAppContextSetup(webApplicationContext);


调用方

首先我们需要在服务方通过以下命令生成 Stub 服务的 Jar 包。
./gradlew verifierStubsJar

这个 Jar 包里面包含了契约文件以及生成的 WireMock 映射文件。我们可以把它发布到 Maven 私库里去,这样调用方可以直接从私库下载 Stub 的 Jar 包。

对于调用方,Spring Cloud Contract 提供了 Stub Runner 来简化 Stub 的使用。
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(webEnvironment= SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(repositoryRoot="http://<nexus_root>",
    ids = {"com.xingren.service:sms-client-stubs:1.5.0-SNAPSHOT:stubs:6565"})
public class ContractTest {
@Test
public void testSendSms() {
    ResponseEntity<SmsServiceResponse> response =
    restTemplate.exchange("http://localhost:6565/sendsms", HttpMethod.POST,
            new HttpEntity<>(request), SmsServiceResponse.class);
    // do some verification
}


注意注解 AutoConfigureStubRunner,里面设置了下载 Stub Jar 包的私库地址以及包的完整 ID,注意最后的 6565 就是指定 Stub 运行的本地端口。测试的时候访问 Stub 端口,就会根据契约返回内容。

前端开发

另外一个使用 Mock 的场景就是对于前端开发。以前,前端工程师一般需要自己创建 Mock 数据进行开发,但 Mock 数据很容易和后台最终提供的数据有不一致的地方。CDC 和 Spring Cloud Contract 也可以帮上忙。

Spring Cloud Contract 生成的 Stub 其实是 WireMock 的映射文件,因此直接使用 WireMock 也是可以的。不过,它还提供了使用 Spring Cloud Cli 运行 Stub 的方式。

首先需要安装 SpringBoot Cli 和 Spring Cloud Cli,Mac 下可以使用 Homebrew。
$ brew tap pivotal/tap
$ brew install springboot
$ spring install org.springframework.cloud:spring-cloud-cli:1.4.0.RELEASE

然后在当前目录创建一个 stubrunner.yml 配置文件,里面的配置参数和前面的 AutoConfigureStubRunner 的配置其实是一样的:
stubrunner:
workOffline: false
repositoryRoot: http://<nexus_root>
ids:
- com.xingren.service:sms-client-stubs:1.5.0-SNAPSHOT:stubs:6565

最后运行 spring cloud stubrunner,即可启动 Stub 服务。前端同学就可以愉快的使用 Stub 来进行前端开发了。

DSL

Spring Cloud Contract 的契约 DSL,既可以用于生成服务方的测试,也可以用于生成供调用方使用的 Stub,但是这两种方式对数据的验证方法有一些不同。对于服务方测试,DSL 需要提供请求内容,验证响应;而对于 Stub,DSL 需要匹配请求,提供响应内容。Spring Cloud Contract 提供了几种方式来处理。

一种方式是通过 $(consumer(...), producer(...)) 的语法(或者$(stub(...), test(...))、$(client(...), server(...))、$(c(...), p(...)),都是一样的)。例如:
org.springframework.cloud.contract.spec.Contract.make {
request {
    method('GET')
    url $(consumer(~/\/[0-9]{2}/), producer('/12'))    
   }   
response {        
    status 200        
    body(                
        name: $(consumer('Kowalsky'), producer(regex('[a-zA-Z]+')))
    )
}


上面就是指对于调用方,url 需要匹配 ~/\/[0-9]{2}/ 这个正则表达式,Stub 就会返回响应,其中 name 则为 Kowalsky。而对于服务方,生产的测试用例的请求 url 为 /12,它会验证响应中的 name 符合正则 '[a-zA-Z]+'。另外,Spring Cloud Contract 还提供了 stubMatchers 和 testMatchers 来支持更复杂的请求匹配和测试验证。

Spring Cloud Contract 现在还在快速发展中,目前对于生成测试用例的规则,还是有不够灵活的地方。例如,对于某些 Stub 应该返回,但生成的测试里不需要验证的字段,支持不太完善。还有对于 form-urlencoded 的请求,处理起来不如 Json 的请求那么方便。相信后继版本会改善。

总结

通过上面简单介绍,我们可以看到基于 Spring Cloud Contract 以及契约测试的方法,可以让微服务之间以及前后端之间的集成更顺畅。

另外前面还提到 Pact,它的优势是支持多种语言,但我们的环境都是基于 JVM 的,而 Spring Cloud Contract 和 SpringBoot 以及 Junit 的集成更简单方便。而且 Spring Cloud Contract 的另一个优势是它可以自动生成服务方的自动化测试。

原文链接:https://mp.weixin.qq.com/s/9aTgYSp9c1DOa63SkGlZNg

0 个评论

要回复文章请先登录注册