A comprehensive guide to performing integration tests with Node.js

It’s crucial to remember that integration tests are not something to be feared but rather a vital part of comprehensively testing your application.

When we discuss testing, unit tests often come to mind first. These tests focus on examining small portions of code in isolation. However, it’s important to acknowledge that your application is much larger than these individual code snippets, and most parts don’t function independently. This is where the significance of integration tests becomes evident. They pick up where unit tests leave off, effectively bridging the gap between unit testing and end-to-end testing.

You know you need to write integration tests, so why aren’t you doing it?

This article will guide you on writing clear, well-structured integration tests, illustrated with examples from API-driven applications.

Although the code examples in this article will be in JavaScript/Node.js, the majority of the concepts discussed can be easily applied to integration tests across various platforms.

Unit Tests and Integration Tests: A Necessary Partnership

Unit tests zero in on a single, specific unit of code, which is often a particular method or function within a larger component.

These tests are conducted in isolation, meaning any external dependencies are typically replaced with stubs or mocks.

In essence, these dependencies are swapped with pre-programmed behavior, guaranteeing that the test results depend solely on the accuracy of the unit being tested.

For a deeper understanding of unit tests, you can refer to additional resources.

Unit tests are instrumental in maintaining high code quality and good design. They also simplify the process of covering edge cases.

However, a limitation of unit tests is their inability to test how different components interact with each other. This is precisely where integration tests come into play.

Integration Tests

If unit tests are characterized by testing the smallest code units in isolation, then integration tests represent the opposite.

Integration tests are designed to test the interaction of multiple, larger units (components) and can even span across multiple systems in certain cases.

The primary goal of integration tests is to identify bugs in the connections and dependencies between different components, including issues such as:

  • Passing invalid or incorrectly ordered arguments
  • Issues with the database schema
  • Problems with cache integration
  • Flaws in business logic or errors in data flow (due to the broader testing perspective)

In situations where the components being tested don’t have complex logic (for instance, components with minimal cyclomatic complexity), integration tests become significantly more crucial than unit tests.

In such cases, unit tests are primarily used to ensure good code design.

While unit tests help ensure that functions are written correctly, integration tests ensure the system functions correctly as a whole. Both unit and integration tests have their own complementary roles to play, and both are indispensable for a comprehensive testing approach.

Think of unit tests and integration tests as two sides of the same coin; the coin holds no value without both.

Therefore, testing can’t be considered complete without conducting both integration and unit tests.

Setting Up Your Integration Test Suite

While setting up a test suite for unit tests is usually straightforward, setting up one for integration tests can be more challenging.

This difference arises because, in integration tests, components might have dependencies outside the project, such as databases, file systems, email providers, external payment services, etc.

Sometimes, integration tests need to use these external services and components; other times, they can be stubbed.

When these external dependencies are required, it can lead to several challenges.

  • Unreliable Test Execution: External services can experience downtime, return unexpected responses, or be in an undesirable state. This unpredictability can lead to false positives or false negatives in test results.
  • Slow Execution Speed: The process of preparing and connecting to external services can be time-consuming. Tests are often executed on an external server as part of CI.
  • Complex Test Setup: External services must be in the right state for testing. For instance, the database might need to be pre-populated with specific test data, and so on.

Guidelines for Writing Integration Tests

Unlike unit tests, there are no rigid rules for integration tests. However, there are general guidelines worth following when writing them.

Repeatable Tests

The order in which tests are run or their dependencies shouldn’t influence the test outcome. Running the same test multiple times should always yield the same result. Achieving this consistency can be tricky when the test relies on internet connectivity to interact with third-party services. However, this obstacle can often be overcome through techniques like stubbing and mocking.

For external dependencies over which you have more control, implementing setup and teardown steps before and after each integration test can help ensure that the test always starts from a clean and consistent state.

Testing Relevant Actions

When it comes to testing all possible scenarios, unit tests are a much better choice.

Integration tests, on the other hand, are more focused on the interactions between modules. Therefore, testing happy scenarios is generally the preferred approach as it effectively covers the crucial connections between modules.

Understandable Tests and Assertions

A well-written test should convey its purpose at a glance. The reader should quickly understand what is being tested, how the environment is set up, what is being stubbed, when the test is executed, and what assertions are being made. Assertions themselves should be straightforward and utilize helper functions to improve comparison and logging.

Easy Test Setup

Getting the test to its initial state should be as simple and clear as possible.

Avoid Testing Third-Party Code

While it’s acceptable to use third-party services in your tests, there’s no need to test their functionality directly. If you don’t trust the reliability of a third-party service, you should reconsider using it in the first place.

Keep Production Code Separate from Test Code

Production code should remain clean and free from test-related logic. Intertwining the two, as in Mixing test code with production code, can lead to the undesirable coupling of unrelated domains.

Relevant Logging

Failed tests without proper logging are of limited value.

While extra logging might not be necessary for passing tests, it’s essential for debugging failing ones.

Comprehensive logging should include information like database queries, API requests and responses, and a detailed comparison of the expected and actual values being asserted. This level of detail can significantly aid in troubleshooting.

Striving for Clean and Understandable Tests

A basic test adhering to these guidelines might look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const co = require('co'); 
const test = require('blue-tape'); 
const factory = require('factory');
const superTest = require('../utils/super_test');
const testEnvironment = require('../utils/test_environment_preparer');  

const path = '/v1/admin/recipes'; 

test(`API GET ${path}`, co.wrap(function* (t) { 
	yield testEnvironment.prepare();
	const recipe1 = yield factory.create('recipe'); 
	const recipe2 = yield factory.create('recipe'); 

	const serverResponse = yield superTest.get(path); 

	t.deepEqual(serverResponse.body, [recipe1, recipe2]); 
}));

This code snippet tests an API endpoint (GET /v1/admin/recipes), expecting it to return an array of saved recipes as a response.

Notice that even this simple test relies on several utility functions – a common characteristic of well-structured integration test suites.

Helper components play a vital role in making integration tests easier to understand.

Let’s delve into the components necessary for effective integration testing.

Helper Components

A robust testing suite typically includes a few fundamental ingredients: flow control mechanisms, a testing framework, a database handler, and a way to interact with backend APIs.

Flow Control

Asynchronous flow presents one of the biggest challenges in JavaScript testing.

Callbacks have the potential to wreak havoc in code, and promises alone aren’t always sufficient. This is where flow control helpers become invaluable.

While we wait for the full support of async/await, libraries offering similar functionality can be used. The goal is to write code that is readable, expressive, robust, and capable of handling asynchronous operations.

Co allows you to write code in a clean and organized manner while preserving its non-blocking nature. This is achieved through the use of generator functions defined with co and the yield keyword for managing asynchronous results.

Another viable option is to use Bluebird. Bluebird is a promise library with several helpful features, including support for handling arrays, errors, timeouts, and more.

Both co and Bluebird coroutines function similarly to the async/await syntax in ES7, pausing execution until promises are resolved. The key difference is that they consistently return a promise, which proves beneficial for error handling.

Testing Framework

The choice of a testing framework often boils down to personal preference. When making this choice, prioritize frameworks that are easy to use, don’t introduce unexpected side effects, and produce output that is both readable and easily piped for further processing.

JavaScript offers a wide selection of testing frameworks. In our examples, we’ll be using Tape. In my view, Tape not only meets the criteria mentioned earlier but also stands out as cleaner and more straightforward compared to alternatives like Mocha or Jasmine.

Tape is built upon the Test Anything Protocol (TAP) protocol.

The Test Anything Protocol (TAP) has implementations in most programming languages.

Tape functions by taking tests as input, executing them, and then presenting the results in the TAP format. These TAP results can then be piped to a test reporter or displayed directly in the console in their raw form. Tape is designed to be run from the command line.

Some noteworthy features of Tape include:

  • The ability to specify a module to load before executing the test suite, which can simplify test environment preparation and eliminate unnecessary code.
  • The inclusion of a small and easy-to-use assertion library.
  • The option to define the expected number of assertions within a test.

Factory Library

Factory libraries offer a more flexible and maintainable alternative to static fixture files for generating test data. They allow you to define models and create instances of those models without writing convoluted and repetitive code.

In JavaScript, we have factory_girl for this purpose. This library draws inspiration from a gem with a similar name, originally developed for Ruby on Rails.

1
2
3
4
5
6
const factory = require('factory-girl').factory; 
const User = require('../models/user'); 

factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); 

const user = factory.build('user');

To use Factory Girl, you’ll first need to define a new model.

This definition includes a name, a reference to the corresponding model in your project, and an object that serves as a template for generating new instances.

Instead of directly providing the template object, you can also supply a function that returns either an object or a promise, offering greater flexibility in instance creation.

When creating a new model instance, you have the following options:

  • Overriding specific values in the newly created instance.
  • Passing additional values through the build function’s options.

Let’s illustrate this with an example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const factory = require('factory-girl').factory; 
const User = require('../models/user'); 

factory.define('user', User, (buildOptions) => {
	return {
		name: 'Mike',
		surname: 'Dow',
		email: buildOptions.email ||  'mike@gmail.com'
	}
}); 

const user1 = factory.build('user');
// {"name": "Mike", "surname": "Dow", "email": "mike@gmail.com"}

const user2 = factory.build('user', {name: 'John'}, {email: 'john@gmail.com'});
// {"name": "John", "surname": "Dow", "email": "john@gmail.com"}

Connecting to APIs

For integration tests that involve interacting with APIs, spinning up a full-fledged HTTP server and making actual HTTP requests only to tear them down shortly after – particularly when running multiple tests – is inefficient and can significantly increase the overall test execution time.

SuperTest offers a more streamlined approach. This JavaScript library allows you to make API calls without the overhead of setting up a live server. Built on top of SuperAgent, a library for making TCP requests, SuperTest avoids the need to establish new TCP connections, resulting in much faster API calls.

SuperTest, with its support for promises, is particularly well-suited for supertest-as-promised. By returning a promise, it helps you avoid deeply nested callback functions, making asynchronous flow control much more manageable.

1
2
3
4
5
const express = require('express') 
const request = require('supertest-as-promised'); 

const app = express(); 
request(app).get("/recipes").then(res => assert(....)); 

While initially designed for the Express.js framework, SuperTest can be adapted for use with other frameworks as well.

Other Useful Utilities

In some testing scenarios, you might need to mock certain dependencies in your code, test the logic surrounding functions using spies, or utilize stubs. This is where utility packages like Sinon.js prove incredibly helpful.

SinonJS is a comprehensive library that provides support for spies, stubs, and mocks, along with other valuable testing features like time manipulation, test sandboxing, enhanced assertions, and the ability to simulate servers and requests.

Situations may arise where you need to mock a specific dependency in your code, especially when references to the service you want to mock are used by other parts of your system.

To address this, you can utilize dependency injection or, if that’s not feasible, a mocking service like Mockery.

Mockery is designed to help you mock code that relies on external dependencies. For it to work correctly, Mockery should be called before loading your tests or the code under test.

1
2
3
4
5
6
7
const mockery = require('mockery'); 
mockery.enable({ 
warnOnReplace: false, 
warnOnUnregistered: false 
}); 

const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);

With this new reference in place (in this case, mockingStripe), mocking services later in your tests becomes much easier.

1
2
const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount');
stubStripeTransfer.returns(Promise.resolve(null));

Sinon simplifies the process of mocking. However, one potential issue is that stubs created with Sinon will persist across multiple tests. To isolate these stubs and prevent interference, you can leverage Sinon’s sandbox feature. This allows subsequent tests to revert the system to its original, unmocked state.

1
2
3
4
5
6
7
const sandbox = require('sinon').sandbox.create();
const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount');
stubStripeTransfer.returns(Promise.resolve(null));

// after the test, or better when starting a new test

sandbox.restore();

Other components might be needed for tasks like:

  • Clearing the database (often achievable with a pre-built hierarchical query)
  • Resetting the database to a known, working state (sequelize-fixtures)
  • Mocking TCP requests made to external, third-party services (nock)
  • Utilizing more expressive and informative assertions (chai)
  • Working with saved responses from third-party services (easy-fix)

Moving Beyond Simple Tests

Abstraction and extensibility are crucial aspects of building a maintainable and effective integration test suite. Any logic that distracts from the core of the test – preparing data, performing actions, and making assertions – should be encapsulated within utility functions.

While there’s no single “right” way to do this (it depends on the specifics of your project), certain qualities are common to well-designed integration test suites.

The code snippet below demonstrates how to test an API that creates a new recipe and sends an email as a side effect.

It achieves this by stubbing the external email provider, allowing you to verify that an email would have been sent without actually sending one. Additionally, the test checks if the API responded with the correct status code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const co = require('co'); 
const factory = require('factory');
const superTest = require('../utils/super_test');  
const basicEnv = require('../utils/basic_test_enivornment'); 

const path = '/v1/admin/recipes'; 

basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { 
	const chef = yield factory.create(chef); 
	const body = {
		chef_id: chef.id,
		recipe_name: cake,
		Ingredients: [carrot, chocolate, biscuit]
	}; 
	
	const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null);
	const serverResponse = yield superTest.get(path, body); 
	
	assert.spies(stub).called(1);
	assert.statusCode(serverResponse, 201);
}));

Key points to note about this test:

  • It is repeatable, ensuring a clean environment for each execution.
  • It has a straightforward setup process, with all setup-related logic consolidated within the basicEnv.test function.
  • It focuses on a single action – testing one specific API endpoint.
  • It clearly expresses the test’s expectations using simple assert statements.
  • It avoids directly involving third-party code by using stubbing/mocking.

Embrace Integration Testing

When deploying new code to production, developers and everyone involved in the project want assurance that new features will function as intended and existing functionality won’t regress.

Achieving this level of confidence is challenging without a solid testing strategy. Inadequate testing can lead to frustration, project fatigue, and ultimately, project failure.

Integration tests, when combined with unit tests, provide a crucial first line of defense against bugs.

Relying solely on one or the other leaves gaps in your test coverage, increasing the risk of undetected errors. By consistently employing both, you make new code commits more reliable, instill confidence in the development process, and foster trust among all project stakeholders.

Licensed under CC BY-NC-SA 4.0