目前支持的匹配验证方法:
对于Array和Map这样的数据结构,DSL也有相应匹配验证方法,我这里就不罗列了,请参考官网的介绍;
执行Miku端的测试
Test Case准备好后,我们就可以执行测试了。因为我们实际上是用的Junit的框架,所以和执行一般的单元测试是一样的:
./gradlew :example-consumer-miku:clean test |
成功执行后,你就可以在Pacts\Miku下面找到所有测试生成的契约文件。
发布契约文件到Pact Broker
契约文件,也就是Pacts\Miku下面的那些JSON文件,可以用来驱动Provider端的契约测试。由于我们的示例把Consumer和Provider都放在了同一个Codebase下面,所以Pacts\Miku下面的契约文件对Provider是直接可见的,而真实的项目中,往往不是这样,你需要通过某种途径把契约文件从Consumer端发送给Provider端。你可以选择把契约文件SCP到Provider的测试服务器去,也可以选择使用中间文件服务器来共享契约文件,你甚至可以直接人肉发邮件把契约文件扔给Provider的团队,然后告诉他们“这是我们的契约,你们看着办吧~”(当然,这样很Low ...),这些都是可行的。显然,Pact提供了更加优雅的方式,那就是使用Pact Broker。
当你准备好Broker后,就可以用它来方便的实现真正的消费者驱动的契约测试了。
好吧,我得承认,“准备”这两个字我用得有些轻描淡写,实际的情况是你可能需要费一番周折才能弄好一个Broker服务。目前有好些方法可以搭建Broker服务,你可以直接下载官网的源码然后自己折腾,也可以使用Docker来个一键了事,更可以直接找Pact官方申请一个公共的Broker,当然,那样做就得暴露你的契约给第三方服务器,真实的产品项目多半是不行的,但如果只是学习,那就事半功倍了,比如我们当前的这个示例就是如此。
将契约文件上传到Broker服务器非常简单:
./gradlew :example-consumer-miku:pactPublish |
然后你会在命令行下面看到类似这样的输出:
> Task :example-consumer-miku:pactPublish Publishing JunitDSLConsumer1-ExampleProvider.json ... HTTP/1.1 200 OK Publishing JunitDSLJsonBodyConsumer-ExampleProvider.json ... HTTP/1.1 200 OK Publishing JunitDSLLambdaJsonBodyConsumer-ExampleProvider.json ... HTTP/1.1 200 OK Publishing BaseConsumer-ExampleProvider.json ... HTTP/1.1 200 OK Publishing JunitRuleConsumer-ExampleProvider.json ... HTTP/1.1 200 OK Publishing JunitRuleMultipleInteractionsConsumer-ExampleProvider.json ... HTTP/1.1 200 OK Publishing JunitDSLConsumer2-ExampleProvider.json ... HTTP/1.1 200 OK |
上传完成之后,你就可以在我们的Broker服务器上面看到对于的契约内容了。
值得说明的是,你可以看到上面我们有7个Consumer对应1个Provdier。在真实的项目中,不应该是这样的,因为现在我们实际上只有一个Consumer Miku。我只是在不同的契约文件中对Consumer的名字做了不同的命名,目的只是展示一下Broker的这个漂亮的调用关系图。这只是一个示例,仅此而已。
至此,Pact测试中,Consumer端的工作我们就全部搞定了,剩下的就是Provider的活了。
Provider端的测试
在Provider端,我们使用Gradle的Plugin来执行契约测试,非常的简单,不需要写一行测试代码:
./gradlew :example-provider:pactVerify |
在Provider端执行契约测试之前,我们需要先启动Provider的应用。虽然通过gradle我们可以配置自动关停应用,但对于初学者,我还是建议大家多手动捣鼓捣鼓,不然你都不知道这个测试是怎么个跑法。啥?不知道怎么启动Provider?自己去本文的开头部分找去 ...
然后,你可以在命令行下面看到类似这样的输出:
Arimans-MacBook-Pro:pact-jvm-example ariman$ ./gradlew :example-provider:pactVerify > Task :example-provider:pactVerify_ExampleProvider Verifying a pact between Miku - Base contract and ExampleProvider [Using File /Users/ariman/Workspace/Pacting/pact-jvm-example/Pacts/Miku/BaseConsumer-ExampleProvider.json] Given WARNING: State Change ignored as there is no stateChange URL Consumer Miku returns a response which has status code 200 (OK) includes headers "Content-Type" with value "application/json;charset=UTF-8" (OK) has a matching body (OK) Given WARNING: State Change ignored as there is no stateChange URL Pact JVM example Pact interaction returns a response which has status code 200 (OK) includes headers "Content-Type" with value "application/json;charset=UTF-8" (OK) has a matching body (OK) ... Verifying a pact between JunitRuleMultipleInteractionsConsumer and ExampleProvider [from Pact Broker https://ariman.pact.dius.com.au/pacts/provider/ExampleProvider/consumer/JunitRuleMultipleInteractionsConsumer/version/1.0.0] Given WARNING: State Change ignored as there is no stateChange URL Miku returns a response which has status code 200 (OK) includes headers "Content-Type" with value "application/json;charset=UTF-8" (OK) has a matching body (OK) Given WARNING: State Change ignored as there is no stateChange URL Nanoha returns a response which has status code 200 (OK) includes headers "Content-Type" with value "application/json;charset=UTF-8" (OK) has a matching body (OK) |
从上面的结果可以看出,我们的测试既使用了来自本地的契约文件,也使用了来自Broker的契约文件。
由于我们示例使用的Broker服务器是公共的,任何调戏我们这个示例应用的小伙伴都能上传他们自己的契约文件,其中难免会存在错误的契约。所以,如果你发现来自Broker的契约让你的测试挂掉了,请不要惊慌哟。当然,因为是公共服务器,我会不定时的清空里面的契约文件,所以哪天你要是发现你之前上传的契约文件没有了,也不必大惊小怪。
相关的Gradle配置
OK,Provider和Miku感情故事我们就讲完了。在讲Nanoha之前,先让我们来看看Gradle的一些配置内容:
project(':example-consumer-miku') { ... test { systemProperties['pact.rootDir'] = "$rootDir/Pacts/Miku" } pact { publish { pactDirectory = "$rootDir/Pacts/Miku" pactBrokerUrl = mybrokerUrl pactBrokerUsername = mybrokerUser pactBrokerPassword = mybrokerPassword } } ... } project(':example-consumer-nanoha') { ... test { systemProperties['pact.rootDir'] = "$rootDir/Pacts/Nanoha" } ... } import java.net.URL project(':example-provider') { ... pact { serviceProviders { ExampleProvider { protocol = 'http' host = 'localhost' port = 8080 path = '/' // Test Pacts from local Miku hasPactWith('Miku - Base contract') { pactSource = file("$rootDir/Pacts/Miku/BaseConsumer-ExampleProvider.json") } hasPactsWith('Miku - All contracts') { pactFileLocation = file("$rootDir/Pacts/Miku") } // Test Pacts from Pact Broker hasPactsFromPactBroker(mybrokerUrl, authentication: ['Basic', mybrokerUser, mybrokerPassword]) // Test Pacts from local Nanoha // hasPactWith('Nanoha - With Nantionality') { // pactSource = file("$rootDir/Pacts/Nanoha/ConsumerNanohaWithNationality-ExampleProvider.json") // } // hasPactWith('Nanoha - No Nantionality') { // stateChangeUrl = new URL('http://localhost:8080/pactStateChange') // pactSource = file("$rootDir/Pacts/Nanoha/ConsumerNanohaNoNationality-ExampleProvider.json") // } } } } } |
Gradle的配置也是非常的简单的,Provider,Miku和Nanoha作为三个单独的应用,都是独立配置的,其中的一些关键信息:
systemProperties['pact.rootDir'] 指定了我们生成契约文件的路径;
Miku中的pact { ... }定义了我们Pact Broker的服务器地址,以及我们访问时需要的认证信息。
如果你想通过浏览器访问Broker,比如看上面的关系图,你也是需要这个认证信息的。这里的配置使用的是变量,真正的用户名和密码在哪儿?不告诉你,自己在代码里面找找吧( ̄▽ ̄)~*
Provider的hasPactWith()和hasPactsWith()指定了执行PactVerify时会去搜索的本地路径,相应的,hasPactsFromPactBroker则是指定了Broker的服务器地址;
为什么要注释掉Nanoha的契约文件路径呢?因为目前我们还没有生成Nanoha的契约文件,如果不注释掉它们的话,测试会报找不到文件的错误。我们可以在之后生成完Nanoha的契约文件后,再打开注释;
Provider与Nanoha间的契约测试
Nanoha端的契约测试和Miku端大同小异,只是我们会在Nanoha端使用ProviderState的特性。关于ProviderState的具体含义,大家可以参见官网的介绍.
准备Provider端的ProviderState
Provider会返回一个.nationality的字段,在真实项目里,它的值可能来自数据库(当然,也可能来自更下一层的API调用)。在我们的示例里面,简单起见,直接使用了Static的属性来模拟数据的存储:
provider.ulti.Nationality
public class Nationality { private static String nationality = "Japan"; public static String getNationality() { return nationality; } public static void setNationality(String nationality) { Nationality.nationality = nationality; } } |
然后,通过修改.nationality就可以模拟对存储数据的修改。所以,我们定义了一个控制器pactController,在/pactStateChange上面接受POST的reqeust来修改.nationality:
provider.PactController
@Profile("pact") @RestController public class PactController { @RequestMapping(value = "/pactStateChange", method = RequestMethod.POST) public void providerState(@RequestBody PactState body) { switch (body.getState()) { case "No nationality": Nationality.setNationality(null); System.out.println("Pact State Change >> remove nationality ..."); break; case "Default nationality": Nationality.setNationality("Japan"); System.out.println("Pact Sate Change >> set default nationality ..."); break; } } } |
因为这个控制器只是用来测试的,所以它应该只在非产品环境下才能可见,所以我们使用了一个pact的Profile Annotation来限制这个控制器只能在使用pact的profile时才能可见。
OK,总结一下就是:当Provider使用pact的profile运行时,它会在URL/pactStateChange上接受一个POST请求,来修改.nationality的值,再具体一些,可以被设置成默认值Japan,或者null。
Nanoha端的契约测试
Nanoha端的测试文件和Miku端的差不多,我们使用Lambda DSL,在一个文件里面写两个TestCase。
public class NationalityPactTest { PactSpecVersion pactSpecVersion; private void checkResult(PactVerificationResult result) { if (result instanceof PactVerificationResult.Error) { throw new RuntimeException(((PactVerificationResult.Error)result).getError()); } assertEquals(PactVerificationResult.Ok.INSTANCE, result); } @Test public void testWithNationality() { Map<String, String> headers = new HashMap<String, String>(); headers.put("Content-Type", "application/json;charset=UTF-8"); DslPart body = newJsonBody((root) -> { root.numberType("salary"); root.stringValue("fullName", "Takamachi Nanoha"); root.stringValue("nationality", "Japan"); root.object("contact", (contactObject) -> { contactObject.stringMatcher("Email", ".*@ariman.com", "takamachi.nanoha@ariman.com"); contactObject.stringType("Phone Number", "9090940"); }); }).build(); RequestResponsePact pact = ConsumerPactBuilder .consumer("ConsumerNanohaWithNationality") .hasPactWith("ExampleProvider") .given("") .uponReceiving("Query fullName is Nanoha") .path("/information") .query("fullName=Nanoha") .method("GET") .willRespondWith() .headers(headers) .status(200) .body(body) .toPact(); MockProviderConfig config = MockProviderConfig.createDefault(this.pactSpecVersion.V3); PactVerificationResult result = runConsumerTest(pact, config, mockServer -> { ProviderHandler providerHandler = new ProviderHandler(); providerHandler.setBackendURL(mockServer.getUrl()); Information information = providerHandler.getInformation(); assertEquals(information.getName(), "Takamachi Nanoha"); assertEquals(information.getNationality(), "Japan"); }); checkResult(result); } @Test public void testNoNationality() { Map<String, String> headers = new HashMap<String, String>(); headers.put("Content-Type", "application/json;charset=UTF-8"); DslPart body = newJsonBody((root) -> { root.numberType("salary"); root.stringValue("fullName", "Takamachi Nanoha"); root.stringValue("nationality", null); root.object("contact", (contactObject) -> { contactObject.stringMatcher("Email", ".*@ariman.com", "takamachi.nanoha@ariman.com"); contactObject.stringType("Phone Number", "9090940"); }); }).build(); RequestResponsePact pact = ConsumerPactBuilder .consumer("ConsumerNanohaNoNationality") .hasPactWith("ExampleProvider") .given("No nationality") .uponReceiving("Query fullName is Nanoha") .path("/information") .query("fullName=Nanoha") .method("GET") .willRespondWith() .headers(headers) .status(200) .body(body) .toPact(); MockProviderConfig config = MockProviderConfig.createDefault(this.pactSpecVersion.V3); PactVerificationResult result = runConsumerTest(pact, config, mockServer -> { ProviderHandler providerHandler = new ProviderHandler(); providerHandler.setBackendURL(mockServer.getUrl()); Information information = providerHandler.getInformation(); assertEquals(information.getName(), "Takamachi Nanoha"); assertEquals(information.getNationality(), null); }); checkResult(result); } } |
这两个TestCase的主要区别是:
我们对nationality的期望一个Japan,一个是null;
通过.given()方法来指定我们的ProviderState,从而控制在Provider端运行测试之前修改对应nationality的值;
Consumer端运行测试的方式还是一样的:
./gradlew :example-consumer-nanoha:clean test |
然后,就可以在Pacts\Nanoha路径下面找到生成的契约文件了。
Provider端的契约测试
启动Provider的应用
上面我们提到,运行Provider需要使用pact的profile,所以现在启动Provider的命令会有所不同:
export SPRING_PROFILES_ACTIVE=pact ./gradlew :example-provider:bootRun |
如果你之前已经启动了Provider,记得要kill掉哟,不然会端口占用的啦~
修改Gradle配置文件
我们在Consumer的契约中,使用.given()指定了ProviderState,但说到底,那里指定的只是一个字符串而已,真正干活的,还是Gradle,所以我们需要Gradle的相关配置:
build.gralde hasPactWith('Nanoha - With Nantionality') { pactSource = file("$rootDir/Pacts/Nanoha/ConsumerNanohaWithNationality-ExampleProvider.json") } hasPactWith('Nanoha - No Nantionality') { stateChangeUrl = new URL('http://localhost:8080/pactStateChange') pactSource = file("$rootDir/Pacts/Nanoha/ConsumerNanohaNoNationality-ExampleProvider.json") } |
这里,我们取消了之前对Nanoha的注释。第一个TestCase我们会测试使用默认的nationality=Japan。第二个TestCase,我们指定了stateChangeUrl,它会保证在测试运行之前,先发送一个POST请求给这个URL,然后我们的TestCase测试nationality=null。
执行契约测试
同样的方法执行契约测试:
./gradlew :example-provider:pactVerify |
然后你就可以在命令行下面看见对应的输出了。
验证我们的测试
如果你一字不漏的玩儿到了这里,那么恭喜你,你应该可以在自己的项目里去实践Pact了(好了,那个抄椅子的同学,你不用说了,我知道,你们用的是Python╮(╯_╰)╭)。
但是在离开本示例之前,还是发扬一下我们的测试精神吧,比如,搞点小破坏~
在Provider返回的body里面,Miku和Nanoha都有使用字段.name。如果某天,Provider想把.name改成.fullname,估计Miku和Nanoha就要跪了。这是一种经典的契约破坏场景,用来做我们的玩儿法再适合不过了。可是要那么玩儿的话,需要修改Provider的好些代码,想必不少测试的同学,特别是对Spring Boot不了解的同学就又要拍砖了。
所以还是让我们来个简单的吧,比如霸王硬上弓,直接把.name给miku了,哦,不对,是null了。
provider.InformationController
@RestController public class InformationController { ... information.setName(null); return information; } } |
喂,喂,干坐着干嘛,动手改呀!这行代码可是需要你们自己加上去的哟,即便它已经简单到只有一行。然后,那个写Python的,别告诉我你看不懂information.setName(null),Okay? ̄▽ ̄
最后,重新运行我们的契约测试,你就能看到一些长得像这样的东东啦~:
... Verifying a pact between Nanoha - No Nantionality and ExampleProvider [Using File /Users/ariman/Workspace/Pacting/pact-jvm-example/Pacts/Nanoha/ConsumerNanohaNoNationality-ExampleProvider.json] Given No nationality Query name is Nanoha returns a response which has status code 200 (OK) includes headers "Content-Type" with value "application/json;charset=UTF-8" (OK) has a matching body (FAILED) Failures: 0) Verifying a pact between Miku - Base contract and ExampleProvider - Pact JVM example Pact interactionVerifying a pact between Miku - Base contract and ExampleProvider - Pact JVM example Pact interaction Given returns a response which has a matching body $.name -> Expected 'Hatsune Miku' but received null ... |
上文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理。