Do you want the benefits of contract testing with much less effort? Are you convinced of the benefits of contract testing but think it’s just too difficult to roll out across your organization?
You might worry that implementing Pact in your organization requires challenging changes to culture and process.
In this article, I’ll show you a drastically simplified approach to contract testing that a single developer can bring online. You'll get many of the benefits of contract testing for much less work. You'll build a platform that will help convince your team of how much contract testing can help prevent problems going out to production.
If you've ever tried to read the Pact documentation, you might think contract testing is a complicated team-based affair that requires new infrastructure. Let me show you that it doesn’t have to be this way.
Pre-requisites
The example code for this article is available on GitHub.
You can clone the code repository using Git or download the zip file and unpack it.
You’ll need Node.js installed to run the example code. The code repository contains three working examples that we’ll go through in turn. Follow along and try out each example for yourself.
Testing a REST API with a Simplified Contract
For our first example of simplified contract testing, let’s run some tests against an existing REST API. We’ll use the JSON Placeholder REST API.
JSON Placeholder is a fake REST API that includes endpoints for creating, updating, reading, and deleting “blog posts”. In Figure 1, you can see the JSON response for getting all blog posts. Load this URL in your web browser to see it for yourself:
Figure 1: The JSON response from JSON Placeholder getting all blog posts, viewed in Chrome.
We'll use the Jest testing framework to run our contract tests. The Axios code library will make HTTP requests to the JSON Placeholder REST API, then we’ll check that the responses conform to a contract defined in a JSON schema. The setup for our first example is illustrated in Figure 2:
Figure 2: Using Jest to make HTTP requests and checking the responses against a JSON schema.
To try out the contract tests, you’ll need a local copy of the code repository and Node.js installed. Change into the rest-api
subdirectory for the first example project:
cd simplified-contract-testing/rest-api
Then install project dependencies:
npm install
Now run the tests:
npm test
After a few moments, you should see the output from a handful of successful tests.
Now let’s see how the code works. Listing 1 shows a pared-down version of the YAML test spec that makes a HTTP request to the /post
endpoint and checks the response against the GetPostsResponse
schema. The code repo has an expanded version that contains tests for multiple HTTP endpoints.
schema:
definitions:
# Schema for a blog post:
Post:
title: Represents a blog post.
type: object
required:
- userId
- id
- title
- body
properties:
userId:
type: number
id:
type: number
title:
type: string
body:
type: string
# Schema for the REST API response:
GetPostsResponse:
title: GET /post response
type: array
items:
# Reference to the Post schema.
$ref: "#/schema/definitions/Post"
# List of tests specs.
specs:
# Tests the REST API that gets the list of blog posts.
- title: Gets all blog posts
description: Gets all blog posts from the REST API.
method: get
url: /posts
expected:
status: 200
headers:
Content-Type: application/json; charset=utf-8
body:
# Reference to the schema for the REST API response.
$ref: "#/schema/definitions/GetPostsResponse"
Listing 1: A Test Spec That Makes an HTTP Request to the /posts
Endpoint
How do we convert the test spec to a series of automated tests? For that, we'll use Jest’s test.each
function. We can use a very cool technique to generate automated tests from our data.
In this case, we generate tests from our test spec loaded from the YAML file. You can see in Listing 2 that the code required to achieve this is minimal and not complicated. Listing 2 is slightly abbreviated, but not by much if you compare it to the complete code file.
const axios = require("axios");
const yaml = require("yaml");
const fs = require("fs");
const { resolveRefs } = require("./lib/resolver");
const { matchers } = require("jest-json-schema");
expect.extend(matchers);
// The REST API we are testing:
const baseURL = `https://jsonplaceholder.typicode.com`;
describe("Contract tests", () => {
// Loads the test spec:
const testSpec = resolveRefs(
yaml.parse(fs.readFileSync(`${__dirname}/test-spec.yaml`, "utf-8"))
);
// Generates a test for each contract test in the spec:
test.each(testSpec.specs)(`$title`, async (spec) => {
// Makes the HTTP request:
const response = await axios({
method: spec.method,
url: spec.url,
baseURL,
data: spec.body,
validateStatus: () => true, // All status codes are ok.
});
// Matches headers:
if (spec.expected.headers) {
for ([headerName, expectedValue] of Object.entries(
spec.expected.headers
)) {
const actualValue = response.headers[headerName.toLowerCase()];
expect(actualValue).toEqual(expectedValue);
}
}
// Matches response body against the expected schema:
if (spec.expected.body) {
expect(response.data).toMatchSchema(spec.expected.body);
}
});
});
Listing 2: Generating a Series of Jest Tests from Our Data
We have already seen how simple it can be to make data-driven contract tests against an existing REST API. Now let’s run our contract tests against a more realistic REST API backed by a database.
Mocking Your Database for Fast Contract Tests
To try out this second example for yourself, change into the mocked-mongodb
subdirectory, install dependencies, and then run the tests:
cd simplified-contract-testing/mocked-mongodb
npm install
npm test
The problem with testing against real infrastructure (like a real database) is that it makes our automated tests run very slowly. We can speed this up massively by mocking our database. By that, I mean replacing the real database with a fake version we can load up with pre-canned data fixtures to run our automated tests against. So while the real version of the service is configured to use a real database, the version we load for automated testing is configured to use the fake database instead. If you are interested, you can see the full code for the REST API in the code repo.
You can see how this looks in Figure 3. Now we are running a REST API locally, and we have it talking to a mock database where we can control the data fixtures on a test-by-test basis:
Figure 3: Mocking the database and controlling data fixtures on a test-by-test basis.
It turns out to be incredibly easy to mock whole modules when using Jest. Any file we place in the __mocks__
subdirectory can replace a real code module while automated tests run. So we place the file mongodb.js
in the __mocks__
subdirectory, and Jest automatically replaces require(“mongodb”)
with our mock version of the code. You can see our mock version of MongoDB in Listing 3. Note how it exports the function __setData__
, which we can call from our tests to control the data fixture loaded for each test.
//
// A mock version of the MongoDB library.
//
let data = {}; // Data fixtures are plugged in here.
// --snip--
class MongoCollection {
constructor(collectionName) {
this.collectionName = collectionName;
}
async findOne({ _id }) {
const collectionData = data[this.collectionName] || [];
return collectionData.find((el) => el._id.__value === _id.__value);
}
find() {
// A mock version of Mongodb's find function.
return {
async toArray() {
// Returns the data fixture:
return data[this.collectionName] || [];
},
};
}
async insertOne() {
return {
insertedId: new ObjectId("newly-inserted"),
};
}
}
class MongoDatabase {
collection(collectionName) {
return new MongoCollection(collectionName);
}
}
class MongoClient {
async connect() {}
db() {
return new MongoDatabase();
}
}
// Allows automated tests to set data fixtures.
function __setData__(_data) {
data = _data;
}
module.exports = {
MongoClient,
ObjectId,
__setData__,
};
Listing 3: The Mock Version of MongoDB
Now that we have a mock database and the ability to load data fixtures, we must give our test spec the ability to control which data fixture is loaded for each test. You can see a snippet of the updated test spec in Listing 3. Note the new fixture
field that, in this case, specifies that this test should load the data fixture named many-posts
.
- title: Gets all blog posts
description: Gets all blog posts from the REST API.
# Specifies the data fixture to load before running the test:
fixture: many-posts
method: get
url: /posts
expected:
status: 200
headers:
Content-Type: application/json; charset=utf-8
body:
$ref: "#/schema/definitions/GetPostsResponse"
Listing 4: A Test That Loads the Data Fixture many-posts
What we are missing now is the code that loads the data fixture for each test, which you can see in Listing 5 — or check out the full version in the code repo. We are now running a local REST API so you can see how we start the web server before each test. Also note the call to loadFixture
at the start of each test, which loads the data
fixture requested by the test.
// --snip--
describe("Contract tests", () => {
// Loads the test spec:
const testSpec = resolveRefs(
yaml.parse(fs.readFileSync(`${__dirname}/test-spec.yaml`, "utf-8"))
);
// --snip--
beforeEach(async () => {
// Starts the web server on a random port before each test:
await startServer();
});
afterEach(async () => {
await closeServer();
});
test.each(testSpec.specs)(`$title`, async (spec) => {
if (spec.fixture) {
// Loads a named database fixture into the mock MongoDB for each test:
loadFixture(spec.fixture);
} else {
clearFixture();
}
// Makes the HTTP request:
const response = await axios({
method: spec.method,
url: spec.url,
baseURL,
data: spec.body,
validateStatus: () => true,
});
// Matches status:
if (spec.expected.status) {
expect(response.status).toEqual(spec.expected.status);
}
// Matches headers:
if (spec.expected.headers) {
for ([headerName, expectedValue] of Object.entries(
spec.expected.headers
)) {
const actualValue = response.headers[headerName.toLowerCase()];
expect(actualValue).toEqual(expectedValue);
}
}
// Matches response body against the expected schema:
if (spec.expected.body) {
expect(response.data).toMatchSchema(spec.expected.body);
}
});
});
Listing 5: Starting the Web Server and Loading Data Fixtures Before Each Test
Running contract tests against a REST API is pretty useful, but we must often also work with services that communicate via asynchronous message queues. For this third and final example, we will extend our REST API so that it can send and receive messages through a RabbitMQ instance. Try out the tests for yourself:
cd simplified-contract-testing/mocked-rabbit
npm install
npm test
Mocking Your Message Queue for Fast, Asynchronous Contract Tests
Please note that we are mocking RabbitMQ in this example, but in principle, this technique can work for any asynchronous messaging system (such as Kafka or SQS).
Let's mock the module amqplib
(essentially RabbitMQ), so that we can:
- Directly invoke asynchronous message handlers.
- Retrieve asynchronous published messages.
You can see how this looks in Figure 4. Our Jest tests are now acting through our mock version of RabbitMQ to interact with our REST API:
Figure 4: Using a mock version of RabbitMQ to interact with our REST API during automated testing.
Our mock version of AMQPlib, which we use to replace RabbitMQ, is similar to our mock version of MongoDB. We add the file amqplib.js
to the __mocks__
subdirectory and Jest automatically replaces require(“amqplib”)
with the mock version you see in Listing 6. Published messages are saved in the __published__
object, and the mock AMQPlib also tracks message handlers in the __consume__
object.
const crypto = require("crypto");
// Allows the contract tests to check messages that were published to the message queue:
const __published__ = {};
// Allows the contract tests to invoke message handlers:
const __consume__ = {};
const __queue_bindings__ = {};
const mockChannel = {
async assertExchange() {},
async assertQueue() {
return {
queue: crypto.randomBytes(5).toString("hex"),
};
},
async bindQueue(queueName, exchangeName) {
__queue_bindings__[exchangeName] = queueName;
},
// Publishes a message to a mock queue.
async publish(exchange, routingKey, content) {
__published__[exchange] = JSON.parse(content.toString());
},
// Binds an event handler to a message queue.
async consume(queue, handler) {
__consume__[queue] = handler;
},
ack() {},
};
const mockConnection = {
async createChannel() {
return mockChannel;
},
};
async function connect() {
return mockConnection;
}
module.exports = {
connect,
__published__,
__consume__,
__queue_bindings__,
};
Listing 6: The Mock Version of AMQPlib Used to Replace RabbitMQ
With RabbitMQ mocked, our Jest tests can now invoke asynchronous message handlers directly in our code and then check any asynchronous responses that are subsequently published. Now that we can trigger HTTP requests or asynchronous messages from our tests, we need to update our test spec to specify the required type for each test. You can see an example in Listing 7. Note how the type
field specifies rabbit
, and the exchange
field selects the RabbitMQ “exchange” we are using to trigger the asynchronous message handler we are trying to test.
At this point, we are not just checking “immediate responses” from HTTP requests, we now must also be able to check for asynchronous messages and compare them against the expected JSON schema. Note the asyncResponse
section of Listing 7 and how it defines the schema for a message that is expected to be published on the payment-processed
exchange.
- title: Processes a payment on new user
description: Processes a payment on adding a new user.
fixture: many-posts
# The "trigger" that invokes the code to be tested:
type: rabbit
exchange: new-user
body:
userId: 1
name: John Doe
expected:
# Expectations for the asynchronous response:
asyncResponse:
type: rabbit
exchange: payment-processed
body:
$ref: "#/schema/definitions/UserPaymentProcessedMessage"
Listing 7: A Test Triggers an Async Message Handler
We have extended our contract testing system to be even more flexible:
- We can trigger HTTP requests or asynchronous message handlers.
- We can match HTTP responses and asynchronous responses against a schema.
This means we can test the following combinations:
- Trigger an HTTP request and check that its immediate response matches expectations.
- Trigger an HTTP request and check that it results in publishing an asynchronous message that matches expectations.
- Trigger an async message handler and check that it results in publishing an asynchronous message that matches expectations.
You can find examples of all these combinations in the expanded test spec in the code repo.
The code that generates our contract testing suite is getting more complex, as you can see in Listing 8. Still, it’s not bad considering the number of tests and the different testing combinations that we can achieve with this relatively small piece of code. See the full code that generates the contract tests in the code repo.
// --snip--
// The "http" trigger.
async function http(spec) {
return await axios({
method: spec.method,
url: spec.url,
baseURL,
data: spec.body,
validateStatus: () => true,
});
}
// The "rabbit" trigger.
async function rabbit(spec) {
const queue = amqplib.__queue_bindings__[spec.exchange];
if (!queue) {
throw new Error(`No queue is bound for exchange '${spec.exchange}'`);
}
// Uses the mock "amqplib" to invoke the message handler:
const consumeHandler = amqplib.__consume__[queue];
if (!consumeHandler) {
throw new Error(
`No consumer found for queue '${queue}' bound to exchange '${spec.exchange}'`
);
}
consumeHandler({ content: Buffer.from(JSON.stringify(spec.body)) });
}
const triggers = {
http,
rabbit,
};
test.each(testSpec.specs)(`$title`, async (spec) => {
// --snip--
// Triggers the code to be tested.
const trigger = triggers[spec.type];
const immediateResponse = await trigger(spec);
// Matches the immediate response against the expected schema.
if (spec.expected.immediateResponse) {
if (spec.expected.immediateResponse.status) {
expect(immediateResponse.status).toEqual(
spec.expected.immediateResponse.status
);
}
if (spec.expected.immediateResponse.headers) {
for ([headerName, expectedValue] of Object.entries(
spec.expected.immediateResponse.headers
)) {
const actualValue = immediateResponse.headers[headerName.toLowerCase()];
expect(actualValue).toEqual(expectedValue);
}
}
if (spec.expected.immediateResponse.body) {
expect(immediateResponse.data).toMatchSchema(
spec.expected.immediateResponse.body
);
}
}
// Matches the async response against the expected schema.
if (spec.expected.asyncResponse) {
const expectedResponse = spec.expected.asyncResponse;
if (!expectedResponse.exchange) {
throw new Error(
`An exchange is required for async responses on spec '${spec.title}'`
);
}
const actualAsyncResponsePayload =
amqplib.__published__[expectedResponse.exchange];
if (!actualAsyncResponsePayload) {
throw new Error(
`Expected async response to be published on exchange '${expectedResponse.exchange}'.`
);
}
if (expectedResponse.body) {
expect(actualAsyncResponsePayload).toMatchSchema(expectedResponse.body);
}
}
});
Listing 8: Our Contract Testing Code Now Handles HTTP and Asynchronous Requests and Responses
Our adventure in contract testing isn’t complete until we fully automate our tests.
Adding Contract Tests to Your CI/CD Pipeline
We can automate our tests in any CI/CD provider, as long as we can clone our code, install Node.js, and run the following commands in our project:
npm ci
npm test
Given that our external dependencies are mocked (MongoDB and RabbitMQ in these examples), that’s all that we need to get our simplified contract tests running in our CI/CD pipeline.
Listing 9 shows a CI pipeline for GitHub Actions. You can see the full, multi-project version in the code repo.
name: Automated contract tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3 # Installs Node.js.
with:
node-version: 20
- run: npm ci # Installs project dependencies.
- run: npm test # Runs the automated contract tests.
Listing 9: The GitHub Actions Workflow Runs Simplified Contract Tests for a Project
Back in the beginning of this article, I showed how we can run contract tests against an existing REST API. In the same way, we can run our contract tests against any REST API or service that we can access.
Because we control the code making the HTTP request, we can augment the code to include authentication details for the production deployment of our REST API and then run the contract tests against it. If you are contract testing against a production service via asynchronous messaging, then you'll need access (e.g., via an SSH tunnel or Kubernetes port forwarding) to your message queue.
Obviously, we must be very careful if we run contract tests against a production system, especially when tests can make changes to that system. In the full version of my example, you may have noticed that one of the tests makes an HTTP POST request to create a blog post in the REST API. Tests like this are probably best run against a QA or staging deployment, but if you really do want to run them against production, you might want a test account to easily flush out after running tests.
Bringing It Together: How I Applied Contract Testing
In my team, we used the simplified approach to contract testing covered in this post without making the team adopt any arduous processes. We integrated it into our usual automated testing process (in this example, running npm test
in our CI pipeline), and we didn’t need any new infrastructure (like a contract broker) to bring all this together.
Mocking, while not specifically related to contract testing, proved essential in making it convenient and fast to run these tests. Contract testing is more on the level of integration testing in terms of how much code it can cover for the least amount of effort.
But because we mock certain external services (like the database and message queue), our contract tests can be much closer to unit tests in terms of performance. This makes it easy and fast to run our contract tests locally for frequent feedback while we make code changes to a REST API or microservice.
Wrapping Up
In this article, we've shown you that contract testing in Node.js can be as simple as defining a JSON schema and then using your favorite testing framework to check responses from REST APIs and asynchronous messaging.
Using this simplified approach to contract testing, you can get started very quickly and gain practical results that will help convince your team that contract testing is worthwhile.
Happy testing!
P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.
P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.
Top comments (0)