Editor’s note: This article was last updated 15 March 2023 to reflect changes made in Node.js v19.
With any application, testing is an integral part of the development process. For example, testing allows you to verify that changes to a project don’t break its expected behavior, and tests can act as pseudo documentation when path flows are documented.
In this article, we’ll explore unit and integration testing in Node.js applications by reviewing my Express API, ms-starwars; I recommend doing a git clone
of the project and following along as I discuss the different ways to unit test the application.
Jump ahead:
When testing with Node.js, you’ll typically use either Mocha, Chai, Jest, Chai HTTP, or Sinon. We’ll review these later. First, there are several different types of tests that we should be familiar with.
Unit testing involves testing your application’s code and logic, which includes anything that your application can do on its own without having to rely on external services or data.
Integration testing involves testing your application as it connects with services inside or outside of the application. This could include connecting different parts of your application or connecting two different applications in a larger umbrella project.
As part of integration testing, we can also perform tests on the Data Access Object (DAO) to confirm that data is flowing properly.
Regression testing involves testing your application’s behavior after a set of changes have been made. Typically, this is something you’d do before major product releases.
End-to-end testing involves testing the full end-to-end flow of your project, including external HTTP calls and complete flows within your project.
Component testing involves testing the totality of a service, including the modules and external services connected to it. However, it’s expensive to perform because it takes a lot of time.
In this article, we’ll focus on unit and integration testing. First, let’s review the different frameworks we’ll use.
Mocha is a test runner that enables you to exercise your Node.js code. Mocha works well with any Node.js project and follows the basic Jasmine syntax, which is similar to the following code borrowed from the Mocha getting started docs:
describe('Array', function() { describe('#indexOf()', function() { it('should return -1 when the value is not present', function() { assert.equal([1, 2, 3].indexOf(4), -1); }); }); });
With Mocha, you can also include the use of assertion libraries like assert
, expect
, and others. Mocha also has many features within the test runner itself.
Chai offers an assertion library for Node.js that includes basic assertions that you can use to verify behavior; some of the more popular ones include should
, expect
, and assert
.
You can use these in your tests to evaluate the conditions of the code you’re testing, for example, the following code borrowed from Chai’s homepage:
chai.should(); foo.should.be.a('string'); foo.should.equal('bar'); foo.should.have.lengthOf(3); tea.should.have.property('flavors') .with.lengthOf(3);
Chai-HTTP is a plugin that offers a full-fledged test runner that will actually run your application and test its endpoints directly:
describe('GET /films-list', () => { it('should return a list of films when called', done => { chai .request(app) .get('/films-list') .end((err, res) => { res.should.have.status(200); expect(res.body).to.deep.equal(starwarsFilmListMock); done(); }); }); });
With Chai HTTP, the test runner starts your application, calls the requested endpoint, and then brings it down all in one command. This is really powerful, helping to perform integration tests on your application.
In addition to having a test runner and assertions, testing also requires spying, stubbing, and mocking. Sinon provides a fairly straightforward framework for spys, stubs, and mocks with your Node.js tests; you just use the associated spy, stub, and mock objects for different tests in your application. A simple test with some Sinon stubs would look like the following code:
describe('Station Information', function() { afterEach(function() { wmata.stationInformation.restore(); }); it('should return station information when called', async function() { const lineCode = 'SV'; const stationListStub = sinon .stub(wmata, 'stationInformation') .withArgs(lineCode) .returns(wmataStationInformationMock); const response = await metro.getStationInformation(lineCode); expect(response).to.deep.equal(metroStationInformationMock); }); });
There’s a lot going on here, so let’s just pay attention to the following code snippet:
const stationListStub = sinon .stub(wmata, 'stationInformation') .withArgs(lineCode) .returns(wmataStationInformationMock);
The code above creates a stub for the wmata
service’s method stationInformation
with args lineCode
, which will return the mock at wmataStationInformationMock
. With this approach, you can build out basic stubs that the test runner will use in lieu of the methods it runs across, thereby allowing you to isolate behavior.
Before we actually start building our tests, I’ll give a brief description of my project. ms-starwars is actually an orchestration of API calls to the Star Wars API (SWAPI). SWAPI is a great API by itself that provides a wealth of data on the Star Wars canon. What’s even cooler is that SWAPI is community-driven. So, if you see some missing information, you can open a PR to the project and add it yourself.
When you call endpoints for SWAPI, the API returns additional endpoints that you can call to get more information. This makes the rest calls somewhat lightweight. Below is a response from the film
endpoint:
{ "title": "A New Hope", "episode_id": 4, "opening_crawl": "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy....", "director": "George Lucas", "producer": "Gary Kurtz, Rick McCallum", "release_date": "1977-05-25", "characters": [ "https://swapi.co/api/people/1/", "https://swapi.co/api/people/2/", "https://swapi.co/api/people/3/", "https://swapi.co/api/people/4/", "https://swapi.co/api/people/5/", "https://swapi.co/api/people/6/", "https://swapi.co/api/people/7/", "https://swapi.co/api/people/8/", "https://swapi.co/api/people/9/", "https://swapi.co/api/people/10/", "https://swapi.co/api/people/12/", "https://swapi.co/api/people/13/", "https://swapi.co/api/people/14/", "https://swapi.co/api/people/15/", "https://swapi.co/api/people/16/", "https://swapi.co/api/people/18/", "https://swapi.co/api/people/19/", "https://swapi.co/api/people/81/" ], "planets": [ "https://swapi.co/api/planets/2/", "https://swapi.co/api/planets/3/", "https://swapi.co/api/planets/1/" ], "starships": [ "https://swapi.co/api/starships/2/", "https://swapi.co/api/starships/3/", "https://swapi.co/api/starships/5/", "https://swapi.co/api/starships/9/", "https://swapi.co/api/starships/10/", "https://swapi.co/api/starships/11/", "https://swapi.co/api/starships/12/", "https://swapi.co/api/starships/13/" ], "vehicles": [ "https://swapi.co/api/vehicles/4/", "https://swapi.co/api/vehicles/6/", "https://swapi.co/api/vehicles/7/", "https://swapi.co/api/vehicles/8/" ], "species": [ "https://swapi.co/api/species/5/", "https://swapi.co/api/species/3/", "https://swapi.co/api/species/2/", "https://swapi.co/api/species/1/", "https://swapi.co/api/species/4/" ], "created": "2014-12-10T14:23:31.880000Z", "edited": "2015-04-11T09:46:52.774897Z", "url": "https://swapi.co/api/films/1/" }
Additional API endpoints are returned for various areas, including characters
, planets
, etc. To get all the data about a specific film, you’d have to call the film
endpoint, all endpoints for characters
, planets
, starships
, vehicles
, and species
.
I built ms-starwars as an attempt to bundle HTTP calls to the returned endpoints, enable you to make single requests, and get associated data for any of the endpoints.
To set up this orchestration, I created Express routes and the associated controllers. I also added a cache mechanism for each of the SWAPI calls, which boosted my API’s performance. Therefore, these bundled HTTP calls don’t have the latency associated with making multiple HTTP calls, etc.
Within the project, the unit tests are available at /test/unit
, and the integration
tests are available at test/integration
. You can run them with my project’s npm scripts, npm run unit-tests
and npm run intergration-tests
.
In the next sections, we’ll walk through writing unit and integration tests. Then, we’ll cover some considerations and optimizations you can make. Let’s get to the code!
First, let’s create a new file in the sample project at /test/firstUnit.js
. At the top of your test, add the following code:
const sinon = require('sinon'); const chai = require('chai'); const expect = chai.expect; const swapi = require('../apis/swapi'); const starwars = require('../controllers/starwars'); // swapi mocks const swapiFilmListMock = require('../mocks/swapi/film_list.json'); // starwars mocks const starwarsFilmListMock = require('../mocks/starwars/film_list.json');
The first several lines pull in the project’s dependencies:
const sinon = require('sinon'); const chai = require('chai'); const expect = chai.expect; const swapi = require('../apis/swapi'); const starwars = require('../controllers/starwars');
For example, the code above:
expect
so we can use it in assertionsswapi
API services that are defined in the project, which are direct calls to the SWAPI endpointsstarwars
API controllers that are defined in the project, which are orchestrations of the SWAPI endpointsNext, you’ll notice all the mocks pulled in:
// swapi mocks const swapiFilmListMock = require('../mocks/swapi/film_list.json'); // starwars mocks const starwarsFilmListMock = require('../mocks/starwars/film_list.json');
These are JSON responses from both the SWAPI endpoints and results returned from the project’s controllers.
Since our unit tests are just testing our actual code and not dependent on the actual flows, mocking data enables us to test the code without relying on the running services. Let’s define our first test with the following code:
describe('Film List', function() { afterEach(function() { swapi.films.restore(); }); it('should return all the star wars films when called', async function() { sinon.stub(swapi, 'films').returns(swapiFilmListMock); const response = await starwars.filmList(); expect(response).to.deep.equal(starwarsFilmListMock); }); });
The describe
block defines an occurrence of the test. Normally, you would use describe
and wrap that with an it
, enabling you to group tests so that describe
can be thought of as a name for the group, and it
can be thought of as the individual tests that will be run.
You’ll also notice that we have an afterEach
function. There are several of these types of functions that work with Mocha. Most often, you’ll see afterEach
and beforeEach
, which are basically lifecycle hooks that enable you to set up data for a test and then free up resources after a test is run.
Within the afterEach
function, the swapi.films.restore()
call frees up the SWAPI films
endpoint for stubbing and future tests. This is necessary because the starwars
controller that I’m testing is calling the SWAPI films
endpoint.
In the it
block, you’ll see a definition followed by an async function
call, which indicates to the runner that there is asynchronous behavior to be tested, enabling us to use the await
call that you see in line 7.
Finally, we get to the test itself. First, we define a stub with the following code:
sinon.stub(swapi, 'films').returns(swapiFilmListMock);
This stub signals to Mocha to use the mock file whenever the films
method is called from the swapis
API service.
To free up this method in your test runner, you’ll need to call the restore
. This isn’t really a problem for us since we’re just running one test, but, if you had many tests defined, then you’d want to do this. I’ve included it here just to indicate convention.
Finally, we have our actual method call and an expect
to check the result:
const response = await starwars.filmList(); expect(response).to.deep.equal(starwarsFilmListMock);
When you run this test, it should call the filmList
controller and return what would be expected with the starwarsFilmListMock
response. Let’s run it. Install Mocha globally in your terminal with the code below:
npm i mocha --global
Then, run the test with the following:
mocha test/firstUnit
You should see the following output on your screen:
On a high level, you can expect this with any unit tests. We did the following:
Arrange
: Set up our data by creating a stubAct
: Made a call to our controller method to act on the testAssert
: Asserted that the response from the controller equals our saved mock valueThis pattern of Arrange
, Act
, and Assert
is a good thing to keep in mind when running any test.
Our first test covered the basic setup, so you now have a basic understanding of arrange
, act
, and assert
. Now, let’s consider a more complicated test:
describe('Film', function() { afterEach(function() { swapi.film.restore(); swapi.people.restore(); }); it('should return all the metadata for a film when called', async function() { const filmId = '1'; const peopleId = '1'; const planetId = '1'; const starshipId = '2'; const vehicleId = '4'; const speciesId = '1'; sinon .stub(swapi, 'film') .withArgs(filmId) .resolves(swapiFilmMock); sinon .stub(swapi, 'people') .withArgs(peopleId) .resolves(swapiPeopleMock); sinon .stub(swapi, 'planet') .withArgs(planetId) .resolves(swapiPlanetMock); sinon .stub(swapi, 'starship') .withArgs(starshipId) .resolves(swapiStarshipMock); sinon .stub(swapi, 'vehicle') .withArgs(vehicleId) .resolves(swapiVehicleMock); sinon .stub(swapi, 'species') .withArgs(speciesId) .resolves(swapiSpeciesMock); const response = await starwars.film(filmId); expect(response).to.deep.equal(starwarsFilmMock); }); });
Wow, that’s a lot of stubs! But it’s not as scary as it looks; basically, this test does the same thing as our previous example.
I wanted to highlight this test because it uses multiple stubs with args. As I mentioned before, ms-starwars bundles several HTTP calls under the hood. The one call to the film
endpoint actually makes calls to film
, people
, planet
, starship
, vehicle
, and species
. All of these mocks are necessary for this.
Generally speaking, your unit tests will look like this example. You can perform similar behaviors for PUT
, POST
, and DELETE
method calls.
Notice that we used a stub and mock in our return value. We tested the application logic and were not concerned with the application working in its entirety. Tests that test full flows typically are integration or end-to-end tests.
For the unit tests, we only focused on testing the code itself without being concerned with end-to-end flows. Our focus was making sure that the application methods have the expected outputs from the expected input.
With integration tests as well as with end-to-end tests, we’re testing flows. Integration tests are important because they make sure that individual components of your application can work together.
With microservices, this is important because you’ll have different classes defined that together create a microservice. You might also have a single project with multiple services, and you’d write integration tests to make sure they work well together.
For the ms-starwars project, we’ll make sure that the orchestration provided by the controllers works with the individual API calls to the SWAPI endpoints. Go ahead and define a new file with /test/firstIntegration.js
and add the following code to the top of the file:
const chai = require('chai'); const chaiHttp = require('chai-http'); chai.use(chaiHttp); const app = require('../server'); const should = chai.should(); const expect = chai.expect; // starwars mocks const starwarsFilmListMock = require('../mocks/starwars/film_list.json');
In the code above, we first define an instance of Chai
and Chai HTTP
. Next, we define an instance of the actual app itself from the server.js
file.
Then, we pull in should
and expect
, and finally, we pull in a mock that we’ll use to compare the response. With that done, let’s build our test:
describe('GET /films-list', () => { it('should return a list of films when called', done => { chai .request(app) .get('/films-list') .end((err, res) => { res.should.have.status(200); expect(res.body).to.deep.equal(starwarsFilmListMock); done(); }); }); });
The code above is similar to the syntax we saw before; we have the describe
with an it
, which sets up the test and indicates that the test is actually occurring here.
We make a call to chai.request
and pass our reference to our app server.js
file, allowing us to engage the Chai HTTP library to make our HTTP call.
We then pass a GET
call to the films-list
endpoint from our API and call end
to signal behavior on what to do when the call completes. We expect a status of 200
with the following code:
res.should.have.status(200);
Then, we expect a body to equal our mock with the following code:
expect(res.body).to.deep.equal(starwarsFilmListMock);
Finally, to stop the test runner, we call done()
, which starts your application locally, runs the request you specify, like GET
, POST
, PUT
, DELETE
, etc., enables you to capture the response, and brings down the local running application.
Now that our integration test is set up, run it with the following code:
mocha --exit test/firstIntegration > note that the `--exit` flag is being passed here just to signal to the test runner to stop after the test finishes. You can run it without `--exit` , but it would just wait for you to manually cancel the process.
You should see something like the following:
There are other frameworks that can run your application besides your test runner. However, Chai HTTP is clean, easy to implement with any project, and doesn’t require additional frameworks in general.
I recommend playing with the Chai HTTP library and your application and consulting the documentation when you have questions.
With any test suite, you should also consider an overall strategy. You should ask yourself, what do I want to test? Have I covered all the application flows? Are there specific edge conditions that I want to test? Do I need to provide reports for my product owner or team lead?
The frameworks I’ve covered so far enable you to run tests, but there are many options for test reporters. In addition, there are several test tools available that provide code coverage, for example, the Istanbul Test Coverage Tool.
One failure I’ve encountered with teams is that they think if the code coverage tool says you have 90 percent coverage, then you’re good, but this isn’t really accurate. When you write your tests, you should consider odd behavior and testing for specific inputs. Just because your code has been covered doesn’t mean that the outliers and edge cases have been covered.
With any test suite, you should consider not just the “happy path” and “sad path” scenarios, but any edge cases and specific cases for your customers. In addition, often times with integration and end-to-end tests, you may be reliant on external HTTP calls.
If the external APIs are down, this could be problematic. Recently, I built another microservice that did just that. I employed a mock server to run my tests and used start-server-and-test to run both together. This proved to be a great experience because I could run my tests in isolation without relying on external APIs.
I recommend checking out my article, which is a great example of an innovative approach to testing without dependencies. Overall, your test strategy will be based on your situation. I recommend that you look beyond just the “happy path” or “expected cases” and consider all the other factors.
Hopefully, this article has provided you with a good introduction to testing your Node.js applications.
First, we discussed the different frameworks and technologies you can use in your Node.js applications. Then, we walked through running unit and integration tests for our Node.js applications.
I used the Express framework, but these patterns could apply to other Node.js frameworks as well. I recommend checking out the links I’ve provided above, as well as the documentation for each framework.
Follow me on Twitter at @AndrewEvans0102. Thanks for reading!
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.