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/README.md b/rest-heroes/README.md
index cee4c03970..5b9cc8d598 100644
--- a/rest-heroes/README.md
+++ b/rest-heroes/README.md
@@ -2,7 +2,8 @@
## Table of Contents
- [Introduction](#introduction)
- - [Exposed Endpoints](#exposed-endpoints)
+ - [Exposed Endpoints](#exposed-endpoints)
+- [Contract testing with Pact](#contract-testing-with-pact)
- [Running the Application](#running-the-application)
- [Running Locally via Docker Compose](#running-locally-via-docker-compose)
- [Deploying to Kubernetes](#deploying-to-kubernetes)
@@ -40,6 +41,23 @@ The following table lists the available REST endpoints. The [OpenAPI document](o
| `/api/heroes/{id}` | `DELETE` | | `204` | | Deletes Hero with id == `{id}` |
| `/api/heroes/hello` | `GET` | | `200` | `String` | Ping "hello" endpoint |
+## Contract testing with Pact
+[Pact](https://pact.io) is a code-first tool for testing HTTP and message integrations using `contract tests`. Contract tests assert that inter-application messages conform to a shared understanding that is documented in a contract. Without contract testing, the only way to ensure that applications will work correctly together is by using expensive and brittle integration tests.
+
+The `rest-heroes` application is a [Pact _Provider_](https://docs.pact.io/provider), and as such, should run provider verification tests against contracts produced by consumers.
+
+As [this README states](src/test/resources/pacts/README.md), contracts generally should be hosted in a [Pact Broker](https://docs.pact.io/pact_broker) and then automatically discovered in the provider verification tests.
+
+One of the main goals of the Superheroes application is to be super simple and just "work" by anyone who may clone this repo. That being said, we can't make any assumptions about where a Pact broker may be or any of the credentials required to access it.
+
+Therefore, the [Pact contract](src/test/resources/pacts/rest-fights-rest-heroes.json) is committed into this application's source tree inside the [`src/test/resources/pacts` directory](src/test/resources/pacts).
+
+Additionally, Pact provider verification tests don't currently work with Quarkus dev mode and continuous testing. Therefore, the [contract verification tests](src/test/java/io/quarkus/sample/superheroes/hero/ContractVerificationTests.java) are only executed if the `-DrunContractVerificationTests=true` flag is passed. You could do this when running `./mvnw test`, `./mvnw verify`, or `./mvnw package`.
+
+The provider verification tests **ARE** executed during this project's CI/CD processes. They run against any pull requests and any commits back to the `main` branch.
+
+There is a [Quarkus Pact extension](https://github.com/quarkiverse/quarkus-pact) under development aiming to make integration with Pact simple and easy. It will be integrated with this project once it becomes available.
+
## Running the Application
The application runs on port `8083` (defined by `quarkus.http.port` in [`application.yml`](src/main/resources/application.yml)).
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..c0efd8fb34
--- /dev/null
+++ b/rest-heroes/src/test/java/io/quarkus/sample/superheroes/hero/ContractVerificationTests.java
@@ -0,0 +1,79 @@
+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.PactBrokerConsumerVersionSelectors;
+import au.com.dius.pact.provider.junitsupport.loader.PactFolder;
+import au.com.dius.pact.provider.junitsupport.loader.SelectorBuilder;
+import io.smallrye.mutiny.Uni;
+
+@QuarkusTest
+@Provider("rest-heroes")
+@PactFolder("pacts")
+@EnabledIfSystemProperty(named = "runContractVerificationTests", matches = "true", disabledReason = "runContractVerificationTests system property not set or equals false")
+// You could comment out the @PactFolder and above @EnabledIfSystemPropery annotations
+// if you'd like to use a Pact broker. You'd also un-comment the following 2 annotations
+//@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-heroes/src/test/resources/pacts/README.md b/rest-heroes/src/test/resources/pacts/README.md
new file mode 100644
index 0000000000..f33459182f
--- /dev/null
+++ b/rest-heroes/src/test/resources/pacts/README.md
@@ -0,0 +1,13 @@
+The [Pact](https://pact.io) contracts in this directory would generally **NOT** be checked into source control like this. Instead, they would likely be pushed to a [Pact Broker](https://docs.pact.io/pact_broker) and then automatically discovered in the provider verification tests.
+
+One of the main goals of the Superheroes application is to be super simple and just "work" by anyone who may clone this repo. That being said, we can't make any assumptions about where a Pact broker may be or any of the credentials required to access it.
+
+It would be simple enough to modify [`ContractVerificationTests.java`](../../java/io/quarkus/sample/superheroes/hero/ContractVerificationTests.java) to use a Pact broker:
+1. Remove the `@PactFolder("pacts")` annotation
+2. Remove the `@EnabledIfSystemProperty` annotation
+ - This is only here because, currently, pact contract verification does not work in Quarkus dev mode/continuous testing, but does work if running the Maven's ```test`/`verify` goals.
+3. Place a `@PactBroker` annotation with a `url` parameter containing the location of the Pact broker.
+4. When you run the tests, specify the `pactbroker.auth.token` property so that the test can authenticate with the broker.
+5. _Optionally_, you could also add an `@EnabledIfSystemProperty(named = "pactbroker.auth.token", matches = ".+", disabledReason = "pactbroker.auth.token system property not set")` annotation.
+ - This would make sure that the verification tests **ONLY** ran if the `pactbroker.auth.token` property was set.
+ - If you run the verification tests and don't provide the token, the tests will fail.
diff --git a/rest-heroes/src/test/resources/pacts/rest-fights-rest-heroes.json b/rest-heroes/src/test/resources/pacts/rest-fights-rest-heroes.json
new file mode 100644
index 0000000000..19be2c486f
--- /dev/null
+++ b/rest-heroes/src/test/resources/pacts/rest-fights-rest-heroes.json
@@ -0,0 +1,161 @@
+{
+ "consumer": {
+ "name": "rest-fights"
+ },
+ "interactions": [
+ {
+ "comments": {
+ "testname": "io.quarkus.sample.superheroes.fight.client.HeroConsumerContractTests.helloHeroes()",
+ "text": [
+
+ ]
+ },
+ "description": "A hello request",
+ "key": "2ec6e2e8",
+ "pending": false,
+ "request": {
+ "headers": {
+ "Accept": [
+ "text/plain"
+ ]
+ },
+ "method": "GET",
+ "path": "/api/heroes/hello"
+ },
+ "response": {
+ "body": {
+ "content": "Hello heroes!",
+ "contentType": "text/plain",
+ "contentTypeHint": "DEFAULT",
+ "encoded": false
+ },
+ "headers": {
+ "Content-Type": [
+ "text/plain"
+ ]
+ },
+ "matchingRules": {
+ "body": {
+ "$": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "regex",
+ "regex": ".+"
+ }
+ ]
+ }
+ }
+ },
+ "status": 200
+ },
+ "type": "Synchronous/HTTP"
+ },
+ {
+ "comments": {
+ "testname": "io.quarkus.sample.superheroes.fight.client.HeroConsumerContractTests.randomHeroFound()",
+ "text": [
+
+ ]
+ },
+ "description": "A request for a random hero",
+ "key": "df5e15cd",
+ "pending": false,
+ "request": {
+ "headers": {
+ "Accept": [
+ "application/json"
+ ]
+ },
+ "method": "GET",
+ "path": "/api/heroes/random"
+ },
+ "response": {
+ "body": {
+ "content": {
+ "level": 42,
+ "name": "Super Baguette",
+ "picture": "super_baguette.png"
+ },
+ "contentType": "application/json",
+ "encoded": false
+ },
+ "headers": {
+ "Content-Type": [
+ "application/json"
+ ]
+ },
+ "matchingRules": {
+ "body": {
+ "$.level": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "integer"
+ }
+ ]
+ },
+ "$.name": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "type"
+ }
+ ]
+ },
+ "$.picture": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "type"
+ }
+ ]
+ }
+ }
+ },
+ "status": 200
+ },
+ "type": "Synchronous/HTTP"
+ },
+ {
+ "comments": {
+ "testname": "io.quarkus.sample.superheroes.fight.client.HeroConsumerContractTests.randomHeroNotFound()",
+ "text": [
+
+ ]
+ },
+ "description": "A request for a random hero",
+ "key": "ad777651",
+ "pending": false,
+ "providerStates": [
+ {
+ "name": "No random hero found"
+ }
+ ],
+ "request": {
+ "headers": {
+ "Accept": [
+ "application/json"
+ ]
+ },
+ "method": "GET",
+ "path": "/api/heroes/random"
+ },
+ "response": {
+ "status": 404
+ },
+ "type": "Synchronous/HTTP"
+ }
+ ],
+ "metadata": {
+ "pact-jvm": {
+ "version": "4.3.14"
+ },
+ "pactSpecification": {
+ "version": "4.0"
+ }
+ },
+ "provider": {
+ "name": "rest-heroes"
+ }
+}
diff --git a/rest-villains/README.md b/rest-villains/README.md
index 410258fcd1..4df59ecf97 100644
--- a/rest-villains/README.md
+++ b/rest-villains/README.md
@@ -3,6 +3,7 @@
## Table of Contents
- [Introduction](#introduction)
- [Exposed Endpoints](#exposed-endpoints)
+- [Contract testing with Pact](#contract-testing-with-pact)
- [Running the Application](#running-the-application)
- [Running Locally via Docker Compose](#running-locally-via-docker-compose)
- [Deploying to Kubernetes](#deploying-to-kubernetes)
@@ -40,6 +41,23 @@ The following table lists the available REST endpoints. The [OpenAPI document](o
| `/api/villains/{id}` | `DELETE` | | `204` | | Deletes Villain with id == `{id}` |
| `/api/villains/hello` | `GET` | | `200` | `String` | Ping "hello" endpoint |
+## Contract testing with Pact
+[Pact](https://pact.io) is a code-first tool for testing HTTP and message integrations using `contract tests`. Contract tests assert that inter-application messages conform to a shared understanding that is documented in a contract. Without contract testing, the only way to ensure that applications will work correctly together is by using expensive and brittle integration tests.
+
+The `rest-villains` application is a [Pact _Provider_](https://docs.pact.io/provider), and as such, should run provider verification tests against contracts produced by consumers.
+
+As [this README states](src/test/resources/pacts/README.md), contracts generally should be hosted in a [Pact Broker](https://docs.pact.io/pact_broker) and then automatically discovered in the provider verification tests.
+
+One of the main goals of the Superheroes application is to be super simple and just "work" by anyone who may clone this repo. That being said, we can't make any assumptions about where a Pact broker may be or any of the credentials required to access it.
+
+Therefore, the [Pact contract](src/test/resources/pacts/rest-fights-rest-villains.json) is committed into this application's source tree inside the [`src/test/resources/pacts` directory](src/test/resources/pacts).
+
+Additionally, Pact provider verification tests don't currently work with Quarkus dev mode and continuous testing. Therefore, the [contract verification tests](src/test/java/io/quarkus/sample/superheroes/villain/ContractVerificationTests.java) are only executed if the `-DrunContractVerificationTests=true` flag is passed. You could do this when running `./mvnw test`, `./mvnw verify`, or `./mvnw package`.
+
+The provider verification tests **ARE** executed during this project's CI/CD processes. They run against any pull requests and any commits back to the `main` branch.
+
+There is a [Quarkus Pact extension](https://github.com/quarkiverse/quarkus-pact) under development aiming to make integration with Pact simple and easy. It will be integrated with this project once it becomes available.
+
## Running the Application
The application runs on port `8084` (defined by `quarkus.http.port` in [`application.properties`](src/main/resources/application.properties)).
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..394c180be1
--- /dev/null
+++ b/rest-villains/src/test/java/io/quarkus/sample/superheroes/villain/ContractVerificationTests.java
@@ -0,0 +1,76 @@
+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.PactBrokerConsumerVersionSelectors;
+import au.com.dius.pact.provider.junitsupport.loader.PactFolder;
+import au.com.dius.pact.provider.junitsupport.loader.SelectorBuilder;
+
+@QuarkusTest
+@Provider("rest-villains")
+@PactFolder("pacts")
+@EnabledIfSystemProperty(named = "runContractVerificationTests", matches = "true", disabledReason = "runContractVerificationTests system property not set or equals false")
+// You could comment out the @PactFolder and above @EnabledIfSystemPropery annotations
+// if you'd like to use a Pact broker. You'd also un-comment the following 2 annotations
+//@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
+ }
+}
diff --git a/rest-villains/src/test/resources/pacts/README.md b/rest-villains/src/test/resources/pacts/README.md
new file mode 100644
index 0000000000..d35b77bdac
--- /dev/null
+++ b/rest-villains/src/test/resources/pacts/README.md
@@ -0,0 +1,13 @@
+The [Pact](https://pact.io) contracts in this directory would generally **NOT** be checked into source control like this. Instead, they would likely be pushed to a [Pact Broker](https://docs.pact.io/pact_broker) and then automatically discovered in the provider verification tests.
+
+One of the main goals of the Superheroes application is to be super simple and just "work" by anyone who may clone this repo. That being said, we can't make any assumptions about where a Pact broker may be or any of the credentials required to access it.
+
+It would be simple enough to modify [`ContractVerificationTests.java`](../../java/io/quarkus/sample/superheroes/villain/ContractVerificationTests.java) to use a Pact broker:
+1. Remove the `@PactFolder("pacts")` annotation
+2. Remove the `@EnabledIfSystemProperty` annotation
+ - This is only here because, currently, pact contract verification does not work in Quarkus dev mode/continuous testing, but does work if running the Maven's ```test`/`verify` goals.
+3. Place a `@PactBroker` annotation with a `url` parameter containing the location of the Pact broker.
+4. When you run the tests, specify the `pactbroker.auth.token` property so that the test can authenticate with the broker.
+5. _Optionally_, you could also add an `@EnabledIfSystemProperty(named = "pactbroker.auth.token", matches = ".+", disabledReason = "pactbroker.auth.token system property not set")` annotation.
+ - This would make sure that the verification tests **ONLY** ran if the `pactbroker.auth.token` property was set.
+ - If you run the verification tests and don't provide the token, the tests will fail.
diff --git a/rest-villains/src/test/resources/pacts/rest-fights-rest-villains.json b/rest-villains/src/test/resources/pacts/rest-fights-rest-villains.json
new file mode 100644
index 0000000000..32b17262b9
--- /dev/null
+++ b/rest-villains/src/test/resources/pacts/rest-fights-rest-villains.json
@@ -0,0 +1,161 @@
+{
+ "consumer": {
+ "name": "rest-fights"
+ },
+ "interactions": [
+ {
+ "comments": {
+ "testname": "io.quarkus.sample.superheroes.fight.client.VillainConsumerContractTests.helloVillains()",
+ "text": [
+
+ ]
+ },
+ "description": "A hello request",
+ "key": "2ec6e2e8",
+ "pending": false,
+ "request": {
+ "headers": {
+ "Accept": [
+ "text/plain"
+ ]
+ },
+ "method": "GET",
+ "path": "/api/villains/hello"
+ },
+ "response": {
+ "body": {
+ "content": "Hello villains!",
+ "contentType": "text/plain",
+ "contentTypeHint": "DEFAULT",
+ "encoded": false
+ },
+ "headers": {
+ "Content-Type": [
+ "text/plain"
+ ]
+ },
+ "matchingRules": {
+ "body": {
+ "$": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "regex",
+ "regex": ".+"
+ }
+ ]
+ }
+ }
+ },
+ "status": 200
+ },
+ "type": "Synchronous/HTTP"
+ },
+ {
+ "comments": {
+ "testname": "io.quarkus.sample.superheroes.fight.client.VillainConsumerContractTests.randomVillainFound()",
+ "text": [
+
+ ]
+ },
+ "description": "A request for a random villain",
+ "key": "937f7526",
+ "pending": false,
+ "request": {
+ "headers": {
+ "Accept": [
+ "application/json"
+ ]
+ },
+ "method": "GET",
+ "path": "/api/villains/random"
+ },
+ "response": {
+ "body": {
+ "content": {
+ "level": 42,
+ "name": "Super Chocolatine",
+ "picture": "super_chocolatine.png"
+ },
+ "contentType": "application/json",
+ "encoded": false
+ },
+ "headers": {
+ "Content-Type": [
+ "application/json"
+ ]
+ },
+ "matchingRules": {
+ "body": {
+ "$.level": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "integer"
+ }
+ ]
+ },
+ "$.name": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "type"
+ }
+ ]
+ },
+ "$.picture": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "type"
+ }
+ ]
+ }
+ }
+ },
+ "status": 200
+ },
+ "type": "Synchronous/HTTP"
+ },
+ {
+ "comments": {
+ "testname": "io.quarkus.sample.superheroes.fight.client.VillainConsumerContractTests.randomVillainNotFound()",
+ "text": [
+
+ ]
+ },
+ "description": "A request for a random villain",
+ "key": "7438ddcd",
+ "pending": false,
+ "providerStates": [
+ {
+ "name": "No random villain found"
+ }
+ ],
+ "request": {
+ "headers": {
+ "Accept": [
+ "application/json"
+ ]
+ },
+ "method": "GET",
+ "path": "/api/villains/random"
+ },
+ "response": {
+ "status": 404
+ },
+ "type": "Synchronous/HTTP"
+ }
+ ],
+ "metadata": {
+ "pact-jvm": {
+ "version": "4.3.14"
+ },
+ "pactSpecification": {
+ "version": "4.0"
+ }
+ },
+ "provider": {
+ "name": "rest-villains"
+ }
+}