Skip to content

Commit

Permalink
Pact continuous testing
Browse files Browse the repository at this point in the history
Closes #164
  • Loading branch information
edeandrea committed Oct 18, 2022
1 parent 7d3d13b commit 4f4c70a
Show file tree
Hide file tree
Showing 12 changed files with 587 additions and 110 deletions.
7 changes: 7 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
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
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Also makes an assumption and hard-codes the Pact {@link au.com.dius.pact.consumer.MockServer MockServer}
* to be running on {@code localhost:8081}.
* </p>
* <p>
* Quarkus itself is set to run its tests on a random port, so port {@code 8081} should be available.
* </p>
*/
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<String, String> 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;
}
}
Loading

0 comments on commit 4f4c70a

Please sign in to comment.