Building an API consumer-first with Pact
— Testing, Automation, JavaScript, Software Development — 6 min read

At the start of April I decided to finally take the leap and try and build my own app, based on my personal project of automating my CV with FRESH and HackMyResume.
I’m currently going through the idea validation stage of the app but as the app will ultimately need a backend I decided to invest some time building a basic API with Create, Read, Update & Delete (CRUD) functionality for the CV data.
This CRUD API was the perfect excuse for me to try out Pact, a tool I’d used previously for testing my API consumer code in another project but never for it’s purpose as a consumer-driven contract testing solution.
I really enjoyed using Pact and the paradigm shift it offered so I thought I’d share my experience using it and I’ve created a demo project you can download to try it out yourself.
What is consumer-driven contract testing?
Contract testing is the act of validating a contract, in the case of an API this would be the contract of what the API endpoint expects to receive and what it returns based on the input.
It’s common practice when building an API to produce some form of document, either in the form of documentation or API definition using a tool like Swagger.
While Swagger is a great tool (and it compliments Pact well) it has some flaws:
- The Swagger document (even those generated as part of the code base) can become incorrect if the implementation is updated but the documentation is not
- The Swagger document is usually used one-way with the API consumer, using it to test the contract and the API provider never verifying the contract is maintained
- As an API provider it’s hard to visualise which services are consuming the API and which properties they are actually using as there’s no means of the consumer telling Swagger this
Consumer-driven contract testing flips this top-down, one-way communication flow with the API consumer defining how they are using the API and the API provider using the definition to test that it honours the ‘pact’.
This is a powerful idea as it means that the API provider is free to update any endpoints or properties that are not used by any of the API’s consumers, allowing the API to evolve while maintaining quality.
Pact’s Broker service also provides a great visualisation of the different consumers for an API provider which helps in scaled micro-service based deployments where an API provider could have numerous consumers all requiring different properties.
Designing the API
As mentioned earlier, Swagger is a great tool and it’s still by far the best tool I’ve ever used for jumping in to sketch out an API, so I started by creating the following Swagger doc for the API.
1openapi: 3.0.02# Added by API Auto Mocking Plugin3servers:4 - description: SwaggerHub API Auto Mocking5 url: https://virtserver.swaggerhub.com/colinfwren/pactDemo/1.0.06info:7 description: API for demoing Pact8 version: "1.0.0"9 title: Pact Demo API10 contact:11 email: lolpointerexception@gmail.com12 license:13 name: Apache 2.014 url: 'http://www.apache.org/licenses/LICENSE-2.0.html'15tags:16 - name: public17 description: Available to everyone without logging in18 - name: private19 description: Available only to the logged in user20paths:21 /accounts/:22 post:23 tags:24 - public25 summary: Create an account for the user26 operationId: signupUser27 description: |28 Using the provided email and password, create the user account29 requestBody:30 description: User's email and password31 required: true32 content:33 application/json:34 schema:35 $ref: '#/components/schemas/LoginDetails'36 responses:37 '201':38 description: User account successfully created39 content:40 application/json:41 schema:42 type: object43 '400':44 description: Account already exists for that user45 content:46 application/json:47 schema:48 type: object49 properties:50 message:51 type: string52 description: error message53 example: Account already exists54 '406':55 description: User didn't send application/json content-type header56 /sessions/:57 post:58 tags:59 - public60 summary: Log the user in with the credentials provided61 operationId: loginUser62 description: |63 Using the provided email and password, logs the user in to the system64 requestBody:65 description: User's email and password66 required: true67 content:68 application/json:69 schema:70 $ref: '#/components/schemas/LoginDetails'71 responses:72 '201':73 description: User successfully logged in, returns Token for authentication74 content:75 application/json:76 schema:77 type: object78 properties:79 token:80 type: string81 description: Auth token for user to use with subsequent calls to server82 example: dead-b33f-1337-beef83 '400':84 description: User provided incorrect details85 content:86 application/json:87 schema:88 type: object89 properties:90 message:91 type: string92 description: error message93 example: Account doesn't exist94 '406':95 description: User didn't send application/json content-type header96 delete:97 tags:98 - private99 security:100 - BearerAuth: []101 summary: Log the user out102 operationId: logoutUser103 description: Log the user out of their current session104 responses:105 '204':106 description: User successfully logged out107 '401':108 description: User is unable to be logged out, as they aren't logged in109 /dogs/:110 get:111 tags:112 - private113 summary: Lists user's dogs114 operationId: getDogs115 security:116 - BearerAuth: []117 description: |118 Gets the dogs for the logged in user119 responses:120 '200':121 description: User fetched all their dogs successfully122 content:123 application/json:124 schema:125 $ref: '#/components/schemas/DogList'126 '401':127 description: User is unable to fetch their dogs, as they aren't logged in128 post:129 tags:130 - private131 summary: Creates a new dog132 operationId: createDog133 security:134 - BearerAuth: []135 description: |136 Creates a new dog for the logged in user137 requestBody:138 description: Doggo information139 required: true140 content:141 application/json:142 schema:143 $ref: '#/components/schemas/Dog'144 responses:145 '201':146 description: User successfully created dog, returns ID of the new dog147 content:148 application/json:149 schema:150 $ref: '#/components/schemas/DogList'151 '400':152 description: User sent bad data to create dog with, returns a list of paths to the incorrect data153 content:154 application/json:155 schema:156 $ref: '#/components/schemas/DogUpdateErrors'157 '401':158 description: User is unable to create dog, most likely due to not being logged in159 '406':160 description: User didn't send application/json content-type header161 /dogs/${dogId}:162 get:163 tags:164 - public165 summary: Returns dog166 operationId: getDog167 description: |168 Returns the dog with the provided ID if in system169 parameters:170 - in: path171 name: dogId172 description: ID (uuid) of the dog to return173 required: true174 example: dead-beef-1337-beef175 schema:176 type: string177 responses:178 '200':179 description: Dog with the provided ID180 content:181 application/json:182 schema:183 $ref: '#/components/schemas/Dog'184 '404':185 description: No dog in the system with provided ID186 delete:187 tags:188 - private189 summary: Deletes a dog190 operationId: deleteDog191 security:192 - BearerAuth: []193 description: |194 Deletes the dog with the provided ID if in system and belongs to the user195 parameters:196 - in: path197 name: dogId198 description: ID (uuid) of the dog to return199 required: true200 example: dead-beef-1337-beef201 schema:202 type: string203 responses:204 '204':205 description: User successfully deleted dog206 '401':207 description: User isn't logged in208 '403':209 description: User is trying to delete a dog that doesn't belong to them210 '404':211 description: No dog in the system with provided ID212 patch:213 tags:214 - private215 summary: Updates dog info216 operationId: updateDog217 security:218 - BearerAuth: []219 description: |220 Updates the dog with the provided ID if in the system and belongs to the user221 parameters:222 - in: path223 name: dogId224 description: ID (uuid) of the dog to update info of225 required: true226 example: dead-beef-1337-beef227 schema:228 type: string229 requestBody:230 description: Sets of changes to make to the dog's info231 required: true232 content:233 application/json:234 schema:235 $ref: '#/components/schemas/DogInfoUpdates'236 example:237 - compatibility:238 cats: Hates the things239 responses:240 '200':241 description: User successfully updated dog's info242 content:243 application/json:244 schema:245 $ref: '#/components/schemas/Dog'246 '401':247 description: User isn't logged in248 '400':249 description: User sent bad data to perform update with, returns list of paths to incorrect objects250 content:251 application/json:252 schema:253 $ref: '#/components/schemas/DogUpdateErrors'254 '403':255 description: User is trying to update the info of a dog that doesn't belong to them256 '404':257 description: No dog in the system with provided ID258 '406':259 description: User didn't send application/json content-type header260components:261 securitySchemes:262 BearerAuth:263 type: http264 scheme: bearer265 schemas:266 LoginDetails:267 type: object268 properties:269 email:270 type: string271 description: User's email to log in with272 example: test@test.com273 password:274 type: string275 description: User's password to log in with276 example: correctHorseBatteryStaple277 DogList:278 type: array279 items:280 type: object281 properties:282 id:283 type: string284 description: ID of the dog in the system285 example: dead-b33f-1337-beef286 DogUpdateErrors:287 type: array288 items:289 type: object290 properties:291 path:292 type: string293 description: JSONPath to incorrect data294 example: 0.age295 error:296 type: string297 description: Reason why data is incorrect298 example: "Age is expected to be a number"299 DogInfoUpdates:300 type: array301 items:302 type: object303 properties:304 name:305 type: string306 description: The candidate's name as it should appear on the dog307 example: Jane Q. Programmer308 compatibility:309 $ref: '#/components/schemas/DogCompatibility'310 age:311 type: number312 description: How old the dog is313 example: 2314 Dog:315 type: array316 required:317 - name318 items:319 type: object320 properties:321 name:322 type: string323 description: The candidate's name as it should appear on the dog324 example: Jane Q. Programmer325 compatibility:326 $ref: '#/components/schemas/DogCompatibility'327 age:328 type: number329 description: How old the dog is330 example: 2331 DogCompatibility:332 type: object333 properties:334 cats:335 type: string336 description: How well does the dog get on with cats337 example: Not very well338 kids:339 type: string340 description: How well does the dog get on with kids341 example: Really well342 mailmen:343 type: string344 description: How well does the dog get on with the mailman345 example: Best buds
Usually the Swagger document would be used to create a mock server for a consumer to use, to test its API integration code, but instead I used Pact.
Building the API consumer
It can feel a bit odd to build the API consumer before there’s even an API to consume but when working in a distributed team I’ve done this a number of times with both front-end and back-end teams building against the same API definition, integrating later which has always caused issues (something Pact solves).
I built a new module in my front-end repository to integrate with the to-be API and created a test suite for that module in Jest, using Pact to define the request to be sent to the API provider and the expected response.
1describe('getDogs', () => {2 describe('Get Dog list for authenticated user', () => {3 beforeEach(async () => {4 updateAuthenticationToken(authenticationToken);5 const interaction = {6 state: 'The user has authenticated',7 uponReceiving: 'a request for all Dogs the user has created',8 withRequest: {9 path: collectionEndpoint,10 method: 'GET',11 headers: {12 authorization: `Bearer ${authenticationToken}`,13 },14 },15 willRespondWith: {16 body: dogList,17 status: 200,18 headers: {19 'Content-Type': 'application/json; charset=UTF-8',20 },21 },22 };23 await provider.addInteraction(interaction);24 });25 it('Returns the list of Dogs for the authenticated user', async () => {26 const response = await getDogs();27 expect(response).toEqual(dogList);28 await provider.verify();29 });30 });31});
Pact then uses these definitions (called a pact) to create a mock server that the front-end API requests will be sent to and will return the expected response, allowing the functions to be integration tested without the API provider needing to be accessible.
One gotcha to look out for is when you have tests that match against the same response status, say a test for a 401 returned because the user doesn’t send an authentication header and a 401 because the authentication token is invalid, in this instance Pact will only add the first interaction for the response code to the final pact.
I then published the pact to the Pact Broker, which is a server that manages the pacts for other services to validate against, test results and relationships between the API consumers and providers.
1const publisher = require('@pact-foundation/pact-node');2const path = require('path');3
4const opts = {5 providerBaseUrl: 'http://localhost:8080',6 pactFilesOrDirs: [path.resolve(process.cwd(), 'pacts')],7 pactBroker: 'http://localhost:9292',8 pactBrokerUsername: 'test',9 pactBrokerPassword: 'test',10 consumerVersion: '1.0.0',11};12
13publisher.publishPacts(opts);
Setting up the Pact Broker
Pact offer a Docker image for the Pact Broker which is perfect for local testing as it can be spun up quickly but they also provide a free tier on their paid for service Pactflow that allows for 5 pacts to be held.
I’ve created a Docker Compose file you can use to set up the Pact Broker which will make the service available at http://localhost:9292 (use test
for both the username and password to log in).
1version: "3.3"2services:3 broker:4 image: pactfoundation/pact-broker:2.52.2-15 environment:6 PACT_BROKER_DATABASE_ADAPTER: sqlite7 PACT_BROKER_DATABASE_NAME: pact_broker.sqlite8 PACT_BROKER_BASIC_AUTH_USERNAME: test9 PACT_BROKER_BASIC_AUTH_PASSWORD: test10 PACT_BROKER_BASIC_AUTH_READ_ONLY_USERNAME: test11 PACT_BROKER_BASIC_AUTH_READ_ONLY_PASSWORD: test12 PACT_BROKER_PORT: "9292"13 ports:14 - 9292:9292
Once the pact publish job is configured to upload the pact to the Pact Broker and the pact is published, the consumer’s pact will be available in the Pact Broker’s UI.

The next step is to use the Pact Broker to allow the API provider to download the API consumer’s pact, verifying whether the API provider honours that pact and uploading the results to the Pact Broker.
Building the API provider using Pact for TDD
The ability to download the API consumer’s pact and verify conformance shows the power of consumer-driven contracts as now we’re able to use the already verified contract in order to practise Test Driven Development (TDD).
The test suite for an API provider is less verbose compared to the one built for the API consumer. This is because the concerns of these test suites are verifying the pact is honoured instead of defining it.
1describe('Pact verification', () => {2 const port = 8080;3 const opts = {4 provider: 'doggo-api-provider',5 providerBaseUrl: `http://localhost:${port}`,6 pactBrokerUrl: 'http://localhost:9292',7 pactBrokerUsername: 'test',8 pactBrokerPassword: 'test',9 publishVerificationResult: true,10 providerVersion,11 };12
13 beforeAll(async () => {14 apiServer.listen(port, '0.0.0.0', () => {15 console.log(`${apiServer.name} listening at ${apiServer.url}`);16 });17 });18
19 afterAll(async () => {20 apiServer.close();21 });22
23 it('Should validate the expectations of consumers', () => {24 return new Verifier().verifyProvider(opts);25 });26});
Once the test suite is run the test suite results will be uploaded to the Pact Broker and results will be visible for everyone to see. This means that should the tests fail then it’s easy to see which consumers are impacted by changes.

Red, Green, Refactor
In order to get my initial test suite passing for the API I relied heavily on stubbed values but of course the final app would use a proper authentication service to do this, which can be hard to manage when using Pact.
Pact’s JSON structure is static, so authentication tokens and other potentially dynamic data needs to be available to both the consumer and provider at time of the tests running.
Pact suggest a few ways to mitigate the issue such as using long lived authentication tokens or to have authentication as a separate layer, allowing Pact to run against the unauthenticated endpoints.
When refactoring my API to use firebase, I opted to keep the stubbed values but use Jest’s excellent mocking features to build mocks for the firebase/app
, firebase/auth
and firebase-admin
modules, returning the stubs within the mocks.
This allowed me to move all my test logic out of the controllers and into those underlying functions and is much better than my previous approach of using a strategy pattern to return stubs based on the NODE_ENV
being test
(Jest sets this at runtime).
Test data
As the API grows in functionality and number of consuming services it becomes important to manage test data and ensure a consistent set of values being used for behaviour flags for stubbed data.
I managed this by creating another test data module that stored all the request and response payloads as well as values for authentication tokens and IDs, allowing me to change any test values without breaking the tests due to data mismatches.
In my demo project I use this technique also, with the test data being found in the common
folder.
Summary
By using consumer-driven contract testing I was able to verify my front-end would work with my API before I had even built the API itself and then used the pact from the consumer to build the API using TDD.
As someone who has been on multiple teams where we’ve either been given a Swagger document that turned out to be out of date or had to spend time reworking API integrations due to the back-end developers not interpreting the Swagger document the same way the front-end developers did Pact is an amazing solution.
In my app development I’ve used Pact’s Pactflow service in my Continuous Integration (CI) in order to verify the pact on every push, using the app’s version straight from the package.json
to ensure versioning is reported properly.
As my app grows in size I look forward to seeing how Pact scales as more consumers get built to handle more focused jobs using the data from the CRUD API.
Check it out yourself
I’ve created a demo project that contains a API consumer, API provider and a Docker Compose file to run the Pact Broker that can be used to play with Pact and see how consumer-driven contracts testing can bring a higher level of quality to API powered applications.