Repository: node-nock/nock
Pull request #723 (2016-10-12 14:23:30)

Add optionally() and make isDone and pendingMocks consistent.
This PR adds `optionally()`, so you can define mocks that might happen, but not worry about it if they don't (I'm using this for some common test setup where for almost every test in need a basic request mocked out, but not for all of them).

Along the way I noticed `isDone()` and `pendingMocks()` weren't actually consistent. Only `isDone()` ever considered persisted mocks to be 'done' (they stayed in `pendingMocks()` forever), and only `isDone()` supported `options.requireDone` (see below). I've now made them consistent so, tidied the implementations together, and simplified this significantly on the way.

There was an existing feature sort-of like `optionally()` already present: you could pass `requireDone: false` as an option to an interceptor. Passing as an option is less nice UX for my case, but more problematically it was previously inconsistent between pendingMocks (which ignored it) and isDone (which didn't), it wasn't undocumented _anywhere_, and it doesn't appear to have ever been used (no results in google or in code on github that aren't from direct copies of this codebase). I've removed this, since `optionally()` does the same thing. Could keep it, your call, but I'd just let it go IMO.


Patch:
diff --git a/README.md b/README.md
index 7b2db64..fc30920 100644
--- a/README.md
+++ b/README.md
@@ -44,12 +44,14 @@ For instance, if a module performs HTTP requests to a CouchDB server or makes HT
     - [Path filtering](#path-filtering)
     - [Request Body filtering](#request-body-filtering)
     - [Request Headers Matching](#request-headers-matching)
+    - [Optional Requests](#optional-requests)
     - [Allow __unmocked__ requests on a mocked hostname](#allow-unmocked-requests-on-a-mocked-hostname)
 - [Expectations](#expectations)
     - [.isDone()](#isdone)
     - [.cleanAll()](#cleanall)
     - [.persist()](#persist)
     - [.pendingMocks()](#pendingmocks)
+    - [.activeMocks()](#activemocks)    
 - [Logging](#logging)
 - [Restoring](#restoring)
 - [Turning Nock Off (experimental!)](#turning-nock-off-experimental)
@@ -810,6 +812,23 @@ var scope = nock('http://api.myservice.com')
                 })
 ```
 
+## Optional Requests
+
+By default every mocked request is expected to be made exactly once, and until it is it'll appear in `scope.pendingMocks()`, and `scope.isDone()` will return false (see [expectations](#expectations)). In many cases this is fine, but in some (especially cross-test setup code) it's useful to be able to mock a request that may or may not happen. You can do this with `optionally()`. Optional requests are consumed just like normal ones once matched, but they do not appear in `pendingMocks()`, and `isDone()` will return true for scopes with only optional requests pending.
+
+```js
+var example = nock("http://example.com");
+example.pendingMocks() // []
+example.get("/pathA").reply(200);
+example.pendingMocks() // ["GET http://example.com:80/path"]
+
+// ...After a request to example.com/pathA:
+example.pendingMocks() // []
+
+example.get("/pathB").optionally().reply(200);
+example.pendingMocks() // []
+```
+
 ## Allow __unmocked__ requests on a mocked hostname
 
 If you need some request on the same host name to be mocked and some others to **really** go through the HTTP stack, you can use the `allowUnmocked` option like this:
@@ -900,6 +919,22 @@ It is also available in the global scope:
 console.error('pending mocks: %j', nock.pendingMocks());
 ```
 
+## .activeMocks()
+
+You can see every mock that is currently active (i.e. might potentially reply to requests) in a scope using `scope.activeMocks()`. A mock is active if it is pending, optional but not yet completed, or persisted. Mocks that have intercepted their requests and are no longer doing anything are the only mocks which won't appear here.
+
+You probably don't need to use this - it mainly exists as a mechanism to recreate the previous (now-changed) behavior of `pendingMocks()`.
+
+```js
+console.error('active mocks: %j', scope.activeMocks());
+```
+
+It is also available in the global scope:
+
+```js
+console.error('active mocks: %j', nock.activeMocks());
+```
+
 # Logging
 
 Nock can log matches if you pass in a log function like this:
diff --git a/lib/intercept.js b/lib/intercept.js
index e633935..2d7b69b 100644
--- a/lib/intercept.js
+++ b/lib/intercept.js
@@ -293,24 +293,34 @@ function isActive() {
 
 }
 
-function isDone() {
-  return _.every(allInterceptors, function(interceptors) {
-    return _.every(interceptors.scopes, function(interceptor) {
-      return interceptor.__nock_scope.isDone();
-    });
-  });
-}
-
-function pendingMocks() {
+function interceptorScopes() {
   return _.reduce(allInterceptors, function(result, interceptors) {
     for (var interceptor in interceptors.scopes) {
-      result = result.concat(interceptors.scopes[interceptor].__nock_scope.pendingMocks());
+      result = result.concat(interceptors.scopes[interceptor].__nock_scope);
     }
 
     return result;
   }, []);
 }
 
+function isDone() {
+  return _.every(interceptorScopes(), function(scope) {
+    return scope.isDone();
+  });
+}
+
+function pendingMocks() {
+  return _.flatten(_.map(interceptorScopes(), function(scope) {
+    return scope.pendingMocks();
+  }));
+}
+
+function activeMocks() {
+  return _.flatten(_.map(interceptorScopes(), function(scope) {
+    return scope.activeMocks();
+  }));
+}
+
 function activate() {
 
   if(originalClientRequest) {
@@ -390,6 +400,7 @@ module.exports.activate = activate;
 module.exports.isActive = isActive;
 module.exports.isDone = isDone;
 module.exports.pendingMocks = pendingMocks;
+module.exports.activeMocks = activeMocks;
 module.exports.enableNetConnect = enableNetConnect;
 module.exports.disableNetConnect = disableNetConnect;
 module.exports.overrideClientRequest = overrideClientRequest;
diff --git a/lib/interceptor.js b/lib/interceptor.js
index a1055e9..335a9f8 100644
--- a/lib/interceptor.js
+++ b/lib/interceptor.js
@@ -40,6 +40,13 @@ function Interceptor(scope, uri, method, requestBody, interceptorOptions) {
 
     this.delayInMs = 0;
     this.delayConnectionInMs = 0;
+
+    this.optional = false;
+}
+
+Interceptor.prototype.optionally = function optionally() {
+    this.optional = true;
+    return this;
 }
 
 Interceptor.prototype.replyWithError = function replyWithError(errorMessage) {
diff --git a/lib/scope.js b/lib/scope.js
index d79ce41..ac2be1d 100644
--- a/lib/scope.js
+++ b/lib/scope.js
@@ -119,38 +119,35 @@ Scope.prototype.delete = function _delete(uri, requestBody, options) {
 };
 
 Scope.prototype.pendingMocks = function pendingMocks() {
-  return Object.keys(this.keyedInterceptors);
+  var self = this;
+
+  var pendingInterceptorKeys = Object.keys(this.keyedInterceptors).filter(function (key) {
+    var interceptorList = self.keyedInterceptors[key];
+    var pendingInterceptors = interceptorList.filter(function (interceptor) {
+      // TODO: This assumes that completed mocks are removed from the keyedInterceptors list
+      // (when persistence is off). We should change that (and this) in future.
+      var persistedAndUsed = self._persist && interceptor.interceptionCounter > 0;
+      return !persistedAndUsed && !interceptor.optional;
+    });
+    return pendingInterceptors.length > 0;
+  });
+
+  return pendingInterceptorKeys;
 };
 
+// Returns all keyedInterceptors that are active.
+// This incomplete interceptors, persisted but complete interceptors, and
+// optional interceptors, but not non-persisted and completed interceptors.
+Scope.prototype.activeMocks = function activeMocks() {
+  return Object.keys(this.keyedInterceptors);
+}
+
 Scope.prototype.isDone = function isDone() {
   var self = this;
   // if nock is turned off, it always says it's done
   if (! globalIntercept.isOn()) { return true; }
 
-  var keys = Object.keys(this.keyedInterceptors);
-  if (keys.length === 0) {
-    return true;
-  } else {
-    var doneHostCount = 0;
-
-    keys.forEach(function(key) {
-      var doneInterceptorCount = 0;
-
-      self.keyedInterceptors[key].forEach(function(interceptor) {
-        var isRequireDoneDefined = !_.isUndefined(interceptor.options.requireDone);
-        if (isRequireDoneDefined && interceptor.options.requireDone === false) {
-          doneInterceptorCount += 1;
-        } else if (self._persist && interceptor.interceptionCounter > 0) {
-          doneInterceptorCount += 1;
-        }
-      });
-
-      if (doneInterceptorCount === self.keyedInterceptors[key].length ) {
-        doneHostCount += 1;
-      }
-    });
-    return (doneHostCount === keys.length);
-  }
+  return this.pendingMocks().length === 0;
 };
 
 Scope.prototype.done = function done() {
@@ -346,6 +343,7 @@ module.exports = extend(startScope, {
   isActive: globalIntercept.isActive,
   isDone: globalIntercept.isDone,
   pendingMocks: globalIntercept.pendingMocks,
+  activeMocks: globalIntercept.activeMocks,
   removeInterceptor: globalIntercept.removeInterceptor,
   disableNetConnect: globalIntercept.disableNetConnect,
   enableNetConnect: globalIntercept.enableNetConnect,


Test patch:
diff --git a/tests/browserify-public/browserify-bundle.js b/tests/browserify-public/browserify-bundle.js
index afba3e4..0152c76 100644
--- a/tests/browserify-public/browserify-bundle.js
+++ b/tests/browserify-public/browserify-bundle.js
@@ -1072,24 +1072,34 @@ function isActive() {
 
 }
 
-function isDone() {
-  return _.every(allInterceptors, function(interceptors) {
-    return _.every(interceptors.scopes, function(interceptor) {
-      return interceptor.__nock_scope.isDone();
-    });
-  });
-}
-
-function pendingMocks() {
+function interceptorScopes() {
   return _.reduce(allInterceptors, function(result, interceptors) {
     for (var interceptor in interceptors.scopes) {
-      result = result.concat(interceptors.scopes[interceptor].__nock_scope.pendingMocks());
+      result = result.concat(interceptors.scopes[interceptor].__nock_scope);
     }
 
     return result;
   }, []);
 }
 
+function isDone() {
+  return _.every(interceptorScopes(), function(scope) {
+    return scope.isDone();
+  });
+}
+
+function pendingMocks() {
+  return _.flatten(_.map(interceptorScopes(), function(scope) {
+    return scope.pendingMocks();
+  }));
+}
+
+function activeMocks() {
+  return _.flatten(_.map(interceptorScopes(), function(scope) {
+    return scope.activeMocks();
+  }));
+}
+
 function activate() {
 
   if(originalClientRequest) {
@@ -1169,6 +1179,7 @@ module.exports.activate = activate;
 module.exports.isActive = isActive;
 module.exports.isDone = isDone;
 module.exports.pendingMocks = pendingMocks;
+module.exports.activeMocks = activeMocks;
 module.exports.enableNetConnect = enableNetConnect;
 module.exports.disableNetConnect = disableNetConnect;
 module.exports.overrideClientRequest = overrideClientRequest;
@@ -1219,6 +1230,13 @@ function Interceptor(scope, uri, method, requestBody, interceptorOptions) {
 
     this.delayInMs = 0;
     this.delayConnectionInMs = 0;
+
+    this.optional = false;
+}
+
+Interceptor.prototype.optionally = function optionally() {
+    this.optional = true;
+    return this;
 }
 
 Interceptor.prototype.replyWithError = function replyWithError(errorMessage) {
@@ -1311,17 +1329,6 @@ Interceptor.prototype.replyWithFile = function replyWithFile(statusCode, filePat
     return this.reply(statusCode, readStream, headers);
 };
 
-Interceptor.prototype.replyWithFile = function replyWithFile(statusCode, filePath, headers) {
-    if (! fs) {
-        throw new Error('No fs');
-    }
-    var readStream = fs.createReadStream(filePath);
-    readStream.pause();
-    this.filePath = filePath;
-    return this.reply(statusCode, readStream, headers);
-};
-
-
 // Also match request headers
 // https://github.com/pgte/nock/issues/163
 Interceptor.prototype.reqheaderMatches = function reqheaderMatches(options, key) {
@@ -2828,38 +2835,35 @@ Scope.prototype.delete = function _delete(uri, requestBody, options) {
 };
 
 Scope.prototype.pendingMocks = function pendingMocks() {
-  return Object.keys(this.keyedInterceptors);
+  var self = this;
+
+  var pendingInterceptorKeys = Object.keys(this.keyedInterceptors).filter(function (key) {
+    var interceptorList = self.keyedInterceptors[key];
+    var pendingInterceptors = interceptorList.filter(function (interceptor) {
+      // TODO: This assumes that completed mocks are removed from the keyedInterceptors list
+      // (when persistence is off). We should change that (and this) in future.
+      var persistedAndUsed = self._persist && interceptor.interceptionCounter > 0;
+      return !persistedAndUsed && !interceptor.optional;
+    });
+    return pendingInterceptors.length > 0;
+  });
+
+  return pendingInterceptorKeys;
 };
 
+// Returns all keyedInterceptors that are active.
+// This incomplete interceptors, persisted but complete interceptors, and
+// optional interceptors, but not non-persisted and completed interceptors.
+Scope.prototype.activeMocks = function activeMocks() {
+  return Object.keys(this.keyedInterceptors);
+}
+
 Scope.prototype.isDone = function isDone() {
   var self = this;
   // if nock is turned off, it always says it's done
   if (! globalIntercept.isOn()) { return true; }
 
-  var keys = Object.keys(this.keyedInterceptors);
-  if (keys.length === 0) {
-    return true;
-  } else {
-    var doneHostCount = 0;
-
-    keys.forEach(function(key) {
-      var doneInterceptorCount = 0;
-
-      self.keyedInterceptors[key].forEach(function(interceptor) {
-        var isRequireDoneDefined = !_.isUndefined(interceptor.options.requireDone);
-        if (isRequireDoneDefined && interceptor.options.requireDone === false) {
-          doneInterceptorCount += 1;
-        } else if (self._persist && interceptor.interceptionCounter > 0) {
-          doneInterceptorCount += 1;
-        }
-      });
-
-      if (doneInterceptorCount === self.keyedInterceptors[key].length ) {
-        doneHostCount += 1;
-      }
-    });
-    return (doneHostCount === keys.length);
-  }
+  return this.pendingMocks().length === 0;
 };
 
 Scope.prototype.done = function done() {
@@ -3055,6 +3059,7 @@ module.exports = extend(startScope, {
   isActive: globalIntercept.isActive,
   isDone: globalIntercept.isDone,
   pendingMocks: globalIntercept.pendingMocks,
+  activeMocks: globalIntercept.activeMocks,
   removeInterceptor: globalIntercept.removeInterceptor,
   disableNetConnect: globalIntercept.disableNetConnect,
   enableNetConnect: globalIntercept.enableNetConnect,
diff --git a/tests/test_intercept.js b/tests/test_intercept.js
index 16bf7ea..dc6f560 100644
--- a/tests/test_intercept.js
+++ b/tests/test_intercept.js
@@ -622,22 +622,6 @@ test("isDone", function(t) {
   req.end();
 });
 
-test("requireDone", function(t) {
-  var scope = nock('http://www.google.com')
-    .get('/', false, { requireDone: false })
-    .reply(200, "Hello World!");
-
-  t.ok(scope.isDone(), "done when a requireDone is set to false");
-
-  scope.get('/', false, { requireDone: true})
-       .reply(200, "Hello World!");
-
-  t.notOk(scope.isDone(), "not done when a requireDone is explicitly set to true");
-
-  nock.cleanAll()
-  t.end();
-});
-
 test("request headers exposed", function(t) {
 
   var scope = nock('http://www.headdy.com')
@@ -2297,6 +2281,129 @@ test('pending mocks works', function(t) {
   });
 });
 
+test('pending mocks doesn\'t include optional mocks', function(t) {
+  var scope = nock('http://example.com')
+    .get('/nonexistent')
+    .optionally()
+    .reply(200);
+
+  t.deepEqual(nock.pendingMocks(), []);
+  t.end();
+});
+
+test('optional mocks are still functional', function(t) {
+  var scope = nock('http://example.com')
+    .get('/abc')
+    .optionally()
+    .reply(200);
+
+  var req = http.get({host: 'example.com', path: '/abc'}, function(res) {
+    t.assert(res.statusCode === 200, "should still mock requests");
+    t.deepEqual(nock.pendingMocks(), []);
+    t.end();
+  });
+});
+
+test('isDone is true with optional mocks outstanding', function(t) {
+  var scope = nock('http://example.com')
+    .get('/abc')
+    .optionally()
+    .reply(200);
+
+  t.ok(scope.isDone());
+  t.end();
+});
+
+test('optional but persisted mocks persist, but never appear as pending', function(t) {
+  var scope = nock('http://example.com')
+    .get('/123')
+    .optionally()
+    .reply(200)
+    .persist();
+
+  t.deepEqual(nock.pendingMocks(), []);
+  var req = http.get({host: 'example.com', path: '/123'}, function(res) {
+    t.assert(res.statusCode === 200, "should mock first request");
+    t.deepEqual(nock.pendingMocks(), []);
+
+    var req = http.get({host: 'example.com', path: '/123'}, function(res) {
+      t.assert(res.statusCode === 200, "should mock second request");
+      t.deepEqual(nock.pendingMocks(), []);
+      t.end();
+    });
+  });
+});
+
+test('optional repeated mocks execute repeatedly, but never appear as pending', function(t) {
+  var scope = nock('http://example.com')
+    .get('/456')
+    .optionally()
+    .times(2)
+    .reply(200);
+
+  t.deepEqual(nock.pendingMocks(), []);
+  var req = http.get({host: 'example.com', path: '/456'}, function(res) {
+    t.assert(res.statusCode === 200, "should mock first request");
+    t.deepEqual(nock.pendingMocks(), []);
+
+    var req = http.get({host: 'example.com', path: '/456'}, function(res) {
+      t.assert(res.statusCode === 200, "should mock second request");
+      t.deepEqual(nock.pendingMocks(), []);
+      t.end();
+    });
+  });
+});
+
+test('activeMocks returns optional mocks only before they\'re completed', function(t) {
+  nock.cleanAll();
+  var scope = nock('http://example.com')
+    .get('/optional')
+    .optionally()
+    .reply(200);
+
+  t.deepEqual(nock.activeMocks(), ["GET http://example.com:80/optional"]);
+  var req = http.get({host: 'example.com', path: '/optional'}, function(res) {
+    t.deepEqual(nock.activeMocks(), []);
+    t.end();
+  });
+});
+
+test('activeMocks always returns persisted mocks', function(t) {
+  nock.cleanAll();
+  var scope = nock('http://example.com')
+    .get('/persisted')
+    .reply(200)
+    .persist();
+
+  t.deepEqual(nock.activeMocks(), ["GET http://example.com:80/persisted"]);
+  var req = http.get({host: 'example.com', path: '/persisted'}, function(res) {
+    t.deepEqual(nock.activeMocks(), ["GET http://example.com:80/persisted"]);
+    t.end();
+  });
+});
+
+test('activeMocks returns incomplete mocks', function(t) {
+  nock.cleanAll();
+  var scope = nock('http://example.com')
+    .get('/incomplete')
+    .reply(200);
+
+  t.deepEqual(nock.activeMocks(), ["GET http://example.com:80/incomplete"]);
+  t.end();
+});
+
+test('activeMocks doesn\'t return completed mocks', function(t) {
+  nock.cleanAll();
+  var scope = nock('http://example.com')
+    .get('/complete-me')
+    .reply(200);
+
+  var req = http.get({host: 'example.com', path: '/complete-me'}, function(res) {
+    t.deepEqual(nock.activeMocks(), []);
+    t.end();
+  });
+});
+
 test('username and password works', function(t) {
   var scope = nock('http://passwordyy.com')
     .get('/')
@@ -2444,6 +2551,28 @@ test('persists interceptors', function(t) {
   }).end();
 });
 
+test('Persisted interceptors are in pendingMocks initially', function(t) {
+  var scope = nock('http://example.com')
+    .get('/abc')
+    .reply(200, "Persisted reply")
+    .persist();
+
+  t.deepEqual(scope.pendingMocks(), ["GET http://example.com:80/abc"]);
+  t.end();
+});
+
+test('Persisted interceptors are not in pendingMocks after the first request', function(t) {
+  var scope = nock('http://example.com')
+    .get('/def')
+    .reply(200, "Persisted reply")
+    .persist();
+
+  http.get('http://example.com/def', function(res) {
+    t.deepEqual(scope.pendingMocks(), []);
+    t.end();
+  });
+});
+
 test("persist reply with file", function(t) {
   var scope = nock('http://www.filereplier.com')
     .persist()
@@ -4401,7 +4530,6 @@ test('remove interceptor for not found resource', function(t) {
 });
 
 test('isDone() must consider repeated responses', function(t) {
-
   var scope = nock('http://www.example.com')
     .get('/')
     .times(2)
@@ -4431,7 +4559,6 @@ test('isDone() must consider repeated responses', function(t) {
       t.end();
     });
   });
-
 });
 
 test('you must setup an interceptor for each request', function(t) {