Nuno Job
Geek. Open-source enthusiast. Shaping the future of the node.js ☁ @nodejitsu. Founder @thenodefirm& curator @lxjs
This is the old blog, check the new one at nunojob.com

Mock HTTP Integration Testing in Node.js using Nock and Specify

One of the big things in releasing nano 3 was updating the tests. I really wanted this release out before LXJS but I had a lot of requirements for the tests:

I decided to use nock and specify for this. nock because it's the only tool that can do this job. And specify because despite being written in 100 LOC it is the only tool that can fill all of these requirements. (disclaimer: I'm only familiar with node-tap, mocha, & vows so other testing things might be able to do all of these requirements)

In this article we are going to npm install nano and try to write some tests for it. This is strange but serves for demonstration purposes only. You can check the nano tree for more tests if you feel like checking more stuff out after you are done here.

  mkdir nano_mock_testing
  cd nano_mock_testing
  npm install nano specify nock

These are the versions that got installed in my pc

   nock@0.13.0 ./node_modules/nock 
   specify@0.4.0 ./node_modules/specify 
   ├── cycle@1.0.0
   ├── colors@0.6.0-1
   └── difflet@0.2.1
   nano@3.0.1 ./node_modules/nano 
   ├── errs@0.2.0
   ├── request@2.9.202
   └── follow@0.8.0

And my node versions

   $ node -e "console.log(process.versions)"
   { node: '0.6.7',
     v8: '3.6.6.15',
     ares: '1.7.5-DEV',
     uv: '0.6',
     openssl: '0.9.8r' }

Now we can test the insert functionality of nano. Let's write two tests, on testing a simple insert and another inserting functions inside documents. The test has a setup stage where we create a database and a teardown stage where we destroy the database we created. Because of specify can handle uncaught exceptions we know that teardown will always execute, so this won't affect your future tests even if you run in un-mocked mode.

  var specify  = require('specify')
    , helpers  = require('./helpers')
    , timeout  = helpers.timeout
    , nano     = helpers.nano
    , nock     = helpers.nock
    ;

  // this will work with nocks when you set NOCK=on
  // and without nocks when you don't set the environment variable
  // NOCK
  var mock = nock(helpers.couch, "doc/insert")
    , db   = nano.use("doc_insert")
    ;

  specify("setup", timeout, function (assert) {
    nano.db.create("doc_insert", function (err) {
      assert.equal(err, undefined, "Failed to create database");
    });
  });

  specify("simple", timeout, function (assert) {
    db.insert({"foo": "baz"}, "foobaz", function (error, foo) {   
      assert.equal(error, undefined, "Should have stored foo");
      assert.equal(foo.ok, true, "Response should be ok");
      assert.ok(foo.rev, "Response should have rev");
    });
  });

  specify("functions", timeout, function (assert) {
    db.insert({fn: function () { return true; },
    fn2: "function () { return true; }"}, function (error, fns) {   
      assert.equal(error, undefined, "Should have stored foo");
      assert.equal(fns.ok, true, "Response should be ok");
      assert.ok(fns.rev, "Response should have rev");
      db.get(fns.id, function (error, fns) {
        assert.equal(fns.fn, fns.fn2, "fn matches fn2");
        assert.equal(error, undefined, "Should get foo");
      });
    });
  });

  specify("teardown", timeout, function (assert) {
    nano.db.destroy("doc_insert", function (err) {
      assert.equal(err, undefined, "Failed to destroy database");
      assert.ok(mock.isDone(), "Some mocks didn't run");
    });
  });

  specify.run(process.argv.slice(2));

This won't run. We reference a helpers.js that is not yet there. Let's create it. It needs to add functionality to run tests in mocked and un-mocked mode, and expose things like the CouchDB configuration and default timeouts.

  var path    = require('path')
    , fs      = require('fs')
    , cfg     = {couch: "http://localhost:5984", timeout: 50000}
    , nano    = require('nano')
    , helpers = exports
    ;

  function endsWith (string, ending) {
    return string.length >= ending.length && 
      string.substr(string.length - ending.length) == ending;
  }

  function noop(){}

  function fake_chain() {
    return {
        "get"                  : fake_chain
      , "post"                 : fake_chain
      , "delete"               : fake_chain
      , "put"                  : fake_chain
      , "intercept"            : fake_chain
      , "done"                 : fake_chain
      , "isDone"               : function () { return true; }
      , "filteringPath"        : fake_chain
      , "filteringRequestBody" : fake_chain
      , "matchHeader"          : fake_chain
      , "defaultReplyHeaders"  : fake_chain
      , "log"                  : fake_chain
    };
  }

  helpers.timeout = cfg.timeout;
  helpers.nano = nano(cfg.couch);
  helpers.Nano = nano;
  helpers.couch = cfg.couch;
  helpers.pixel = "Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BABgAAAAA" + 
                  "AAAAAAATCwAAEwsAAAAAAAAAAAAAWm2CAA==";

  helpers.loadFixture = function helpersLoadFixture(filename, json) {
    var contents = fs.readFileSync(
      path.join(__dirname, 'fixtures', filename), 'ascii');
    return json ? JSON.parse(contents): contents;
  };

  helpers.nock = function helpersNock(url, fixture) {
    if(process.env.NOCK) {
      var nock    = require('nock')
        , nocks   = helpers.loadFixture(fixture + '.json', true)
        ;
      nocks.forEach(function(n) {
        var path     = n.path
          , method   = n.method   || "get"
          , status   = n.status   || 200
          , response = n.buffer
                     ? new Buffer(n.buffer, 'base64') 
                     : n.response || ""
          , headers  = n.headers  || {}
          , body     = n.base64
                     ? new Buffer(n.base64, 'base64').toString()
                     : n.body
          ;

        if(typeof response === "string" && endsWith(response, '.json')) {
          response = helpers.loadFixture(path.join(fixture, response));
        }
        if(typeof headers === "string" && endsWith(headers, '.json')) {
          headers = helpers.loadFixture(path.join(fixture, headers));
        }
        if(body==="*") {
          nock(url).filteringRequestBody(function(path) {
            return "*";
          })[method](path, "*").reply(status, response, headers);
        } else {
          nock(url)[method](path, body).reply(status, response, headers);
        }
      });
      nock(url).log(console.log);
      return nock(url);
    } else {
      return fake_chain();
    }
  };

Some things to note here. response and body refer to an http response and the body of an http request (e.g. POST). base64 means you have a base64 http request body in the fixture, and buffer means you have a base64 http response that should be converted into a buffer.

This is not the perfect solution. It's just a solution that fits testing nano, and also the reason why I believe this boilerplate code is a good thing vs. having it in a library: Having this in nock would mean making these decisions for you, and I personally think you should have as little decisions made for your users as possible.

Another thing to notice is that you can use * to accept any http request body, and that if a header or response ends with .json the helper will try to load a fixture with that name. We aren't going to use this, but it can be handy.

We should now be able to run this unmocked (we haven't create the mocks yet remember):

  $ node insert.js 

    /insert.js

  ✔ 1/1 setup 
  ✔ 3/3 simple 
  ✔ 5/5 functions 
  ✔ 2/2 teardown 
  ✔ 11/11 summary

And the database got destroyed (as it should) by the teardown phase:

  $ curl localhost:5984/doc_insert
  {"error":"not_found","reason":"no_db_file"}

To run the mocked tests we need to add the fixture at fixtures/doc/insert.json. This isn't required by nock, it's something we defined in our helpers.js file:

  [
    { "method"   : "put"
    , "path"     : "/doc_insert"
    , "status"   : 201
    , "response" : "{ \"ok\": true }" 
    }
  , { "method"   : "put"
    , "status"   : 201
    , "path"     : "/doc_insert/foobaz"
    , "body"     : "{\"foo\":\"baz\"}"
    , "response" : "{\"ok\":true,\"id\":\"foobaz\",\"rev\":\"1-611488\"}"
    }
  , { "method"   : "post"
    , "status"   : 201
    , "path"     : "/doc_insert"
    , "body"     : "{\"fn\":\"function () { return true; }\",\"fn2\":\"function () { return true; }\"}"
    , "response" : "{\"ok\":true,\"id\":\"123\",\"rev\":\"1-611488\"}"
    }
  , { "path"     : "/doc_insert/123"
    , "response" : "{\"fn\":\"function () { return true; }\",\"fn2\":\"function () { return true; }\",\"id\":\"123\",\"rev\":\"1-611488\"}"
    }
  , { "method"   : "delete"
    , "path"     : "/doc_insert"
    , "response" : "{ \"ok\": true }" 
    }
  ]

Exit CouchDB and try it out:

  $ NOCK=on node insert.js 

    /insert.js

  ✔ 1/1 setup 
  ✔ 3/3 simple 
  ✔ 5/5 functions 
  ✔ 2/2 teardown
  ✔ 11/11 summary 

If you try running the tests un-mocked it will fail, since you switched CouchDB off!

Testing binaries streams

Wow you made it this far! Let's just add a simple streaming test where we insert a pixel in CouchDB. Let's call it pipe.js:

  var fs       = require('fs')
    , path     = require('path') 
    , specify  = require('specify')
    , helpers  = require('./helpers')
    , timeout  = helpers.timeout
    , nano     = helpers.nano
    , nock     = helpers.nock
    , pixel    = helpers.pixel
    ;

  var mock = nock(helpers.couch, "att/pipe")
    , db   = nano.use("att_pipe")
    ;

  specify("setup", timeout, function (assert) {
    nano.db.create("att_pipe", function (err) {
      assert.equal(err, undefined, "Failed to create database");
    });
  });

  specify("test", timeout, function (assert) {
    var buffer   = new Buffer(pixel, 'base64')
      , filename = path.join(__dirname, '.temp.bmp')
      , ws       = fs.createWriteStream(filename)
      ;
      ws.on('close', function () {
        assert.equal(fs.readFileSync(filename).toString('base64'), pixel);
        fs.unlinkSync(filename);
      });
      db.attachment.insert("new", "att", buffer, "image/bmp", 
      function (error, bmp) {
        assert.equal(error, undefined, "Should store the pixel");
        db.attachment.get("new", "att", {rev: bmp.rev}).pipe(ws);
      });

  });

  specify("teardown", timeout, function (assert) {
    nano.db.destroy("att_pipe", function (err) {
      assert.equal(err, undefined, "Failed to destroy database");
      assert.ok(mock.isDone(), "Some mocks didn't run");
    });
  });

  specify.run(process.argv.slice(2));

And create the fixture in fixtures/att/pipe.js:

  [
    { "method"   : "put"
    , "path"     : "/att_pipe"
    , "status"   : 201
    , "response" : "{ \"ok\": true }" 
    }
  , { "method"   : "put"
    , "path"     : "/att_pipe/new/att"
    , "base64"   : "Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BABgAAAAAAAAAAAATCwAAEwsAAAAAAAAAAAAAWm2CAA=="
    , "status"   : 201
    , "response" : "{\"ok\":true,\"id\":\"new\",\"rev\":\"1-3b1f88\"}\n"
    }
  , { "path"     : "/att_pipe/new/att?rev=1-3b1f88"
    , "status"   : 200
    , "buffer"   : "Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BABgAAAAAAAAAAAATCwAAEwsAAAAAAAAAAAAAWm2CAA=="
    }
  , { "method"   : "delete"
    , "path"     : "/att_pipe"
    , "status"   : 200
    , "response" : "{ \"ok\": true }" 
    }
  ]

Updated Nock

Nock now supports NOCK_OFF setting. Check the documentation for details. This means that in your helpers.js file fake_chain and the if should no longer be necessary