This workshop should take about 2 hours, depending on how deep you want to go into each topic.
This workshop is setup with a number of steps that can be run through. Each step is in a branch, so to run through a
step of the workshop just check out the branch for that step (i.e. git checkout step1
).
- JDK 17+
- Docker for step 11
- step 1: Simple Consumer calling Provider
- step 2: Client Tested but integration fails
- step 3: Pact to the rescue
- step 4: Verify the provider
- step 5: Back to the client we go
- step 6: Consumer updates contract for missing products
- step 7: Adding the missing states
- step 8: Authorization
- step 9: Implement authorisation on the provider
- step 10: Request Filters on the Provider
- step 11: Using a Pact Broker
NOTE: Each step is tied to, and must be run within, a git branch, allowing you to progress through each stage incrementally. For example, to move to step 2 run the following: git checkout step2
There are two components in scope for our workshop.
- Product Catalog application (Consumer). It provides a console interface to query the Product service for product information.
- Product Service (Provider). Provides useful things about products, such as listing all products and getting the details of an individual product.
We need to first create an HTTP client to make the calls to our provider service:
The Consumer has implemented the product service client which has the following:
GET /products
- Retrieve all productsGET /products/{id}
- Retrieve a single product by ID
The diagram below highlights the interaction for retrieving a product with ID 10:
You can see the client interface we created in consumer/src/main/au/com/dius/pactworkshop/consumer/ProductService.java
:
@Service
public class ProductService {
private final RestTemplate restTemplate;
@Autowired
public ProductService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public List<Product> getAllProducts() {
return restTemplate.exchange("/products",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<Product>>(){}).getBody();
}
public Product getProduct(String id) {
return restTemplate.getForEntity("/products/{id}", Product.class, id).getBody();
}
}
We can run the client with ./gradlew consumer:bootRun
- it should fail with the error below, because the Provider is not running.
Caused by: org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8085/products": Connection refused: connect; nested exception is java.net.ConnectException: Connection refused: connect
Move on to step 2
Now let's create a basic test for our API client. We're going to check 2 things:
- That our client code hits the expected endpoint
- That the response is marshalled into an object that is usable, with the correct ID
You can see the client interface test we created in consumer/src/test/java/au/com/dius/pactworkshop/consumer/ProductServiceTest.java
:
class ProductServiceTest {
private WireMockServer wireMockServer;
private ProductService productService;
@BeforeEach
void setUp() {
wireMockServer = new WireMockServer(options().dynamicPort());
wireMockServer.start();
RestTemplate restTemplate = new RestTemplateBuilder()
.rootUri(wireMockServer.baseUrl())
.build();
productService = new ProductService(restTemplate);
}
@AfterEach
void tearDown() {
wireMockServer.stop();
}
@Test
void getAllProducts() {
wireMockServer.stubFor(get(urlPathEqualTo("/products"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("[" +
"{\"id\":\"9\",\"type\":\"CREDIT_CARD\",\"name\":\"GEM Visa\",\"version\":\"v2\"},"+
"{\"id\":\"10\",\"type\":\"CREDIT_CARD\",\"name\":\"28 Degrees\",\"version\":\"v1\"}"+
"]")));
List<Product> expected = Arrays.asList(new Product("9", "CREDIT_CARD", "GEM Visa", "v2"),
new Product("10", "CREDIT_CARD", "28 Degrees", "v1"));
List<Product> products = productService.getAllProducts();
assertEquals(expected, products);
}
@Test
void getProductById() {
wireMockServer.stubFor(get(urlPathEqualTo("/products/50"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"50\",\"type\":\"CREDIT_CARD\",\"name\":\"28 Degrees\",\"version\":\"v1\"}")));
Product expected = new Product("50", "CREDIT_CARD", "28 Degrees", "v1");
Product product = productService.getProduct("50");
assertEquals(expected, product);
}
}
Let's run this test and see it all pass:
> ./gradlew consumer:test
BUILD SUCCESSFUL in 2s
Meanwhile, our provider team has started building out their API in parallel. Let's run our website against our provider (you'll need two terminals to do this):
# Terminal 1
❯ ./gradlew provider:bootRun
...
...
Tomcat started on port(s): 8085 (http) with context path ''
Started ProviderApplication in 1.67 seconds (JVM running for 2.039)
# Terminal 2
> ./gradlew consumer:bootRun --console plain
...
...
Started ConsumerApplication in 1.106 seconds (JVM running for 1.62)
Products
--------
1) Gem Visa
2) MyFlexiPay
3) 28 Degrees
Select item to view details:
You should now see 3 different products. Choosing an index number should display detailed product information.
Let's see what happens!
Doh! We are getting 404 every time we try to view detailed product information. On closer inspection, the provider only knows about /product/{id}
and /products
.
We need to have a conversation about what the endpoint should be, but first...
Move on to step 3
Unit tests are written and executed in isolation of any other services. When we write tests for code that talk to other services, they are built on trust that the contracts are upheld. There is no way to validate that the consumer and provider can communicate correctly.
An integration contract test is a test at the boundary of an external service verifying that it meets the contract expected by a consuming service — Martin Fowler
Adding contract tests via Pact would have highlighted the /product/{id}
endpoint was incorrect.
Let us add Pact to the project and write a consumer pact test for the GET /products/{id}
endpoint.
Provider states is an important concept of Pact that we need to introduce. These states help define the state that the provider should be in for specific interactions. For the moment, we will initially be testing the following states:
product with ID 10 exists
products exist
The consumer can define the state of an interaction using the given
property.
Note how similar it looks to our unit test:
In consumer/src/test/java/au/com/dius/pactworkshop/consumer/ProductConsumerPactTest.java
:
@ExtendWith(PactConsumerTestExt.class)
public class ProductConsumerPactTest {
@Pact(consumer = "FrontendApplication", provider = "ProductService")
RequestResponsePact getAllProducts(PactDslWithProvider builder) {
return builder.given("products exist")
.uponReceiving("get all products")
.method("GET")
.path("/products")
.willRespondWith()
.status(200)
.headers(headers())
.body(newJsonArrayMinLike(2, array ->
array.object(object -> {
object.stringType("id", "09");
object.stringType("type", "CREDIT_CARD");
object.stringType("name", "Gem Visa");
})
).build())
.toPact();
}
@Pact(consumer = "FrontendApplication", provider = "ProductService")
RequestResponsePact getOneProduct(PactDslWithProvider builder) {
return builder.given("product with ID 10 exists")
.uponReceiving("get product with ID 10")
.method("GET")
.path("/products/10")
.willRespondWith()
.status(200)
.headers(headers())
.body(newJsonBody(object -> {
object.stringType("id", "10");
object.stringType("type", "CREDIT_CARD");
object.stringType("name", "28 Degrees");
}).build())
.toPact();
}
@Test
@PactTestFor(pactMethod = "getAllProducts")
void getAllProducts_whenProductsExist(MockServer mockServer) {
Product product = new Product();
product.setId("09");
product.setType("CREDIT_CARD");
product.setName("Gem Visa");
List<Product> expected = Arrays.asList(product, product);
RestTemplate restTemplate = new RestTemplateBuilder()
.rootUri(mockServer.getUrl())
.build();
List<Product> products = new ProductService(restTemplate).getAllProducts();
assertEquals(expected, products);
}
@Test
@PactTestFor(pactMethod = "getOneProduct")
void getProductById_whenProductWithId10Exists(MockServer mockServer) {
Product expected = new Product();
expected.setId("10");
expected.setType("CREDIT_CARD");
expected.setName("28 Degrees");
RestTemplate restTemplate = new RestTemplateBuilder()
.rootUri(mockServer.getUrl())
.build();
Product product = new ProductService(restTemplate).getProduct("10");
assertEquals(expected, product);
}
private Map<String, String> headers() {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json; charset=utf-8");
return headers;
}
}
This test starts a mock server on a random port that acts as our provider service. To get this to work we update the URL in the Client
that we create, after initialising Pact.
To run only the Pact tests:
> ./gradlew consumer:test --tests '*PactTest'
Running this test still passes, but it creates a pact file which we can use to validate our assumptions on the provider side, and have conversation around.
❯ ./gradlew consumer:test --tests '*PactTest'
BUILD SUCCESSFUL in 6s
A pact file should have been generated in consumer/build/pacts/FrontendApplication-ProductService.json
NOTE: even if the API client had been graciously provided for us by our Provider Team, it doesn't mean that we shouldn't write contract tests - because the version of the client we have may not always be in sync with the deployed API - and also because we will write tests on the output appropriate to our specific needs.
Move on to step 4
We will need to copy the Pact contract file that was produced from the consumer test into the Provider module. This will help us verify that the provider can meet the requirements as set out in the contract.
Copy the contract located in consumer/build/pacts/FrontendApplication-Productservice.json
to provider/src/test/resources/pacts/FrontendApplication-Productservice.json
. Or run the Gradle task
> ./gradlew consumer:copyPacts
BUILD SUCCESSFUL in 1s
Now let's make a start on writing Pact tests to validate the consumer contract:
In provider/src/test/java/au/com/dius/pactworkshop/provider/ProductPactProviderTest.java
:
@Provider("ProductService")
@PactFolder("pacts")
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProductPactProviderTest {
@LocalServerPort
int port;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
@State("products exist")
void toProductsExistState() {
}
@State("product with ID 10 exists")
void toProductWithIdTenExistsState() {
}
}
To run only the verification tests:
> ./gradlew provider:test --tests '*Pact*Test'
We now need to validate the pact generated by the consumer is valid, by executing it against the running service provider, which should fail:
❯ ./gradlew provider:test --tests '*Pact*Test'
...
...
au.com.dius.pactworkshop.provider.ProductPactProviderTest > FrontendApplication - get product with ID 10 FAILED
java.lang.AssertionError at ProductPactProviderTest.java:33
2020-10-09 06:21:52.555 INFO 6404 --- [extShutdownHook] o.s.s.concurrent.Thread
2 tests completed, 1 failed
> Task :provider:test FAILED
The test has failed, as the expected path /products/{id}
is returning 404. We incorrectly believed our provider was following a RESTful design, but the authors were too lazy to implement a better routing solution 🤷🏻♂️.
The correct endpoint which the consumer should call is /product/{id}
.
Move on to step 5
We now need to update the consumer client and tests to hit the correct product path.
First, we need to update the GET route for the client:
In consumer/src/main/au/com/dius/pactworkshop/consumer/ProductService.java
:
...
public Product getProduct(String id) {
return restTemplate.getForEntity("/product/{id}", Product.class, id).getBody();
}
Then we need to update the Pact test ID 10 exists
to use the correct endpoint in path
.
In consumer/src/test/java/au/com/dius/pactworkshop/consumer/ProductConsumerPactTest.java
:
@Pact(consumer = "FrontendApplication", provider = "ProductService")
RequestResponsePact getOneProduct(PactDslWithProvider builder) {
return builder.given("product with ID 10 exists")
.uponReceiving("get product with ID 10")
.method("GET")
.path("/product/10")
.willRespondWith()
.status(200)
.headers(headers())
.body(newJsonBody(object -> {
object.stringType("id", "10");
object.stringType("type", "CREDIT_CARD");
object.stringType("name", "28 Degrees");
}).build())
.toPact();
}
...
Let's run and generate an updated pact file on the client:
❯ ./gradlew consumer:test --tests *PactTest
BUILD SUCCESSFUL in 7s
Now we run the provider tests again with the updated contract
Copy the updated contract located in consumer/build/pacts/FrontendApplication-ProductService.json
to provider/src/test/resources/pacts/FrontendApplication-Productservice.json
by running the command:
> ./gradlew consumer:copyPacts
BUILD SUCCESSFUL in 1s
Run the command:
❯ ./gradlew provider:test --tests '*Pact*Test'
...
...
BUILD SUCCESSFUL in 10s
Yay - green ✅!
Move on to step 6
We're now going to add 2 more scenarios for the contract
-
What happens when we make a call for a product that doesn't exist? We assume we'll get a
404
. -
What happens when we make a call for getting all products but none exist at the moment? We assume a
200
with an empty array.
Let's write a test for these scenarios, and then generate an updated pact file.
In consumer/src/test/java/au/com/dius/pactworkshop/consumer/ProductConsumerPactTest.java
:
@Pact(consumer = "FrontendApplication", provider = "ProductService")
RequestResponsePact noProductsExist(PactDslWithProvider builder) {
return builder.given("no products exist")
.uponReceiving("get all products")
.method("GET")
.path("/products")
.willRespondWith()
.status(200)
.headers(headers())
.body("[]")
.toPact();
}
@Pact(consumer = "FrontendApplication", provider = "ProductService")
RequestResponsePact productDoesNotExist(PactDslWithProvider builder) {
return builder.given("product with ID 11 does not exist")
.uponReceiving("get product with ID 11")
.method("GET")
.path("/product/11")
.willRespondWith()
.status(404)
.toPact();
}
@Test
@PactTestFor(pactMethod = "noProductsExist")
void getAllProducts_whenNoProductsExist(MockServer mockServer) {
RestTemplate restTemplate = new RestTemplateBuilder()
.rootUri(mockServer.getUrl())
.build();
List<Product> products = new ProductService(restTemplate).getAllProducts();
assertEquals(Collections.emptyList(), products);
}
@Test
@PactTestFor(pactMethod = "productDoesNotExist")
void getProductById_whenProductWithId11DoesNotExist(MockServer mockServer) {
RestTemplate restTemplate = new RestTemplateBuilder()
.rootUri(mockServer.getUrl())
.build();
HttpClientErrorException e = assertThrows(HttpClientErrorException.class,
() -> new ProductService(restTemplate).getProduct("11"));
assertEquals(404, e.getStatusCode().value());
}
Notice that our new tests look almost identical to our previous tests, and only differ on the expectations of the response - the HTTP request expectations are exactly the same.
❯ ./gradlew consumer:test --tests '*PactTest'
BUILD SUCCESSFUL in 1s
What does our provider have to say about this new test. Again, copy the updated pact file into the provider's pact directory:
> ./gradlew consumer:copyPacts
BUILD SUCCESSFUL in 1s
and run the command:
❯ ./gradlew provider:test --tests '*Pact*Test'
...
...
au.com.dius.pactworkshop.provider.ProductPactProviderTest > FrontendApplication - get all products FAILED
java.lang.AssertionError at ProductPactProviderTest.java:33
au.com.dius.pactworkshop.provider.ProductPactProviderTest > FrontendApplication - get product with ID 11 FAILED
java.lang.AssertionError at ProductPactProviderTest.java:33
2020-10-09 08:27:31.030 INFO 18048 --- [extShutdownHook] o.s.s.concurrent.Threa
4 tests completed, 2 failed
> Task :provider:test FAILED
FAILURE: Build failed with an exception.
We expected this failure, because the product we are requesting does in fact exist! What we want to test for, is what happens if there is a different state on the Provider. This is what is referred to as "Provider states", and how Pact gets around test ordering and related issues.
We could resolve this by updating our consumer test to use a known non-existent product, but it's worth understanding how Provider states work more generally.
Move on to step 7