From 4f4c70a4ddc65c4645fb53ea1d74f8496f23984b Mon Sep 17 00:00:00 2001 From: Eric Deandrea Date: Tue, 18 Oct 2022 09:15:49 -0400 Subject: [PATCH] Pact continuous testing Closes #164 --- rest-fights/pom.xml | 7 ++ .../fight/client/HeroClientTestRunner.java | 68 +++++++++++ .../fight/client/HeroClientTests.java | 68 +++-------- .../client/HeroConsumerContractTests.java | 107 ++++++++++++++++++ .../PactConsumerContractTestProfile.java | 42 +++++++ .../fight/client/VillainClientTestRunner.java | 69 +++++++++++ .../fight/client/VillainClientTests.java | 68 +++-------- .../client/VillainConsumerContractTests.java | 107 ++++++++++++++++++ rest-heroes/pom.xml | 7 ++ .../hero/ContractVerificationTests.java | 75 ++++++++++++ rest-villains/pom.xml | 7 ++ .../villain/ContractVerificationTests.java | 72 ++++++++++++ 12 files changed, 587 insertions(+), 110 deletions(-) create mode 100644 rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroClientTestRunner.java create mode 100644 rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroConsumerContractTests.java create mode 100644 rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/PactConsumerContractTestProfile.java create mode 100644 rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainClientTestRunner.java create mode 100644 rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainConsumerContractTests.java create mode 100644 rest-heroes/src/test/java/io/quarkus/sample/superheroes/hero/ContractVerificationTests.java create mode 100644 rest-villains/src/test/java/io/quarkus/sample/superheroes/villain/ContractVerificationTests.java diff --git a/rest-fights/pom.xml b/rest-fights/pom.xml index c7fc1acedc..f5db5bcc95 100644 --- a/rest-fights/pom.xml +++ b/rest-fights/pom.xml @@ -12,6 +12,7 @@ 1.5.3.Final true 11 + 4.3.14 UTF-8 UTF-8 quarkus-bom @@ -195,6 +196,12 @@ quarkus-test-kafka-companion test + + au.com.dius.pact.consumer + junit5 + ${pact.version} + test + diff --git a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroClientTestRunner.java b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroClientTestRunner.java new file mode 100644 index 0000000000..a2637bf4c3 --- /dev/null +++ b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroClientTestRunner.java @@ -0,0 +1,68 @@ +package io.quarkus.sample.superheroes.fight.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import javax.inject.Inject; + +import io.smallrye.mutiny.helpers.test.UniAssertSubscriber; + +/** + * This class exists so that some of the tests and assertions with the Hero service + * can be re-used between the Pact consumer contract tests and the actual tests + * that use WireMock and perform more thorough verifications that don't belong + * in a consumer contract test. + */ +abstract class HeroClientTestRunner { + protected static final String HERO_API_BASE_URI = "/api/heroes"; + protected static final String HERO_RANDOM_URI = HERO_API_BASE_URI + "/random"; + protected static final String HERO_HELLO_URI = HERO_API_BASE_URI + "/hello"; + protected static final String DEFAULT_HERO_NAME = "Super Baguette"; + protected static final String DEFAULT_HERO_PICTURE = "super_baguette.png"; + protected static final String DEFAULT_HERO_POWERS = "eats baguette really quickly"; + protected static final int DEFAULT_HERO_LEVEL = 42; + protected static final String DEFAULT_HELLO_RESPONSE = "Hello heroes!"; + + @Inject + HeroClient heroClient; + + protected final void runHelloHeroes() { + this.heroClient.helloHeroes() + .subscribe().withSubscriber(UniAssertSubscriber.create()) + .assertSubscribed() + .awaitItem(Duration.ofSeconds(5)) + .assertItem(DEFAULT_HELLO_RESPONSE); + } + + protected final void runRandomHeroNotFound() { + this.heroClient.findRandomHero() + .subscribe().withSubscriber(UniAssertSubscriber.create()) + .assertSubscribed() + .awaitItem(Duration.ofSeconds(5)) + .assertItem(null); + } + + protected final void runRandomHeroFound(boolean verifyPowers) { + var hero = this.heroClient.findRandomHero() + .subscribe().withSubscriber(UniAssertSubscriber.create()) + .assertSubscribed() + .awaitItem(Duration.ofSeconds(5)) + .getItem(); + + assertThat(hero) + .isNotNull() + .extracting( + Hero::getName, + Hero::getLevel, + Hero::getPicture, + Hero::getPowers + ) + .containsExactly( + DEFAULT_HERO_NAME, + DEFAULT_HERO_LEVEL, + DEFAULT_HERO_PICTURE, + verifyPowers ? DEFAULT_HERO_POWERS : null + ); + } +} diff --git a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroClientTests.java b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroClientTests.java index 24fc2da758..5e7756e8b3 100644 --- a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroClientTests.java +++ b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroClientTests.java @@ -29,20 +29,12 @@ import io.smallrye.mutiny.helpers.test.UniAssertSubscriber; /** - * Tests for the {@link HeroClient}. Uses wiremock to stub responses and verify interactions. - * @see HeroesVillainsWiremockServerResource + * Tests for the {@link io.quarkus.sample.superheroes.fight.client.HeroClient}. Uses wiremock to stub responses and verify interactions. + * @see io.quarkus.sample.superheroes.fight.HeroesVillainsWiremockServerResource */ @QuarkusTest @QuarkusTestResource(HeroesVillainsWiremockServerResource.class) -class HeroClientTests { - private static final String HERO_API_BASE_URI = "/api/heroes"; - private static final String HERO_URI = HERO_API_BASE_URI + "/random"; - private static final String HERO_HELLO_URI = HERO_API_BASE_URI + "/hello"; - private static final String DEFAULT_HERO_NAME = "Super Baguette"; - private static final String DEFAULT_HERO_PICTURE = "super_baguette.png"; - private static final String DEFAULT_HERO_POWERS = "eats baguette really quickly"; - private static final int DEFAULT_HERO_LEVEL = 42; - +class HeroClientTests extends HeroClientTestRunner { private static final Hero DEFAULT_HERO = new Hero( DEFAULT_HERO_NAME, DEFAULT_HERO_LEVEL, @@ -53,9 +45,6 @@ class HeroClientTests { @InjectWireMock WireMockServer wireMockServer; - @Inject - HeroClient heroClient; - @Inject ObjectMapper objectMapper; @@ -76,36 +65,15 @@ public void afterEach() { @Test public void findsRandom() { this.wireMockServer.stubFor( - get(urlEqualTo(HERO_URI)) + get(urlEqualTo(HERO_RANDOM_URI)) .willReturn(okForContentType(APPLICATION_JSON, getDefaultHeroJson())) ); IntStream.range(0, 5) - .forEach(i -> { - var hero = this.heroClient.findRandomHero() - .subscribe().withSubscriber(UniAssertSubscriber.create()) - .assertSubscribed() - .awaitItem(Duration.ofSeconds(5)) - .getItem(); - - assertThat(hero) - .isNotNull() - .extracting( - Hero::getName, - Hero::getLevel, - Hero::getPicture, - Hero::getPowers - ) - .containsExactly( - DEFAULT_HERO_NAME, - DEFAULT_HERO_LEVEL, - DEFAULT_HERO_PICTURE, - DEFAULT_HERO_POWERS - ); - }); + .forEach(i -> runRandomHeroFound(true)); this.wireMockServer.verify(5, - getRequestedFor(urlEqualTo(HERO_URI)) + getRequestedFor(urlEqualTo(HERO_RANDOM_URI)) .withHeader(ACCEPT, equalTo(APPLICATION_JSON)) ); } @@ -113,21 +81,15 @@ public void findsRandom() { @Test public void recoversFrom404() { this.wireMockServer.stubFor( - get(urlEqualTo(HERO_URI)) + get(urlEqualTo(HERO_RANDOM_URI)) .willReturn(notFound()) ); IntStream.range(0, 5) - .forEach(i -> - this.heroClient.findRandomHero() - .subscribe().withSubscriber(UniAssertSubscriber.create()) - .assertSubscribed() - .awaitItem(Duration.ofSeconds(5)) - .assertItem(null) - ); + .forEach(i -> runRandomHeroNotFound()); this.wireMockServer.verify(5, - getRequestedFor(urlEqualTo(HERO_URI)) + getRequestedFor(urlEqualTo(HERO_RANDOM_URI)) .withHeader(ACCEPT, equalTo(APPLICATION_JSON)) ); } @@ -135,7 +97,7 @@ public void recoversFrom404() { @Test public void doesntRecoverFrom500() { this.wireMockServer.stubFor( - get(urlEqualTo(HERO_URI)) + get(urlEqualTo(HERO_RANDOM_URI)) .willReturn(serverError()) ); @@ -179,7 +141,7 @@ public void doesntRecoverFrom500() { // Verify that the server only saw 8 actual requests // (2 "real" requests and 3 retries each) this.wireMockServer.verify(8, - getRequestedFor(urlEqualTo(HERO_URI)) + getRequestedFor(urlEqualTo(HERO_RANDOM_URI)) .withHeader(ACCEPT, equalTo(APPLICATION_JSON)) ); } @@ -188,14 +150,10 @@ public void doesntRecoverFrom500() { public void helloHeroes() { this.wireMockServer.stubFor( get(urlEqualTo(HERO_HELLO_URI)) - .willReturn(okForContentType(TEXT_PLAIN, "Hello heroes!")) + .willReturn(okForContentType(TEXT_PLAIN, DEFAULT_HELLO_RESPONSE)) ); - this.heroClient.helloHeroes() - .subscribe().withSubscriber(UniAssertSubscriber.create()) - .assertSubscribed() - .awaitItem(Duration.ofSeconds(5)) - .assertItem("Hello heroes!"); + runHelloHeroes(); this.wireMockServer.verify(1, getRequestedFor(urlEqualTo(HERO_HELLO_URI)) diff --git a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroConsumerContractTests.java b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroConsumerContractTests.java new file mode 100644 index 0000000000..e6fa899187 --- /dev/null +++ b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/HeroConsumerContractTests.java @@ -0,0 +1,107 @@ +package io.quarkus.sample.superheroes.fight.client; + +import static au.com.dius.pact.consumer.dsl.LambdaDsl.newJsonBody; + +import java.util.Map; + +import javax.ws.rs.HttpMethod; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response.Status; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +import au.com.dius.pact.consumer.dsl.PactDslRootValue; +import au.com.dius.pact.consumer.dsl.PactDslWithProvider; +import au.com.dius.pact.consumer.junit5.PactConsumerTestExt; +import au.com.dius.pact.consumer.junit5.PactTestFor; +import au.com.dius.pact.core.model.PactSpecVersion; +import au.com.dius.pact.core.model.V4Pact; +import au.com.dius.pact.core.model.annotations.Pact; + +/** + * Pact consumer contract tests for the {@link io.quarkus.sample.superheroes.fight.client.HeroClient}. + */ +@QuarkusTest +@TestProfile(PactConsumerContractTestProfile.class) +@ExtendWith(PactConsumerTestExt.class) +@PactTestFor( + providerName = "rest-heroes", + pactVersion = PactSpecVersion.V4, + hostInterface = "localhost", + // Make an assumption and hard-code the Pact MockServer to be running on port 8081 + // I don't like it but couldn't figure out any other way + port = "8081" +) +@EnabledIfSystemProperty(named = "runConsumerContractTests", matches = "true", disabledReason = "runConsumerContractTests system property not set or equals false") +public class HeroConsumerContractTests extends HeroClientTestRunner { + @Pact(consumer = "rest-fights") + public V4Pact helloPact(PactDslWithProvider builder) { + return builder + .uponReceiving("A hello request") + .path(HERO_HELLO_URI) + .method(HttpMethod.GET) + .headers(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .willRespondWith() + .headers(Map.of(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN)) + .status(Status.OK.getStatusCode()) + .body(PactDslRootValue.stringMatcher(".+", DEFAULT_HELLO_RESPONSE)) + .toPact(V4Pact.class); + } + + @Pact(consumer = "rest-fights") + public V4Pact randomHeroNotFoundPact(PactDslWithProvider builder) { + return builder + .given("No random hero found") + .uponReceiving("A request for a random hero") + .path(HERO_RANDOM_URI) + .method(HttpMethod.GET) + .headers(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .willRespondWith() + .status(Status.NOT_FOUND.getStatusCode()) + .toPact(V4Pact.class); + } + + @Pact(consumer = "rest-fights") + public V4Pact randomHeroFoundPact(PactDslWithProvider builder) { + return builder + .uponReceiving("A request for a random hero") + .path(HERO_RANDOM_URI) + .method(HttpMethod.GET) + .headers(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .willRespondWith() + .status(Status.OK.getStatusCode()) + .headers(Map.of(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)) + .body(newJsonBody(body -> + body + .stringType("name", DEFAULT_HERO_NAME) + .integerType("level", DEFAULT_HERO_LEVEL) + .stringType("picture", DEFAULT_HERO_PICTURE) + ).build() + ) + .toPact(V4Pact.class); + } + + @Test + @PactTestFor(pactMethod = "helloPact") + void helloHeroes() { + runHelloHeroes(); + } + + @Test + @PactTestFor(pactMethod = "randomHeroNotFoundPact") + void randomHeroNotFound() { + runRandomHeroNotFound(); + } + + @Test + @PactTestFor(pactMethod = "randomHeroFoundPact") + void randomHeroFound() { + runRandomHeroFound(false); + } +} diff --git a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/PactConsumerContractTestProfile.java b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/PactConsumerContractTestProfile.java new file mode 100644 index 0000000000..fbff3e5d50 --- /dev/null +++ b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/PactConsumerContractTestProfile.java @@ -0,0 +1,42 @@ +package io.quarkus.sample.superheroes.fight.client; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +/** + * Quarkus {@link io.quarkus.test.junit.QuarkusTestProfile} for deriving a new profile for handling Pact consumer + * contract tests. Mostly here so that the Hero and Villain rest client URLs are + * set to point to the Pact {@link au.com.dius.pact.consumer.MockServer MockServer} and + * not the WireMock mocks. + *

+ * Also makes an assumption and hard-codes the Pact {@link au.com.dius.pact.consumer.MockServer MockServer} + * to be running on {@code localhost:8081}. + *

+ *

+ * Quarkus itself is set to run its tests on a random port, so port {@code 8081} should be available. + *

+ */ +public class PactConsumerContractTestProfile implements QuarkusTestProfile { + // Make an assumption and hard-code the Pact MockServer to be running on port 8081 + // I don't like it but couldn't figure out any other way + private static final String URL = "localhost:8081"; + + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.stork.hero-service.service-discovery.address-list", URL, + "quarkus.stork.villain-service.service-discovery.address-list", URL + ); + } + + @Override + public String getConfigProfile() { + return "pact-consumer-contract"; + } + + @Override + public boolean disableGlobalTestResources() { + return true; + } +} diff --git a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainClientTestRunner.java b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainClientTestRunner.java new file mode 100644 index 0000000000..ef16f37817 --- /dev/null +++ b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainClientTestRunner.java @@ -0,0 +1,69 @@ +package io.quarkus.sample.superheroes.fight.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import javax.inject.Inject; + +import io.smallrye.mutiny.helpers.test.UniAssertSubscriber; + +/** + * This class exists so that some of the tests and assertions with the Villain service + * can be re-used between the Pact consumer contract tests and the actual tests + * that use WireMock and perform more thorough verifications that don't belong + * in a consumer contract test. + */ +abstract class VillainClientTestRunner { + protected static final String VILLAIN_API_BASE_URI = "/api/villains"; + protected static final String VILLAIN_RANDOM_URI = VILLAIN_API_BASE_URI + "/random"; + protected static final String VILLAIN_HELLO_URI = VILLAIN_API_BASE_URI + "/hello"; + + protected static final String DEFAULT_VILLAIN_NAME = "Super Chocolatine"; + protected static final String DEFAULT_VILLAIN_PICTURE = "super_chocolatine.png"; + protected static final String DEFAULT_VILLAIN_POWERS = "does not eat pain au chocolat"; + protected static final int DEFAULT_VILLAIN_LEVEL = 42; + protected static final String DEFAULT_HELLO_RESPONSE = "Hello villains!"; + + @Inject + VillainClient villainClient; + + protected final void runHelloVillains() { + this.villainClient.helloVillains() + .subscribe().withSubscriber(UniAssertSubscriber.create()) + .assertSubscribed() + .awaitItem(Duration.ofSeconds(5)) + .assertItem(DEFAULT_HELLO_RESPONSE); + } + + protected final void runRandomVillainNotFound() { + this.villainClient.findRandomVillain() + .subscribe().withSubscriber(UniAssertSubscriber.create()) + .assertSubscribed() + .awaitItem(Duration.ofSeconds(5)) + .assertItem(null); + } + + protected final void runRandomVillainFound(boolean verifyPowers) { + var villain = this.villainClient.findRandomVillain() + .subscribe().withSubscriber(UniAssertSubscriber.create()) + .assertSubscribed() + .awaitItem(Duration.ofSeconds(10)) + .getItem(); + + assertThat(villain) + .isNotNull() + .extracting( + Villain::getName, + Villain::getLevel, + Villain::getPicture, + Villain::getPowers + ) + .containsExactly( + DEFAULT_VILLAIN_NAME, + DEFAULT_VILLAIN_LEVEL, + DEFAULT_VILLAIN_PICTURE, + verifyPowers ? DEFAULT_VILLAIN_POWERS : null + ); + } +} diff --git a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainClientTests.java b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainClientTests.java index e27de72471..80fc6d2f94 100644 --- a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainClientTests.java +++ b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainClientTests.java @@ -29,20 +29,12 @@ import io.smallrye.mutiny.helpers.test.UniAssertSubscriber; /** - * Tests for the {@link VillainClient}. Uses wiremock to stub responses and verify interactions. - * @see HeroesVillainsWiremockServerResource + * Tests for the {@link io.quarkus.sample.superheroes.fight.client.VillainClient}. Uses wiremock to stub responses and verify interactions. + * @see io.quarkus.sample.superheroes.fight.HeroesVillainsWiremockServerResource */ @QuarkusTest @QuarkusTestResource(HeroesVillainsWiremockServerResource.class) -class VillainClientTests { - private static final String VILLAIN_API_BASE_URI = "/api/villains"; - private static final String VILLAIN_API = VILLAIN_API_BASE_URI + "/random"; - private static final String VILLAIN_HELLO_URI = VILLAIN_API_BASE_URI + "/hello"; - private static final String DEFAULT_VILLAIN_NAME = "Super Chocolatine"; - private static final String DEFAULT_VILLAIN_PICTURE = "super_chocolatine.png"; - private static final String DEFAULT_VILLAIN_POWERS = "does not eat pain au chocolat"; - private static final int DEFAULT_VILLAIN_LEVEL = 42; - +class VillainClientTests extends VillainClientTestRunner { private static final Villain DEFAULT_VILLAIN = new Villain( DEFAULT_VILLAIN_NAME, DEFAULT_VILLAIN_LEVEL, @@ -53,9 +45,6 @@ class VillainClientTests { @InjectWireMock WireMockServer wireMockServer; - @Inject - VillainClient villainClient; - @Inject ObjectMapper objectMapper; @@ -76,36 +65,15 @@ public void afterEach() { @Test public void findsRandom() { this.wireMockServer.stubFor( - get(urlEqualTo(VILLAIN_API)) + get(urlEqualTo(VILLAIN_RANDOM_URI)) .willReturn(okForContentType(APPLICATION_JSON, getDefaultVillainJson())) ); IntStream.range(0, 5) - .forEach(i -> { - var villain = this.villainClient.findRandomVillain() - .subscribe().withSubscriber(UniAssertSubscriber.create()) - .assertSubscribed() - .awaitItem(Duration.ofSeconds(5)) - .getItem(); - - assertThat(villain) - .isNotNull() - .extracting( - Villain::getName, - Villain::getLevel, - Villain::getPicture, - Villain::getPowers - ) - .containsExactly( - DEFAULT_VILLAIN_NAME, - DEFAULT_VILLAIN_LEVEL, - DEFAULT_VILLAIN_PICTURE, - DEFAULT_VILLAIN_POWERS - ); - }); + .forEach(i -> runRandomVillainFound(true)); this.wireMockServer.verify(5, - getRequestedFor(urlEqualTo(VILLAIN_API)) + getRequestedFor(urlEqualTo(VILLAIN_RANDOM_URI)) .withHeader(ACCEPT, equalTo(APPLICATION_JSON)) ); } @@ -113,21 +81,15 @@ public void findsRandom() { @Test public void recoversFrom404() { this.wireMockServer.stubFor( - get(urlEqualTo(VILLAIN_API)) + get(urlEqualTo(VILLAIN_RANDOM_URI)) .willReturn(notFound()) ); IntStream.range(0, 5) - .forEach(i -> - this.villainClient.findRandomVillain() - .subscribe().withSubscriber(UniAssertSubscriber.create()) - .assertSubscribed() - .awaitItem(Duration.ofSeconds(5)) - .assertItem(null) - ); + .forEach(i -> runRandomVillainNotFound()); this.wireMockServer.verify(5, - getRequestedFor(urlEqualTo(VILLAIN_API)) + getRequestedFor(urlEqualTo(VILLAIN_RANDOM_URI)) .withHeader(ACCEPT, equalTo(APPLICATION_JSON)) ); } @@ -135,7 +97,7 @@ public void recoversFrom404() { @Test public void doesntRecoverFrom500() { this.wireMockServer.stubFor( - get(urlEqualTo(VILLAIN_API)) + get(urlEqualTo(VILLAIN_RANDOM_URI)) .willReturn(serverError()) ); @@ -179,7 +141,7 @@ public void doesntRecoverFrom500() { // Verify that the server only saw 8 actual requests // (2 "real" requests and 3 retries each) this.wireMockServer.verify(8, - getRequestedFor(urlEqualTo(VILLAIN_API)) + getRequestedFor(urlEqualTo(VILLAIN_RANDOM_URI)) .withHeader(ACCEPT, equalTo(APPLICATION_JSON)) ); } @@ -188,14 +150,10 @@ public void doesntRecoverFrom500() { public void helloVillains() { this.wireMockServer.stubFor( get(urlEqualTo(VILLAIN_HELLO_URI)) - .willReturn(okForContentType(TEXT_PLAIN, "Hello villains!")) + .willReturn(okForContentType(TEXT_PLAIN, DEFAULT_HELLO_RESPONSE)) ); - this.villainClient.helloVillains() - .subscribe().withSubscriber(UniAssertSubscriber.create()) - .assertSubscribed() - .awaitItem(Duration.ofSeconds(5)) - .assertItem("Hello villains!"); + runHelloVillains(); this.wireMockServer.verify(1, getRequestedFor(urlEqualTo(VILLAIN_HELLO_URI)) diff --git a/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainConsumerContractTests.java b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainConsumerContractTests.java new file mode 100644 index 0000000000..52d46b9257 --- /dev/null +++ b/rest-fights/src/test/java/io/quarkus/sample/superheroes/fight/client/VillainConsumerContractTests.java @@ -0,0 +1,107 @@ +package io.quarkus.sample.superheroes.fight.client; + +import static au.com.dius.pact.consumer.dsl.LambdaDsl.newJsonBody; + +import java.util.Map; + +import javax.ws.rs.HttpMethod; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response.Status; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +import au.com.dius.pact.consumer.dsl.PactDslRootValue; +import au.com.dius.pact.consumer.dsl.PactDslWithProvider; +import au.com.dius.pact.consumer.junit5.PactConsumerTestExt; +import au.com.dius.pact.consumer.junit5.PactTestFor; +import au.com.dius.pact.core.model.PactSpecVersion; +import au.com.dius.pact.core.model.V4Pact; +import au.com.dius.pact.core.model.annotations.Pact; + +/** + * Pact consumer contract tests for the {@link io.quarkus.sample.superheroes.fight.client.VillainClient}. + */ +@QuarkusTest +@TestProfile(PactConsumerContractTestProfile.class) +@ExtendWith(PactConsumerTestExt.class) +@PactTestFor( + providerName = "rest-villains", + pactVersion = PactSpecVersion.V4, + hostInterface = "localhost", + // Make an assumption and hard-code the Pact MockServer to be running on port 8081 + // I don't like it but couldn't figure out any other way + port = "8081" +) +@EnabledIfSystemProperty(named = "runConsumerContractTests", matches = "true", disabledReason = "runConsumerContractTests system property not set or equals false") +public class VillainConsumerContractTests extends VillainClientTestRunner { + @Pact(consumer = "rest-fights") + public V4Pact helloPact(PactDslWithProvider builder) { + return builder + .uponReceiving("A hello request") + .path(VILLAIN_HELLO_URI) + .method(HttpMethod.GET) + .headers(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN) + .willRespondWith() + .headers(Map.of(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN)) + .status(Status.OK.getStatusCode()) + .body(PactDslRootValue.stringMatcher(".+", DEFAULT_HELLO_RESPONSE)) + .toPact(V4Pact.class); + } + + @Pact(consumer = "rest-fights") + public V4Pact randomVillainNotFoundPact(PactDslWithProvider builder) { + return builder + .given("No random villain found") + .uponReceiving("A request for a random villain") + .path(VILLAIN_RANDOM_URI) + .method(HttpMethod.GET) + .headers(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .willRespondWith() + .status(Status.NOT_FOUND.getStatusCode()) + .toPact(V4Pact.class); + } + + @Pact(consumer = "rest-fights") + public V4Pact randomVillainFoundPact(PactDslWithProvider builder) { + return builder + .uponReceiving("A request for a random villain") + .path(VILLAIN_RANDOM_URI) + .method(HttpMethod.GET) + .headers(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .willRespondWith() + .status(Status.OK.getStatusCode()) + .headers(Map.of(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)) + .body(newJsonBody(body -> + body + .stringType("name", DEFAULT_VILLAIN_NAME) + .integerType("level", DEFAULT_VILLAIN_LEVEL) + .stringType("picture", DEFAULT_VILLAIN_PICTURE) + ).build() + ) + .toPact(V4Pact.class); + } + + @Test + @PactTestFor(pactMethod = "helloPact") + void helloVillains() { + runHelloVillains(); + } + + @Test + @PactTestFor(pactMethod = "randomVillainNotFoundPact") + void randomVillainNotFound() { + runRandomVillainNotFound(); + } + + @Test + @PactTestFor(pactMethod = "randomVillainFoundPact") + void randomVillainFound() { + runRandomVillainFound(false); + } +} diff --git a/rest-heroes/pom.xml b/rest-heroes/pom.xml index 7828f5b742..47dcc97627 100644 --- a/rest-heroes/pom.xml +++ b/rest-heroes/pom.xml @@ -11,6 +11,7 @@ 3.8.1 1.5.3.Final 11 + 4.3.14 UTF-8 UTF-8 quarkus-bom @@ -125,6 +126,12 @@ ${assertj.version} test + + au.com.dius.pact.provider + junit5 + ${pact.version} + test + diff --git a/rest-heroes/src/test/java/io/quarkus/sample/superheroes/hero/ContractVerificationTests.java b/rest-heroes/src/test/java/io/quarkus/sample/superheroes/hero/ContractVerificationTests.java new file mode 100644 index 0000000000..e762eb703a --- /dev/null +++ b/rest-heroes/src/test/java/io/quarkus/sample/superheroes/hero/ContractVerificationTests.java @@ -0,0 +1,75 @@ +package io.quarkus.sample.superheroes.hero; + +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.sample.superheroes.hero.repository.HeroRepository; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectSpy; + +import au.com.dius.pact.provider.junit5.HttpTestTarget; +import au.com.dius.pact.provider.junit5.PactVerificationContext; +import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider; +import au.com.dius.pact.provider.junitsupport.Provider; +import au.com.dius.pact.provider.junitsupport.State; +import au.com.dius.pact.provider.junitsupport.loader.PactBroker; +import au.com.dius.pact.provider.junitsupport.loader.PactBrokerConsumerVersionSelectors; +import au.com.dius.pact.provider.junitsupport.loader.SelectorBuilder; +import io.smallrye.mutiny.Uni; + +@QuarkusTest +@Provider("rest-heroes") +@PactBroker(url = "https://quarkus-super-heroes.pactflow.io") +@EnabledIfSystemProperty(named = "pactbroker.auth.token", matches = ".+", disabledReason = "pactbroker.auth.token system property not set") +public class ContractVerificationTests { + private static final String NO_RANDOM_HERO_FOUND_STATE = "No random hero found"; + + @ConfigProperty(name = "quarkus.http.test-port") + int quarkusPort; + + @InjectSpy + HeroRepository heroRepository; + + @TestTemplate + @ExtendWith(PactVerificationInvocationContextProvider.class) + void pactVerificationTestTemplate(PactVerificationContext context) { + context.verifyInteraction(); + } + + @BeforeEach + void beforeEach(PactVerificationContext context) { + context.setTarget(new HttpTestTarget("localhost", this.quarkusPort)); + + // Have to do this here because the CDI context doesn't seem to be available + // in the @State method below + var isNoRandomHeroFoundState = Optional.ofNullable(context.getInteraction().getProviderStates()) + .orElseGet(List::of) + .stream() + .filter(state -> NO_RANDOM_HERO_FOUND_STATE.equals(state.getName())) + .count() > 0; + + if (isNoRandomHeroFoundState) { + when(this.heroRepository.findRandom()) + .thenReturn(Uni.createFrom().nullItem()); + } + } + + @PactBrokerConsumerVersionSelectors + public static SelectorBuilder consumerVersionSelectors() { + return new SelectorBuilder() + .branch(System.getProperty("pactbroker.consumer.branch", "main")); + } + + @State(NO_RANDOM_HERO_FOUND_STATE) + public void clearData() { + // Already handled in beforeEach + } +} diff --git a/rest-villains/pom.xml b/rest-villains/pom.xml index 4bbe5c15ac..44547231d9 100644 --- a/rest-villains/pom.xml +++ b/rest-villains/pom.xml @@ -11,6 +11,7 @@ 3.8.1 1.5.3.Final 11 + 4.3.14 UTF-8 UTF-8 quarkus-bom @@ -125,6 +126,12 @@ ${assertj.version} test + + au.com.dius.pact.provider + junit5 + ${pact.version} + test + diff --git a/rest-villains/src/test/java/io/quarkus/sample/superheroes/villain/ContractVerificationTests.java b/rest-villains/src/test/java/io/quarkus/sample/superheroes/villain/ContractVerificationTests.java new file mode 100644 index 0000000000..519c187c04 --- /dev/null +++ b/rest-villains/src/test/java/io/quarkus/sample/superheroes/villain/ContractVerificationTests.java @@ -0,0 +1,72 @@ +package io.quarkus.sample.superheroes.villain; + +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; + +import io.quarkus.panache.mock.PanacheMock; +import io.quarkus.test.junit.QuarkusTest; + +import au.com.dius.pact.provider.junit5.HttpTestTarget; +import au.com.dius.pact.provider.junit5.PactVerificationContext; +import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider; +import au.com.dius.pact.provider.junitsupport.Provider; +import au.com.dius.pact.provider.junitsupport.State; +import au.com.dius.pact.provider.junitsupport.loader.PactBroker; +import au.com.dius.pact.provider.junitsupport.loader.PactBrokerConsumerVersionSelectors; +import au.com.dius.pact.provider.junitsupport.loader.SelectorBuilder; + +@QuarkusTest +@Provider("rest-villains") +@PactBroker(url = "https://quarkus-super-heroes.pactflow.io") +@EnabledIfSystemProperty(named = "pactbroker.auth.token", matches = ".+", disabledReason = "pactbroker.auth.token system property not set") +public class ContractVerificationTests { + private static final String NO_RANDOM_VILLAIN_FOUND_STATE = "No random villain found"; + + @ConfigProperty(name = "quarkus.http.test-port") + int quarkusPort; + + @TestTemplate + @ExtendWith(PactVerificationInvocationContextProvider.class) + void pactVerificationTestTemplate(PactVerificationContext context) { + context.verifyInteraction(); + } + + @BeforeEach + void beforeEach(PactVerificationContext context) { + context.setTarget(new HttpTestTarget("localhost", this.quarkusPort)); + + // Have to do this here because the CDI context doesn't seem to be available + // in the @State method below + var isNoRandomVillainFoundState = Optional.ofNullable(context.getInteraction().getProviderStates()) + .orElseGet(List::of) + .stream() + .filter(state -> NO_RANDOM_VILLAIN_FOUND_STATE.equals(state.getName())) + .count() > 0; + + if (isNoRandomVillainFoundState) { + PanacheMock.mock(Villain.class); + + when(Villain.findRandom()) + .thenReturn(Optional.empty()); + } + } + + @PactBrokerConsumerVersionSelectors + public static SelectorBuilder consumerVersionSelectors() { + return new SelectorBuilder() + .branch(System.getProperty("pactbroker.consumer.branch", "main")); + } + + @State(NO_RANDOM_VILLAIN_FOUND_STATE) + public void clearData() { + // Already handled in beforeEach + } +}