Testing and Debugging a Containerized Node application

Introduction

Containers in general and Docker in specific have changed the way how we think about running and deploying software quite a bit. Running an application in a container instead of directly on your computer or server has a lot of advantages. But what about testing and debugging? Are we still able to run tests and debug an application as easy and straight forward when we run it inside a container as we do when we run it directly on our host? In this post I want to give a step by step introduction on how to configure your application and how to adjust your development workflow to achieve exactly that. The source code accompanying this post can be found here.

Creating a sample application

I have chosen to build a simple node JS application that I’ll use to demonstrate testing and debugging when running in a container. The principles show here can be easily adapted to other types of applications, e.g. Python, Go or .NET Core.

Initialize the application

Create a new folder for the project. In the terminal navigate to this folder and run the command

npm init

When asked about the start file enter server.js and when asked for the test command then enter jasmine-node spec. This will generate our package.json file. It should look similar to this

{
  "name": "debug-node",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "test": "jasmine-node spec",
    "start": "node server.js"
  },
  "author": "Gabriel Schenker",
  "license": "ISC",
  "dependencies": {
  }
}

This sample application will use express JS and thus we install this library using

npm install express --save

Now we can add a file server.js to the project. It should contain the following code

var express = require('express');
var app = express();
var port = 3000;
var primes = require('./primes.js');

app.get('/', function(req, res){
    res.status(200).send('Testing and Debugging Sample');
})

app.get('/isPrime/:number', function(req, res){
    res.status(200).send(primes.isPrime(req.params.number));    
})

exports.stop = function(){
    server.close();
}
var server = app.listen(port, function(){
  console.log("Express server listening on port %d in %s mode", port, app.settings.env);
});

On line 4 we import a module primes.js which contains the logic to determine whether or not a given number is a prime. Thus let’s add such a file primes.js to the project and add the following content to it

exports.isPrime = function(number){
    for(var i = 2; i < number/2; i++) {
        if(number % i === 0) {
            return false;
        }
    }
    return number > 1;
}

Note how we export a function isPrime which is used on line 11 in the server.js file. I didn’t spend a lot of time to come up with the most efficient way of determining whether or not the number is a prime, thus bare with me…

Adding a Dockerfile

To be able to package our application into a Docker image we need to add a file called Dockerfile to the solution. The content of this file should look like this

FROM node:4.2.3
# for testing purposes
RUN npm install -g jasmine-node
RUN mkdir /app
WORKDIR /app
COPY package.json /app/
RUN npm install
COPY . /app
EXPOSE 3000 5858
ENTRYPOINT ["npm", "start"]

Note how on line 3 we install jasmine-node which we need to run our tests and how we expose not only port 3000 but also port 5858. The latter will be used to attach the debugger. Also note how we first copy only the package.json into the image and run npm install and only after this copy the remainder of the application folder into the image. This helps us to optimize the build of the Docker image. When building an image Docker will only rebuild the layers of the image that have changed. And it is much less likely that we change the content of the package.json file than that we change some code in the application.

Running the application in a container

Now let’s first build the Docker image of our application. Execute this command in the terminal

docker build -t my-app .

(Don’t forget the point at the end of the command!) And now we can run a container from this image

docker run -d --name my-app -p 3000:3000 my-app

We should see a long ID (hash code) being output in the terminal. To double check whether or not the container is running we can use the docker ps command and we should see this

If for some reason the container is not running we can use the docker logs myApp command to see the logging information produced by our application in the terminal.

Adding tests

I have selected Jasmine as my testing framework. To use Jasmine in a node application we can install the jasmine-node module globally on our development machine. Oh, wait a second… why do we need to install this module on our machine in the first place? Honestly, it is not needed and I only do it to demonstrate how classical development (not using containers) can be easily and completely replaced by container based development. Thus, once you are familiar with Docker and the principles on how to leverage a Docker container for testing and debugging you can stop installing frameworks and test frameworks on you machine. Now, let’s install jasmine-node

npm install -g jasmine-node

we can now start defining our tests. Let’s first start with a simple unit test. Please add a folder spec to your application. Then add a file called primes-spec.js to this folder. Add the following content to this file

var sut = require('../primes.js');
describe("when evaluating if a given number is a prime", function(){
  describe("when the number is a prime", function(){
    it("should return 'true'", function(){
      expect(sut.isPrime(11)).toBe(true);
    });
  });
});

I am not going to discuss the syntax of a Jasmine test here. Please consult the excellent documentation here if you are not familiar with Jasmine. Once we have defined a test we can now execute the following command in our terminal

npm test

This translates to the call jasmine-node spec as we have defined in our project.json file. The output in the terminal should look similar to this

We can of course also add some integration or API tests to the application. For this we need to add the request module to our application. Use this command to add the module

npm install request --save

Now add a file called server-spec.js to the spec folder. It should have the following content

var request = require("request");
var server = require("../server.js");
var base_url = "http://localhost:3000/";

describe("when testing projects endpoint", function(){
    it("should return status OK", function(done){
        request.get(base_url+"isPrime/11", function(error, response, body) {
            expect(response.statusCode).toBe(200);
            done();
        });
    });
    it("should return list of projects", function(done){
        request.get(base_url+'isPrime/11', function(error, response, body) {
            expect(JSON.parse(response.body)).toBe(true);
            done();
        })
    });
    
    // clean up... shutting down the server
    it('should clean up', function(){
        server.stop();
    })
})

Once again we can run all tests by executing npm test in the terminal.

We can now run the test in a container. For this we use docker-compose. Let’s a add a file docker-compose.test.yml file to our project. The content should be

sut:
  build: .
  ports:
    - "3000:3000"
  entrypoint: jasmine-node spec

This yaml file contains instructions to build a container image using the Dockerfile in the current directory, open port 3000 and map it to port 3000 on the host. Finally we override the value of entrypoint as defined in the Dockerfile with the value npm test. Now on in the terminal enter the following command

docker-compose -f docker-compose.test.yml up --build

This will build the Docker image, run a container using this image with the overriden entrypoint. If all works well then we should see an output like this in the terminal

And as expected, the logs tell us that after building the image a container is run and all tests are executed. In this specific case all tests were successful and thus the exit code is 0 (zero). If some test would fail the exit code would be 1. This is important to know when we execute the tests as part of Continuous Integration (CI). Now we can tear down everything by using

docker-compose -f docker-compose.test.yml down

With this we have shown that to run tests against a node application we do not need to do this on our host but can execute them directly in a Docker container. The advantage of it is that we do not need to install any specific software other than a decent editor to edit our project file on our host (e.g. laptop). We can run our application in a Docker container and test it manually or using automated tests. This is huge when we think about it. Everything application specific is in the container. Our host remains clean. That also means that you can now work on different projects with potentially conflicting frameworks or versions of the same framework. Since you’re always only installing the necessary libraries and frameworks in the container and each container is totally opaque there are no more conflicts!

Debugging the application

Running automated tests against our application is one thing but sometimes we still need to debug our application. We want to be able to step through our code line by line and inspect the value of variables, etc. Can we do that if our application runs in a container? The answer is a bold YES. In the following I am showing how we can achieve this for our node application. I am using Visual Studio Code (which just got released in version 1.0 and runs on Windows, Mac and Linux) as my editor. The technique I am showing here has first been published by Alex Zeitler here. I have tweaked it a bit to fit with my needs. First we need to know on which IP address our Docker host runs. Use the following command to find out

docker-machine ip default

This assumes that the name of the VM which is your Docker host is default. In my case the IP address is 192.168.99.100 which is default when you are using Docker Toolbox. Next we add another docker-compose file which we’ll be using for debugging. Add a file called docker-compose.debug.yml to the project and add the following content to the file

sut:
  build: .
  ports:
    - "3000:3000"
    - "5858:5858"
  entrypoint: node --debug=5858 server.js

Similar to the docker-compose file for testing we are using the Dockerfile to build the image, we open port 3000 and(!) port 5858 and map them to the same ports on the host. Finally we override the entrypoint with node --debug=5858 server.js. That is we start node in debug mode listening on port 5858. We can start a debug session by using this command

docker-compose -f docker-compose.debug.yml up --build -d

As a last step we need to configure Visual Studio Code to attach to the node application running in the container. To do this we add a folder .vscode to our project. Inside this folder we add a file launch.json.

The content of this file should be like this

{
	"version": "0.1.0",
	// List of configurations. Add new configurations or edit existing ones.
	"configurations": [
		{
			// Name of configuration; appears in the launch configuration drop down menu.
			"name": "Launch server.js",
			// Type of configuration.
			"type": "node",
			// Workspace relative or absolute path to the program.
			"program": "server.js",
			// Automatically stop program after launch.
			"stopOnEntry": false,
			// Command line arguments passed to the program.
			"args": [],
			// Workspace relative or absolute path to the working directory of the program being debugged. Default is the current workspace.
			"cwd": ".",
			// Workspace relative or absolute path to the runtime executable to be used. Default is the runtime executable on the PATH.
			"runtimeExecutable": null,
			// Optional arguments passed to the runtime executable.
			"runtimeArgs": ["--nolazy"],
			// Environment variables passed to the program.
			"env": {
				"NODE_ENV": "development"
			},
			// Use JavaScript source maps (if they exist).
			"sourceMaps": false,
			// If JavaScript source maps are enabled, the generated code is expected in this directory.
			"outDir": null
		},
		{
			"name": "Attach",
			"type": "node",
			"request": "attach",
			"port": 5858,
			"address": "192.168.99.100",
			"restart": false,
			"sourceMaps": false,
			"outDir": null,
			"localRoot": "${workspaceRoot}/",
			"remoteRoot": "/app/"
		}
	]
}

The interesting part for debugging is the section with the name Attach. We specifically need to make sure that the entry address is set to the correct IP address of our Docker host (192.168.99.100 in my case). Also double check that the port corresponds to the one node is listening at. Once we have added the launch configuration we can click on the debug symbol in VS Code. Make sure that the Attach configuration is selected and then hit the run button.

Now add a break point to line 7 of the server.js file. Open a browser and navigate to 192.168.99.100:3000 (replace the IP address with your own if you have a different one). The debugger should stop at line 7

In the debug window of VS Code we can now see all kinds of interesting information like the content of variables in scope and the call stack. We can also define watches. With other words: we have a full debugging experience similar to what we’re used if we debug directly on our host instead in a container.

Summary

In this post I have shown how we can run automated tests against a node JS application running in a Docker container as well as how we can debug an application running in a container. We can achieve the same results even though the application is now not running natively on our development system but encapsulated in a container. The benefit of all this is that we don’t need to pollute our host (laptop) with libraries and frameworks to support testing and debugging. Everything is encapsulated in the containers and we only need a nice code editor on our host. What I have shown here can also be applied to other type of applications. In a next post I will do this for a ASP.NET Core application.

About Gabriel Schenker

Gabriel N. Schenker started his career as a physicist. Following his passion and interest in stars and the universe he chose to write his Ph.D. thesis in astrophysics. Soon after this he dedicated all his time to his second passion, writing and architecting software. Gabriel has since been working for over 25 years as a consultant, software architect, trainer, and mentor mainly on the .NET platform. He is currently working as senior software architect at Alien Vault in Austin, Texas. Gabriel is passionate about software development and tries to make the life of developers easier by providing guidelines and frameworks to reduce friction in the software development process. Gabriel is married and father of four children and during his spare time likes hiking in the mountains, cooking and reading.
This entry was posted in containers, docker, introduction, node, tutorial. Bookmark the permalink. Follow any comments here with the RSS feed for this post.
  • Luke

    FYI your “tag” links don’t work when there is a space in the tag itself.

    For example https://lostechies.com/gabrielschenker/category/continuous-deployment/ doesn’t work.

    Appart from that – Great blog :)

  • Tom

    Thanks for the tutorial.

    Possibly dumb Docker newbie question: What’s the best way to update files within the container? The only way I see is rebuilding the Docker image. I could see that could get quite tiresome quite quickly!

    • Hugo

      imho mount external directory as a volume (ie. /mnt/sda2)

      • nice way to do it, didn’t think of that!

    • gabrielschenker

      @Hugo is right. Mount volumes. I’ll talk about this in my next post

  • Daniela

    How to debug without VS code? For example, using Atom or Sublime.

  • Thanks for the article, Gabriel!

    You mentioned that you are supposed to execute the tests as part of Continuous Integration, but you didn’t clarify how to collect tests status (success or failure) after tests run, something that’s vital for CI. Can you please update on that?