Skip to content

Commit

Permalink
Pact contract testing
Browse files Browse the repository at this point in the history
Closes #164
  • Loading branch information
edeandrea committed Oct 19, 2022
1 parent 7d3d13b commit 3f49d6f
Show file tree
Hide file tree
Showing 21 changed files with 1,024 additions and 115 deletions.
10 changes: 9 additions & 1 deletion .github/quarkus-ecosystem-test
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ run_build() {

for project in "event-statistics" "rest-fights" "rest-heroes" "rest-villains"
do
$project/mvnw -f $project/pom.xml --settings .github/quarkus-ecosystem-maven-settings.xml -B clean verify -Dquarkus.platform.group-id=io.quarkus -Dquarkus.platform.version=${QUARKUS_VERSION} -Dquarkus.http.host=0.0.0.0 -Dmaven.compiler.release=$java_version
$project/mvnw -f $project/pom.xml \
--settings .github/quarkus-ecosystem-maven-settings.xml \
-B clean verify \
-Dquarkus.platform.group-id=io.quarkus \
-Dquarkus.platform.version=${QUARKUS_VERSION} \
-Dquarkus.http.host=0.0.0.0 \
-Dmaven.compiler.release=$java_version \
-DrunContractVerificationTests=true \
-DrunConsumerContractTests=true
done
}

Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/simple-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ jobs:

- name: "build-test-jvm-${{ matrix.project }}-java-${{ matrix.java }}"
working-directory: ${{ matrix.project }}
run: ./mvnw -B clean verify -Dquarkus.http.host=0.0.0.0 -Dmaven.compiler.release=${{ matrix.java }}
run: |
./mvnw -B clean verify \
-Dquarkus.http.host=0.0.0.0 \
-Dmaven.compiler.release=${{ matrix.java }} \
-DrunContractVerificationTests=true \
-DrunConsumerContractTests=true
native-build-test:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -110,5 +115,7 @@ jobs:
run: |
./mvnw -B clean verify -Pnative \
-Dquarkus.http.host=0.0.0.0 \
-Dmaven.compiler.release=${{ matrix.java }}
-Dmaven.compiler.release=${{ matrix.java }} \
-DrunContractVerificationTests=true \
-DrunConsumerContractTests=true
24 changes: 23 additions & 1 deletion rest-fights/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
- [Service Discovery and Load Balancing](#service-discovery-and-client-load-balancing)
- [Service Discovery](#service-discovery)
- [Client-side Load Balancing](#client-side-load-balancing)
- [Testing](#testing)
- [Testing](#testing)
- [Contract testing with Pact](#contract-testing-with-pact)
- [Running the Application](#running-the-application)
- [Running Locally via Docker Compose](#running-locally-via-docker-compose)
- [Only Fights Service](#only-fights-service)
Expand Down Expand Up @@ -92,6 +93,27 @@ This application has a full suite of tests, including an [integration test suite
- The test suite configures the application to use the [in-memory connector](https://smallrye.io/smallrye-reactive-messaging/smallrye-reactive-messaging/3.11/testing/testing.html) from [SmallRye Reactive Messaging](https://smallrye.io/smallrye-reactive-messaging) (see the `%test.mp.messaging.outgoing.fights` configuration in [`application.properties`](src/main/resources/application.properties)) for verifying interactions with Kafka.
- The [integration test suite](src/test/java/io/quarkus/sample/superheroes/fight/rest/FightResourceIT.java) uses [Quarkus Dev Services](https://quarkus.io/guides/getting-started-testing#testing-dev-services) (see [`KafkaConsumerResource`](src/test/java/io/quarkus/sample/superheroes/fight/KafkaConsumerResource.java)) to interact with a Kafka instance so messages placed onto the Kafka broker by the application can be verified.

### 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-fights` application is a [Pact _Consumer_](https://docs.pact.io/consumer), and as such, should be responsible for defining the contracts between itself and its providers ([`rest-heroes`](../rest-heroes) & [`rest-villains`](../rest-villains)).

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.

This consumer generates the following contracts:
- [`HeroConsumerContractTests.java`](src/test/java/io/quarkus/sample/superheroes/fight/client/HeroConsumerContractTests.java) generates the [`rest-fights-rest-heroes.json`](../rest-heroes/src/test/resources/pacts/rest-fights-rest-heroes.json) contract.
- [`VillainConsumerContractTests.java`](src/test/java/io/quarkus/sample/superheroes/fight/client/VillainConsumerContractTests.java) generates the [`rest-fights-rest-villains.json`](../rest-villains/src/test/resources/pacts/rest-fights-rest-villains.json) contract.

The contracts are committed into the provider's version control simply for easy of use and reproducibility.

Additionally, Pact consumer contract tests don't currently work with Quarkus dev mode and continuous testing. Therefore, the consumer contract tests ([`HeroConsumerContractTests.java`](src/test/java/io/quarkus/sample/superheroes/fight/client/HeroConsumerContractTests.java) & [`VillainConsumerContractTests.java`](src/test/java/io/quarkus/sample/superheroes/fight/client/VillainConsumerContractTests.java)) are only executed if the `-DrunConsumerContractTests=true` flag is passed. You could do this when running `./mvnw test`, `./mvnw verify`, or `./mvnw package`.

The consumer contract 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
First you need to start up all of the downstream services ([Heroes Service](../rest-heroes) and [Villains Service](../rest-villains) - the [Event Statistics Service](../event-statistics) is optional).

Expand Down
8 changes: 8 additions & 0 deletions rest-fights/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<mapstruct.version>1.5.3.Final</mapstruct.version>
<maven.compiler.parameters>true</maven.compiler.parameters>
<maven.compiler.release>11</maven.compiler.release>
<pact.version>4.3.14</pact.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
Expand Down Expand Up @@ -195,6 +196,12 @@
<artifactId>quarkus-test-kafka-companion</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>junit5</artifactId>
<version>${pact.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
Expand Down Expand Up @@ -239,6 +246,7 @@
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
<pact_do_not_track>true</pact_do_not_track>
</systemPropertyVariables>
</configuration>
</plugin>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -53,9 +45,6 @@ class HeroClientTests {
@InjectWireMock
WireMockServer wireMockServer;

@Inject
HeroClient heroClient;

@Inject
ObjectMapper objectMapper;

Expand All @@ -76,66 +65,39 @@ 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))
);
}

@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))
);
}

@Test
public void doesntRecoverFrom500() {
this.wireMockServer.stubFor(
get(urlEqualTo(HERO_URI))
get(urlEqualTo(HERO_RANDOM_URI))
.willReturn(serverError())
);

Expand Down Expand Up @@ -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))
);
}
Expand All @@ -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))
Expand Down
Loading

0 comments on commit 3f49d6f

Please sign in to comment.