- Git
- Node.js (>=8) and npm (>=5.5)
- Internet access for the broker, npm and GitHub access
- Basic understanding of JavaScript and Node.js or ExpressJS
Congratulations! You have been recruited to the most exciting hollywood startup in the history of entertainment - YouMDB!
YouMDB is a movies database service that makes use of bleeding edge blockchain technology, AR, VR, and IMIU-AR to provide information about movies and enables the latest Web ^2.3.1 interactions (because, semver) such as creating reviews, and real-time movie rating capabilities.
As the lead software engineer, you are tasked with building the backend services in a rich Service Oriented Architecture with many many many microservices. Perhaps too many. We'll leave that for another workshop.
Your mission dear Pacticipant, should you choose to accept it, is to seek out an efficient way of testing your microservices so that they're easy to reason about, easy to maintain, quick to test, and reliable to deploy without breaking in production. As always, should you or any of your team members be caught doing full deployment end-to-end tests (omg no!), Martin Fowler will disavow any knowledge of your actions. This README will self-destruct in five thousands microseconds.
Good luck brave Pacticipant!
YouMDB is made up of two primary services which take the following roles:
- The Movies service API - taking the role of a consumer (it consumes services from the Reviews service)
- The Reviews service API - taking the role of a provider
The consumer depends on the Reviews service API because for example, upon getting a request for a list of movies, it should include each movie's reviews statistics. There may be other interactions where the consumer and provider play together.
Goal: Install all required dependencies to begin the workshop.
- Clone the consumer repository and the provider repository to their respective directories
- Install npm modules for each project
- Populate the
.env
variable in the root of the project directory with the broker's details:
BROKER_BASE_URL=http://localhost:80
BROKER_USERNAME=guest
BROKER_PASSWORD=guest
Goal: Familiarize yourself with the project code-base.
The project has been scaffolded using the express generator tool which is used as a quick web application skeleton to start developing with.
The project has a simple example, yet unrelated to the workshop, for an API endpoint from the original scaffold (routes/users.js
) to help get you started around ExpressJS. Don't hesitate to jump on to the official ExpressJS Guide for some help around the framework: https://expressjs.com/en/guide/routing.html.
The project structure takes the following shape:
.
├1─ app.js
├2─ bin
│ └── www
├3─ package.json
├4─ config
│ ├── index.js
├5─ data
│ ├── movies.js
├6─ routes
│ ├── users.js
│ ├── <new-route-file-goes-here>.js
├7─ controllers
│ ├── <new-controller-file-goes-here>.js
├8─ services
│ ├── <new-service-file-goes-here>.js
├9─ repositories
│ ├── movies.js (or reviews.js for the provider)
├10 __tests__
│ ├── <new-test-spec-file-goes-here>.js
├11 pacts
│ ├── <pact-file-will-be-generated-here>.js
├12 .env
├13 logs
│ ├── pact.log
app.js
- the entry point for the Node.js web API which instantiates an ExpressJS web app and exports itbin/www
- uses the exported ExpressJS web app to bind it to a local hostpackage.json
- the project's manifest with the relevant npm run commands and dependenciesconfig/index.js
- used as a config store for configuration used through-out the projectdata/movies.json
- the movies storage represented as a JSON fileroutes/*
- the routes file for each endpointcontrollers/*
- controllers that handle the request and responseutils/*
- should contain utilities you use, for example the HTTP client library that queries the provider (Reviews service)repositories/movies.js
- encapsulates the data access and acts as a mocked in-memory persistent store__tests__/*
- the consumer contract test(s) you write will go herepacts/*
- (consumer-only) the generated pact contract will be placed here upon running a successful pact contract test.env
- the execution of npm commands or the API service might rely on having configuration in environment variables. This is the place to put them. The file is already added to.gitignore
but nevertheless be careful not to commit and push it.pact.log
- logs for the pact testing framework
The recommended layout of the project breaks into several abstractions such as controllers, utils and repositories and it will make it easy for you to follow that pattern and jump from one step to another during the workshop. It is definitely not a recommendation for best practices around projects structure but instead it's an attempt here in being concise and simple. With that said, if you're fairly confident with Node.js development you're more than welcome to follow any other project structures you see fit.
Requirements:
- The Movies service should expose an API endpoint (hopefully RESTful) that lists movies details.
- A movie's details are for you to come up with. Some hints are: title, genre, plot, year and length of the movie.
- A movie's details should include reviews statistics about each movie as part of the movie's details. Specifically, for each movie it should show how many reviews were written for it, and what is the total average of rating the movie received.
- The Reviews service should implement API endpoints per the contract that was decided upon.
The requirements set out above requires an interaction between the Movies and the Reviews services. As a consumer that drives the contract, come up with your expectations for the API from the Reviews services. Remember that this is not ************-driven contracts, and as such it is expected that both Movies and Reviews teams collaborate respectfully on the API contract.
- You probably need to add a new route
- The new route probably needs a new controller
- The new controller needs to find movies, right? There's already a repository created for you with the necessary methods to work on the data. However, you're welcome to make changes as you see fit.
- The Movies service needs a service to interact with the Reviews service and query it for the reviews statistics. You can create this service which is essentially an HTTP client, and place it under
utilities
then use it from the controller or another service to query the Reviews service. Hint: theaxios
HTTP client package is already installed for you. - Once you created the API how do you know it works? We're not yet doing TDD here so feel comfortable to hack away with curl as needed. P.S. don't forget to start the server, right?
npm run start
.
- As a provider you need to build the relevant endpoint per the contract agreed upon with the consumer.
- Follow the guidelines mentioned for the consumer as building blocks for your API.
Notes:
- You do not need to implement filtering the results
- You do not need to implement pagination for the results
Hint 1: Discuss the API contract with your Provider!
You need to query the Reviews service in order to fetch statistics about the movie. How would this data look like?
- What is going to be the endpoint you will call on the provider side?
- What do you expect to receive back as data? What structure of fields?
- Are you going to do a GET or a POST?
Hint 2: Scaffold out a quick and simple /movies endpoint
Let's get our hands dirty with a simple `/movies` endpoint that just returns all the movies in your database, and doesn't interact with anything else. Just to keep things simple and get the code rolling quick!
In app.js add a new router to handle all the requests to /movies
:
const moviesRouter = require('./routes/moviesRouter')
app.use('/movies', moviesRouter)
As the router references above, we need a new route.
Create a new file routes/moviesRouter.js and append it with the following:
const express = require('express')
const router = express.Router()
const MoviesController = require('../controllers/MoviesController')
router.get('/', MoviesController.getAll)
module.exports = router
Which declares a new /movies
endpoint that correlates with HTTP GET
requests and calls a movie controller
Let's get that new movies controller going!
Create a new file at controllers/MoviesController.js and let's drop the following code snippet into it:
const Repository = require('../repositories/movies')
class MoviesController {
static async getAll(req, res) {
try {
const movies = Repository.findAll()
return res.status(200).json([...movies.values()])
} catch (err) {
return res.status(500).end(err)
}
}
}
module.exports = MoviesController
In the above we defined a simpler controller that requires the repository, which is our data access layer. Then defined a method that upon receiving a request loads all the movies in the database, and sends them back.
Hint 3: Did you test out the /movies endpoint?
Let's test your newly `/movies` endpoint to make sure you've got everything working well so far. If you ping it, how does the data look like?
This is an expected response to make sure you're on track:
[
{
"id": "1",
"title": "The Matrix",
"plot": "A computer hacker learns from mysterious rebels about the true nature of his reality and his role in the war against its controllers.",
"year": 1999,
"length": 136,
"genre": [
"action",
"sci-fi"
]
},
{
"id": "2",
"title": "Interstellar",
"plot": "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
"year": 2014,
"length": 169,
"genre": [
"adventure",
"drama",
"sci-fi"
]
}
]
Hint 4: Did you finalize that contract?
So what's going on with the API Contract?
Did you the two teams Movies and Reviews sit together to finalize their contract? I sure hope so!
If not, this should be a good time to check-up on that and make sure you've got your expectation straight.
Hint 5: Let's build a utility to call the Reviews API service
It makes sense that we will have our own dedicated util class that handles interactions with the Reviews API. It's a simple HTTP client that will take the responsibility of all of these interactions.
Create this new utils/ReviewsClient.js file and append the following:
const axios = require('axios')
const config = require('../config/index')
class ReviewsClient {
static async getAllMoviesStatistics() {
const reviewsSummaryEndpoint = `${config.reviewsServiceBaseURL}/stats`
try {
const response = await axios.get(reviewsSummaryEndpoint, {
responseType: 'json'
})
return response.data
} catch (err) {
return []
}
}
}
module.exports = ReviewsClient
Let's summarize this short snippet:
- We're loading the app's configuration to get the domain, port and HTTP protocol for the Reviews API service
- We're then defining a method to call the remote API service and return the data as is
Does it all go according to plan so far?
Yes, we didn't yet make use of this new ReviewsClient library because we'd like to test it first, and what better way to test it than to create the contract for it?
Hint 1: Discuss the API contract with your Consumer!
The Movies service, and possibly other consumers, are going to need some data from you. Great stuff! It means some teams need you services and what a better way to start the day than an API design discussion?
Your consumers will probably need to know:
- which endpoint and HTTP methods to hit on your API
- How the data you will respond with is going to look like
Remember, while you own the API it is preferable to understand the needs of your consumers and create a discussion, instead of you dictating the terms of the contract.
Also note that you are not designing an API strictly for the Movies services. Maybe another service will need it too?
Hint 2: Propose this API contract to your Consumer
The API endpoint is: ``` /stats ```
Example data structure it will return:
[
{
"id": "1",
"totalReviews": 100,
"averageRating": 7.5
},
{
"id": "2",
"totalReviews": 32,
"averageRating": 8.5
}
]
Hint 3: Scaffold out a quick and simple /stats endpoint
Let's start by adding a new router for /stats
to app.js:
app.use('/stats', statsRouter)
Let's create the relevant router entry at routes/statsRouter.js, and add the following route definition for it:
const express = require('express')
const router = express.Router()
const StatsController = require('../controllers/StatsController')
router.get('/', StatsController.get)
module.exports = router
Let's create the relevant controller for StatsController
which is being referenced in the route.
Create a new file at controllers/StatsController.js, and add the following controller class and methods to it:
const Repository = require('../repositories/stats')
class StatsController {
static async get(req, res) {
try {
const movieIds = req.query.movieId || []
const statsSummaryDTO = await Repository.getAll(movieIds)
if (!statsSummaryDTO.length) {
return res.status(404).end()
}
return res.status(200).json(statsSummaryDTO)
} catch (err) {
console.error(err)
return res.status(500).end()
}
}
}
module.exports = StatsController
The simple controller:
- Imports and uses the Repository's data access layer to access information about its movie statistics and reviews
- Define the method and call the repository to get all stats based on a query parameter that is also supported here to search for specific movie IDs
Hint 4: Do you need to test the API quickly?
Start the Node.js API service:
$ npm run start
And run some API calls to it using curl
or similar.
Hint 5: Not seeing any data in the response for the API? Seed your database
Checkout the example database file at data/seed.json which includes an example dataset for the database.
Here's a snippet of what it should look like:
{
"stats": [
{
"id": "1",
"totalReviews": 100,
"averageRating": 7.5
},
{
"id": "2",
"totalReviews": 32,
"averageRating": 8.5
}
],
"reviews": []
}
You can test it out easily by overwriting the empty database at [db.json](./db.json)
like this:
$ cp ./data/seed.json ./db.json
And now re-run your Node.js API service again and send another request.
Once we have the service APIs for both consumer and provider endpoints, we can now proceed to contract testing for both of them.
Consumer contract testing is about ensuring that the interface outside to the provider is set as expected and is guarded by the contract, as well as makes sure that internally for the consumer, the consumer code doesn't break and adheres to the contract.
Due to the above, we can deduct that consumer contract testing is valuable when it runs fast as unit tests, and that it unit tests our HTTP client utility that interacts with the provider.
Requirements:
- Create/Update the Reviews HTTP client utility for a contract test as agreed between the consumer and provider.
Consumer Development Guidelines:
- Unit tests can be found in the
__tests__/
directory and are managed by the Jest testing framework. - For contract testing we use the open source Pact framework (
@pact-foundation/pact
) - At first, you need to setup the pact server to run as a mock for the Reviews service. Look for instantiating a
new Pact()
as the provider object and then setting it up to listen for interactions, i.e:provider.setup()
. - Next, continue to writing a unit test for the Reviews client which defines an interaction, triggers a method on the Reviews client utility and asserts the utility method as well as verify that the interaction has been made successfully. More on how to write a contract unit test below.
- Finally, after all the interactions and expectations been simulated it is required to call
provider.finalize()
which validates all interactions and creates the pact contract. - To execute your contract testing run
npm run test
just as if you'd run unit tests on other projects.
The heart of the consumer contract testing is the unit test. The Reviews client utility is by nature code that interacts with another service, and so its essence is to make calls such as API requests to its provider endpoints, massage responses and return them.
The concept is that we trigger real methods on the consumer endpoints or program code which sends real requests, and this is where the Pact framework comes in and spins up a mock server that catches these requests and responds the way it was programmed.
How does the pact mock service know what interactions to expect and what to reply? We need to set it up.
The beginning of a consumer contract test is therefore to setup an interaction with provider.addInteraction()
which expects a state
that defines the interaction, a withRequest
clause that specifies the request details such as the method used (i.e: GET), the path and any query parameters associated with it. Lastly an interaction includes a willRespondWith
clause which will specify the expected response from the provider including the HTTP status code, any response headers and finally the body of the response.
Once an interaction has been defined and recorded on the Pact framework's mock server, the next step is to trigger the Reviews client utility to make a real call, assert any response data that was returned, and then assert on provider.verify()
that the interaction has been met as expected.
We begin by creating a pact mock server that acts as the provider for the unit tests. To access the Pact object import it from the JavaScript SDK (we will need Matchers later on so that is imported too):
const { Pact, Matchers } = require('@pact-foundation/pact')
Next, we need to define the contract parties along with some details to tell the mock provider how to spin up:
provider = new Pact({
// Setting up the contract
consumer: 'The consumer name',
provider: 'The provider name',
port: 'What port the pact mock provider should listen on for incoming requests?',
// Details for the mock server
// Location for log file to help debug tests
log: process.cwd() + '/logs/pact.log',
// Directory to create the contract at
dir: process.cwd() + '/pacts',
// Debug logs log level
logLevel: 'INFO',
// Specification version
spec: 2
})
Finally, spin it up:
provider.setup()
Note: all the methods on the provider instance are asynchronous and return a promise.
In our use-case we are testing HTTP APIs, so a contract can be enforced on the expected request sent to the provider, and the expected response that the provider sends back. We call this set an interaction.
Interactions take place in a specific state the provider is in. For example, if an API is supposed to return 404 Not Found
then it means it should go into a state of 'no data'. More on that in the provider testing section. For the consumer testing, it is only required to define these states in each interaction.
An interaction there-fore, can be composed of the following:
state
- the provider's state for this interactionuponReceiving
- a description of the requestwithRequest
- an object that defines the contract for the expected request received by the providerwillRespondWith
- an object that defines the contract for the expected response sent by the provider
An example is as follows:
await provider.addInteraction({
state: 'Has a few dogs',
uponReceiving: 'a for a list of dogs filtered by dog type',
withRequest: {
method: 'GET',
path: `/dogs`,
query: {
'dogType': 'beagle'
}
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: [
{
'id': '1',
'name': 'leo'
}
]
}
})
To assert that indeed the interaction was successful the following verify()
promise call should resolve successfully, otherwise there was an error:
provider.verify()
If the client library under test that makes the HTTP API call returns a result, you might also want to assert on that.
Note: You probably want to consult the Contract Testing Guidelines and Best Practices for how to properly write contract tests.
Assuming that all tests passed successfully, we can instruct the pact library to create the pact contract from all the interactions and specifications:
provider.finalize()
This happens at the end of all the tests and you can expect to see the generated pact contract in the directory you specified when instantiating and setting up Pact (it should be in /pacts/
if you haven't changed the suggested configuration).
Remember that we have our ReviewsClient
utility? it's working so let's test it!
Hint 6: Let's get started with a bare-bones ReviewsClient.test.js, shall we?
Create the contract unit test file at tests/ReviewsClient.test.js and start pouring some basic code into it:
const ReviewsClient = require('../utils/ReviewsClient')
const { Pact, Matchers } = require('@pact-foundation/pact')
We're starting out with importing our ReviewsClient which we will test, and we're importing the Pact
and Matchers
objects, which:
- Pact - handles instantiating the pact mock service and interacting with it, providing the test harness altogether around the Pact framework and the contract
- Matchers - a helpful utility to help us match response API data structures
Hint 7: Declare the contract participants
The test is essentially defining the contract when it runs (successfully), and for that we need to define the two-sides of the contract.
When starting to declare the contract, you need to ask yourself:
- What is the name of the consumer service?
- What is the name of the provider service?
- Which port am I accessing the provider service at? I need to know this so I will be able to listen to requests on this port and shoot requests to it.
Add the following to the test:
const CONSUMER = 'Movies'
const PROVIDER = 'Reviews'
const MOCK_PROVIDER_PORT = 3002
describe('Reviews contract tests', () => {
let provider
beforeAll(async () => {
provider = new Pact({
consumer: CONSUMER,
provider: PROVIDER,
port: MOCK_PROVIDER_PORT,
log: process.cwd() + '/logs/pact.log',
dir: process.cwd() + '/pacts',
logLevel: 'INFO',
spec: 2
})
await provider.setup()
})
afterAll(async () => {
await provider.finalize()
})
})
In this test suite, we are instantiating a new Pact
service and calling provider.setup()
which spins up an actual HTTP service on the port we provided that awaits your client requests to be made to it.
We add a catch-all function (afterAll
) to make sure that once all of the contract assertions has been successfully verified we also create the actual Pact contract file (in /pacts
directory as noted in the beforeAll
initialization)
Hint 8: In a new sub test suite what does the interaction look like?
Now you want to nest a describe()
test suite block and start some assertions. One of which is that the reviews client util needs to receive statistics data.
Looking at this test skeleton, ask yourself what does an interaction look like?
- What is the request being sent by the ReviewsClient util? what HTTP method, and endpoint am I hitting on the remote service?
- What does the response being sent by the Reviews API service? What HTTP status code is being received back? what headers should I expect this response to be received with? What does the body of the HTTP message look like?
The two above questions create the contract.
Also to consider:
- Think about what type of matchers can help you to describe the response being sent back.
- What is the name of this request being made? and what state is the reviews service found in this interaction?
describe('Reviews client tests', () => {
test('should receive movie statistics when requesting all of them', async () => {
await provider.addInteraction({
// ... interaction goes here
})
// and don't forget you need to assert that it actually worked as expected:
await expect(provider.verify()).resolves.toBeTruthy()
})
})
Hint 9: Solution for above new sub test suite
This is how that test should look like:
describe('Reviews client tests', () => {
test('should receive movie statistics for specified movies', async () => {
await provider.addInteraction({
state: 'Has reviews statistics for movie',
uponReceiving: 'a request for all movies stats summary',
withRequest: {
method: 'GET',
path: `/stats`
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: Matchers.eachLike(
{
'id': Matchers.like('1'),
'totalReviews': Matchers.like(100),
'averageRating': Matchers.like(7.5)
}
)
}
})
const result = await ReviewsClient.getAllMoviesStatistics()
expect(result.length).toEqual(1)
await expect(provider.verify()).resolves.toBeTruthy()
})
Hint 10: Run the unit test
Go ahead and run the unit tests.
It's easy. Just like any other Jest unit tests you might have written before:
$ npm run test
I really hope they passed! $-)
Hint 11: Check the contract!
Did the unit tests pass? If so this is amazing! You wrote your first API contract.
Take a look at pacts/ directory and see which file exists there now. How does the API contract look like?
Hint 12: Are you really only testing positive scenarios?
Even though the Reviews API service was built by top-notch engineers, you know that at some point it's going to fail you, right? It might reply with a 500 server error or perhaps a 404 because no stats data exist. If that happens, you should know to expect it. Does a 404 return something in the body? I don't know, but surely you'd want to test that your ReviewsClient utility knows how to handle it and that this is enforced as part of the contract, right?
Hint 13: Testing for negative API responses too
Add the following test to the reviews test suite:
test('should handle a request that responds with no stats summary', async () => {
await provider.addInteraction({
state: 'Has no statistics',
uponReceiving: 'a request for movies statistics',
withRequest: {
method: 'GET',
path: `/stats`
},
willRespondWith: {
status: 404
}
})
const result = await ReviewsClient.getAllMoviesStatistics()
expect(result.length).toEqual(0)
await expect(provider.verify()).resolves.toBeTruthy()
})
Contract testing for the provider means that the provider needs to ensure that the API it provides adheres to the contract that was previously set.
To test that, the Pact framework in this case is used as a runner that replays the request to a running instance of the provider API, and asserts that the provider API correctly responded with the response that is in the contract.
These requests that the Pact runner sends are the interactions that were defined in the contract, which is a result of consumer contract testing. Interactions also have other metadata on them such as what state they are in. For example, if the provider's API need to respond with a 404 Not Found
when there isn't any data in the database, then in essence it needs to be set in such state where no data exists in the database.
A general flow of how provider testing would take place:
- Contracts already exist
- The provider API service spins up, waiting to handle requests
- A state management API service spins up, waiting to handle requests that tell it how to transition the provider's API service state from one to another - the purpose of this API is to manage interaction states and this is the major part of provider testing
- Pact framework downloads contracts
- Pact framework spins up a runner that replays the request as specified in each interaction, and asserts on the responses it received from the provider
- Pact framework, before replaying each request, makes a call to the state management API to set the required state
Note: providers would be blocked on consumers creating a contract file before they can go on with contract testing on their side.
Hint 6: Initializing a Pact verifier for contracts
We're setting up Pact. Instantiate a pact verifier instance as below in tests/ReviewsContracts.test.js:
const { Verifier } = require('@pact-foundation/pact')
const pactVerifier = new Verifier()
Hint 7: Replaying Contract Testing for the Provider
All that the Pact test harness does for the Provider is to download the contracts from the broker for the correct Consumer-Provider pair, and re-play them on a running Provider service.
All this configuration is provided to the Pact test framework which takes care of it.
Add the below snippet to the provider contract tet file tests/ReviewsContracts.test.js:
const options = {
provider: 'The provider name',
providerBaseUrl: 'The base url to hit the provider, for example: http://localhost:8080',
providerStatesSetupUrl: 'The URL for the states management API that pact verified will hit with each interaction state',
pactBrokerUrl: 'The URL for the broker from which the pact verifier will pull the contracts',
// Specific tags to fetch
tags: ['develop'],
// Authentication details for the pact broker
pactBrokerUsername: config.pactBrokerUsername,
pactBrokerPassword: config.pactBrokerPassword,
// Send verification results
publishVerificationResult: true,
// Set the provider version for the results that
// will be published to the broker after provider testing
providerVersion: pkg.version,
}
pactVerifier.verifyProvider(options)
Note: calling the verifyProvider()
method may take some time to complete because it is instantiating the pact runner as well as changing states, and interacting with the provider API all during this method call. As such, to ensure it can complete for the test case, enough time needs to be allotted for the test to run.
Per the note above, you may need to also update the contract test file with the following that informs jest to have a hard timeout of 30 seconds before the test file fails.
// this is required due to the time it takes to spin up the verification process
// such as: d/l the pacts from the broker and running the tests
jest.setTimeout(30000);
Hint 8: Updating configuration for Pact Broker integration
But wait, there's more to it.
Where does all of this configuration come from? What's the reference for the config
variable specified in the options
variable in the previous hint?
Add to the top of tests/ReviewsContracts.test.js the following modules and files that we need to import to access our configuration and package details.
const path = require('path')
const pkg = require(path.resolve('./package.json'))
const config = require(path.resolve('./config'))
Hint 9: Configuration library is cool, but where does configuration come from?
That's the correct question to ask!
Let's make sure we update the Node.js API service that we built with the relevant configuration values. Update config/index.js with the following:
require('dotenv').config()
const config = {
provider: 'Reviews',
providerBaseUrl: 'http://localhost:3002',
providerStatesSetupUrl: 'http://localhost:4011/setup',
pactBrokerUrl: process.env.BROKER_BASE_URL || 'http://localhost',
pactBrokerUsername: process.env.BROKER_USERNAME,
pactBrokerPassword: process.env.BROKER_PASSWORD,
pactConsumersTag: process.env.CONSUMERS_CONTRACT_TAG || 'develop'
}
module.exports = config
Note: check with the workshop trainer for the actual values of providerBaseUrl
, providerStatesSetupUrl
and possibly the rest of the pact broker details such as the username, password, and so on.
Hint 10: Setting up Provider States
So far we've just worked on creating a Pact-based test harness that downloads contracts and re-plays them against the Node.js API service. However, how does the application knows which data to return for each interaction in the contract? That's where states management comes in.
Note: State management is not something you required to add to your Node.js API service logic. It's a Pact-related state logic and is not expected to be part of your application for a production running service.
The states management service would expose an API endpoint that the pact runner can call and tell it to transition into a specific state.
State is usually managed in web applications using a persistency store like a database, so it makes sense that the states management service can re-use a repository from the provider's API, but there are of course other ways to do it.
In the following, is an example of a small ExpressJS web application that sets up a POST endpoint at /setup
to handle the pact runner requests to change states.
Let's set this small API that will handle the state in tests/providerStates.js. Note that we chose to put it in the tests/
folder because it's not an actual test spec file that the test framework (jest in our case) will run. Think of it as a test utility that helps us test. Maybe I should've called it tests-utils/
? What can I say, naming things is hard!
So let's scaffold out a quick express skeleton:
const express = require('express')
const logger = require('morgan')
const seed = require('./seed')
const STATES_API_PORT = 4011
const app = express()
const statesRouter = express.Router()
app.use(logger('dev'))
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use('/', statesRouter)
We've got the statesRouter for state management, let's make use of it with a decent controller:
// Setup endpoint
statesRouter.post('/setup', statesController)
// Setup the controller for the endpoint
async function statesController(req, res) {
try {
const state = req.body.state
console.log('Transition to state: ', state)
switch (state) {
case 'Has no statistics':
await seed.deleteAllStats()
break
case 'Has reviews statistics for movie':
await seed.deleteAllStats()
await seed.populateStats()
break
}
return res.status(200).end()
} catch (err) {
console.log(err)
return res.status(500).end()
}
}
What's the seed
thing doing?
Well it's just another test util that helps us seed data to the database, depending on the state that the interaction needs to transition into.
Take a look at tests/seed.js to see that it's a simple database CRUD interface for us to change database records. It even uses the same database abstraction layer (DAL) that we use in the Node.js API service for our business logic. In other words, The seed
dependency used here is merely a small wrapper around database commands to clean the database, or insert some records.
Even though seed.js
is just a test util, do go through it to understand what and why it does so you get this part nailed.
Lastly, add to our state management file the following code to make sure we actually reset the database state to begin with, and then begin listening for HTTP requests:
// Perform any database table migrations if required (this is mostly required for the workshop, and not for real-life use cases). We do database migration first, then listen
seed.tableMigrate()
app.listen(STATES_API_PORT)
Hint 11: Let's run a provider contract test, shall we?
Let's put it all together.
To tie up the whole process together for running provider contract testing:
- Execute the API service:
npm start
- Execute the state management API service:
npm run provider:states
- Run provider contract tests:
npm run test
I advise that you open 3 terminal tabs in the same terminal window to run all the above commands so you can easily see how the pizza fits in. Oh wait, I meant how the puzzle fits in. Apologies, it's my cravings for pizzas!
Important! Only once (1) and (2) are up and ready to process requests should (3) provider contract tests start executing.
Hint 12: Something is wrong with the provider tests?
If your provider contract tests are failing don't worry, we'll fix it.
Why are they failing?
If they are failing because the Pact framework can't find any contracts to download then that's expected. Did the consumer remember to publish their contracts? Talk to them. Communication is key!
Hint 14: Let's start integrating a new endpoint for fetching statistics data from the reviews API!
Now that we've got something fairly simple going on and we know our way around the data, the routes and the controller let's prepare a new route to list movies with their reviews statistics as well.
Let's call this new route /movies/stats
, and add it to our routes/moviesRouter.js:
router.get('/stats', MoviesController.getWithAllStatsWithFilter)
This route makes use of a new static method on the controller named getWithAllStatsWithFilter
which we also need to add in controllers/MoviesController.js:
static async getWithAllStatsWithFilter(req, res) {
try {
const title = req.query.title || ''
const moviesCollection = Repository.findBy('title', title)
if (moviesCollection.size <= 0) {
return res.status(404).end()
}
const statsSummary = await ReviewsClient.getAllMoviesStatistics()
statsSummary.forEach(movieStats => {
const movie = moviesCollection.get(movieStats.id)
if (movie) {
moviesCollection.set(movie.id,
{ ...movie, totalReviews: movieStats.totalReviews, averageRating: movieStats.averageRating })
}
})
return res.status(200).json([...moviesCollection.values()])
} catch (err) {
return res.status(500).end()
}
}
There's quite a bit of logic here, let's explain:
- The controller expects to get a
title
query parameter to filter results based on text search - For all the results that were matched in the DB we're collecting their movie IDs
- And then using our ReviewsClient utility that calls the Reviews service API and returns the data
- We loop through the returned data and match it to each movie
- Return the result as JSON to the API caller
Once the consumer has contract tests implemented and passing, we can publish them to the broker.
To do so, we make use of the following tools:
- Pact Manifest Generation - by running
npm run pact:generate
in a project that has an existing contract, it will create a manifest file that lists all the contracts and their tags. - Publish Contracts based on Manifest - by running
npm run pact:publish:dev
the manifest is investigated to figure out which contracts to publish and how to tag them. You should prefix this npm run command with environment variables information to be able to access the broker. The full command should be:BROKER_BASE_URL=http://localhost BROKER_USERNAME=XYZ BROKER_PASSWORD=XYZ npm run pact:can-i-deploy
replacing XYZ for user and password, as well as the broker base URL based on the details provided by the workshop trainer.
Can the consumer deploy to production? What should you check before doing that? What can break if you deploy?
You can actually test if you're able to deploy to production:
BROKER_BASE_URL=http://localhost BROKER_USERNAME=XYZ BROKER_PASSWORD=XYZ npm run pact:can-i-deploy
So can you deploy? Explore the broker
TBD
GET /reviews
- All reviews available such as: [ { "id": "1", "movieId": "1", "headline": "this is a good movie", "message": "because it features keanu reeves" } ]
GET /stats
- Reviews statistics for all movies GET /stats?movieIds[]=1&movieIds[]=2
- Reviews statistics available for bulk movies specified, such as: [ { "id": "1", "movieId": "1", "totalReviews: 100, "averageRating": 3.5 } ]
Example Movies consumer service API endpoint and response prior to integration with the Reviews service: /movies?title=the%20matrix
{
"id": 1,
"title": "The Matrix",
"plot": "A computer hacker learns from mysterious rebels about the true nature of his reality and his role in the war against its controllers.",
"year": 1999,
"length": 136,
"genre": [
"action",
"sci-fi"
]
}
The Reviews service API is a provider for the Movies service and supports the following API endpoint and response to query for movie review statistics information: /stats?movieId[]=1&movieId[]=2
[
{
"id": "1",
"totalReviews": 100,
"averageRating": 7.5
},
{
"id": "2",
"totalReviews": 32,
"averageRating": 9
},
]
The following sections will describe how to run the consumer and provider API services, how to run their contract tests, publish and verify contracts and how to debug the APIs.
Running the consumer API is as familiar with many other Node.js project, run:
npm start
To enable debugging, and/or database seeding which populates the database with some test data, run:
DEBUG=1 DB_SEED=1 npm start
- The data for the seed is available to inspect and change at
data/movies.json
To query the API once it's up you can use one of the following examples:
curl 'http://localhost:3000/movies?title=The%20Matrix'
curl 'http://localhost:3000/movies?title=the%20matrix'
curl 'http://localhost:3000/movies?title=Room404'
The tests are driven by the Jest JavaScript testing framework and are located in __tests__
.
Running the consumer tests:
npm test
- The consumer tests are running just like any other unit test file by Jest
- A successful test run will result in a pact contract file generated at the
pacts/
directory. - In either case of success or failure, the pact framework will keep logs at
logs/pact.log
that can be investigated for further details.
At this point you developed a service that integrates with a downstream API provider and you've published a contract with this provider but are you able to deploy? Not necessarily. If the provider doesn't have the API ready in production for you to use if you will release your service will break due to the integration not being available in production or even pre-production environments.
Run the can-i-deploy
command to check if all relevant contracts have been verified by your provider to ensure your deployment won't fail due to broken contracts:
BROKER_BASE_URL=http://localhost BROKER_USERNAME=guest BROKER_PASSWORD=guest npm run pact:can-i-deploy
To publish the the contracts to the broker use the following command, and specify the correct broker URL, username, and password as environment variables:
BROKER_BASE_URL=http://localhost BROKER_USERNAME=guest BROKER_PASSWORD=guest npm run pact:publish:dev
Notice that this only publishes contracts that are found to have the dev
tag in the manifest file (pact-manifest.json
).
Steps to setup provider testing phase:
- Make available a
/setup
endpoint that will be able to transition the provider to specific steps - Make available the provider API service (hint, use
wait-on
package to wait for the API service to be ready for incoming requests) - Initiate the contract testing
npm run pact:provider:test
npm run pact:provider:cleanup
Seed reviews data
{
"id": 1,
"totalReviews": 100,
"averageRating": 7.5
},
In the seed data, the id
is used as a foreign key reference to the movie id.
DEBUG=1 DB_SEED=1 npm start
curl 'http://localhost:3002/stats?movieId\[\]=4&movieId\[\]=3'
The following matching options are exposed by Pact's Matchers object:
const { Matchers } = require("@pact-foundation/pact")
const { eachLike, like, term, iso8601DateTimeWithMillis } = Matchers
To further explain them:
eachLike
is used to specify an array of items to match, where the items can be anything from a primitive type like a boolean, string or number, to more complex objects like other arrays or objects.like
is used to match a single data type (primitive or complex)term
is used as a regex to match string data typesiso8601DateTimeWithMillis
is helpful to match ISO date types, such as: 2018-01-01T00:00:00.000Z
- Request matching will fail if the request were made with query params, or body payloads (i.e: POST or PUT requests) that don't exist in the interaction.
- Request matching will not fail if a header is missing from the request to allow easy interop with servers and proxies without brittle specification of each header property
- Response matching will not fail if the server responded with more data keys than was set in the expectation. This follows Postel's law for loosely integrated services.
The body payload matching depends on the content type:
- JSON matching happens when the content type is properly set to JSON content type. In JSON matching, the order of keys in the payload matters.
- String matching happens when JSON is not specified.
- Form matching happens when the content type is set to
application/x-www-form-urlencoded
, upon which the expectation can be specified as either a string of key/value pairs or an object. The order of keys in the object doesn't matter for asserting the correctness of a match.
- Use case:
In the withRequest
clause, specify query parameters that filter for a title and a genre.
http://localhost/movies?title="the%20matrix"&genre="action"&genre="sci-fi"
- Matcher Example:
query: {
"title": "The Matrix",
"genre": [
"action",
"sci-fi"
]
}
Note: when matching an array with a fixed expected value like the above example the order of values matters for the genre query parameter.
- Use-case:
Match URL query params that specify multi-value fields with a bracket annotation:
http://localhost/reviews?itemId[]=1&itemId[]=2
- Matcher Example:
Matcher used in the withRequest
clause:
query: {
'movieId[]': Matchers.eachLike('1')
}
The eachLike
matcher has another parameter that can be set to express that a minimum number of items is expected to match, such as:
query: {
'movieId[]': Matchers.eachLike('1', {min: 100})
}
- Use-case:
Matching expected type of the payload with a generated static value for the mocked provider:
body: [
{
'id': '1231',
'totalReviews': 100,
'averageRating': 7.5
}
]
- Matcher Example:
Matcher used in the willRespondWith
clause:
body: [
{
'id': Matchers.like('1'),
'totalReviews': Matchers.like(100),
'averageRating': Matchers.like(7.5)
}
]
- Use-case:
Matching expected type of the payload with a generated static value for the mocked provider:
body: [
{
'id': '1231',
'totalReviews': 100,
'averageRating': 7.5
}
]
- Matcher Example:
Matcher used in the willRespondWith
clause:
body: [
{
'id': Matchers.like('1'),
'totalReviews': Matchers.like(100),
'averageRating': Matchers.like(7.5)
}
]
TBD