Repository: chaijs/chai
Pull request #621 (2016-02-12 20:38:39)

[WIP] Change by delta (New Assertion)
As discussed on #339 this is the implementation of the `by` assertion.

I've tagged this as _WIP_ because I want to confirm with you guys (especially @keithamus, since he is the one involved issue #339) how this is going to get implemented on the `assert` interface.

I thought about two possible ways to do it, here they are:

1. Create six new methods, one for each of the change related methods of this interface.

  Doing this seems redundant and cumbersome, but this seems to follow the pattern of the rest of the interface.

  If we did this, we would have these new methods: `changesBy`, `doesNotChangeBy`, `increasesBy`, `doesNotIncreaseBy`, `decreasesBy` and `doesNotDecreaseBy`.

  **I also think this is the most adequate approach.**


2. Return the created assertion and allow the user to chain the `.by()` assertion.
  The interface's methods would be something like:

  ```js
  assert.changes = function (fn, obj, prop, msg) {
    if (arguments.length === 3 && typeof obj === 'function') {
      msg = prop;
      prop = null;
    }

    return new Assertion(fn, msg).to.change(obj, prop);
  }

  // This allows the user to write (for example):
  assert.changes(aFunction, anObject, 'aProperty').by(6);
  ```

  Personally, I don't like this because it adds a new kind of behavior to this interface. It seems to be cleaner but actually it causes a mess because it does not follow the other method's patterns

You can also feel free to suggest other improvements and point mistakes, I will always be open to others' opinions.

keithamus: I think Option 1 sounds like the best idea :smile:.
lucasfcosta: Thanks for your feedback! It was very valuable (as always)
lucasfcosta: Hi @keithamus, sorry for the delay on this, well, I've got a doubt here about the behavior of `.by` involving `.not`.

When someone uses, for example, `anyFunction.should.not.change(anObject, 'aKey').by(5)`, should it raise an exception only if the change had a delta value of `5`? Currently if `anyFunction` changed the value of `anObject` by 10, for example, it would throw an Exception because it has changed and this sentence had `should.not.change` in it, I think this may not be the desired behavior.

If so, how can we detect there is a `by` assertion after `change` before even running the assertion?
I've been thinking about a way to do this but I couldn't find one.
keithamus: Presumably `change()` would end up throwing an error before `by()` got a chance to execute?
lucasfcosta: Yes, exactly, currently I'm making `change`, `increase` and `decrease` call the `by` assertion, which will run the tests.

`change`, `increase` and `decrease` will only create flags that will be used by the `by` assertion.

I'll make sure to explain everythin when adding it to this PR.
lucasfcosta: Okay, so, after days analyzing this I've come to a dead end, so let me explain the problem we have here and the possible solution:

**Q: What is the problem?**
**A: ** When running assertions like: `expect(fn).to.not.change(object, 'key').by(2)` the `negate` flag gets applied both to `change` and `by`, so if `fn` changes 'key' (even if it's not by 2) it will fail before it even gets to the by.

**Q: Why does this happen?**
**A: ** We currently run the `change`, `increase` and `decrease` assertions as soon as they get called. When we type `expect(fn).to.not.change(object, 'key')` the `change` assertion runs immediately, therefore when we run `expect(fn).to.not.change(object, 'key').by(2)` we should be saying "fn should not change 'key' by 2, every other change (or no change at all) is accepted" but we're actually saying "fn should not change 'key'" and then saying "it should not change 'key' by 2".
Got it?

**Q: And how can we solve this?**
**A: ** The best idea I've had until now is to change the syntax to:

```
expect(fn).to.change(obj, 'key').but.not.by(2)
```

Using it this way the `not` getter would turn on the negate flag only for the `by` assertion.
Adding a 'but' word just for chain purposes also seems like a good idea.

Please, @keithamus and others, feel free to give feedback/ideas.
If you accept this syntax I'm ready to commit it on this branch (and don't worry, I'll rebase to keep the commit log as clean as possible). 
keithamus: @lucasfcosta thanks for the detailed description of the issue, however I'm not sure it is that much of a problem. If `expect(fn).to.not.change(object, 'key')` fails, then I would expect `expect(fn).to.not.change(object, 'key').by(2)` to also fail. Chai's chaining comes with an implicit agreement that the first failing assertion will fail the whole assertion.

You mention `expect(fn).to.change(obj, 'key').but.not.by(2)` as a workaround, which I think is a good workaround and the kind of syntax we should be aiming for. This should work out of the box right?
lucasfcosta: @keithamus, Yes, it works right out of the box, because `not` gets the `negate` flag turned on only for the by assertion.

I just need to add `but` as a "chaining" only word and change the assertions on the `assert` interface.

I will make sure to make this PR as clean as possible so you can take a look at the final version of these changes.

Thanks for your feedback! I'll probably commit the code for this until tomorrow.

Patch:
diff --git a/lib/chai/core/assertions.js b/lib/chai/core/assertions.js
index a5d3925..68f8c27 100644
--- a/lib/chai/core/assertions.js
+++ b/lib/chai/core/assertions.js
@@ -42,7 +42,7 @@ module.exports = function (chai, _) {
   [ 'to', 'be', 'been'
   , 'is', 'and', 'has', 'have'
   , 'with', 'that', 'which', 'at'
-  , 'of', 'same' ].forEach(function (chain) {
+  , 'of', 'same', 'but' ].forEach(function (chain) {
     Assertion.addProperty(chain, function () {
       return this;
     });
@@ -1634,31 +1634,38 @@ module.exports = function (chai, _) {
    * @name change
    * @alias changes
    * @alias Change
-   * @param {String} object
+   * @param {String} target
    * @param {String} property name _optional_
    * @param {String} message _optional_
    * @namespace BDD
    * @api public
    */
 
-  function assertChanges (object, prop, msg) {
+  function assertChanges (target, prop, msg) {
     if (msg) flag(this, 'message', msg);
     var fn = flag(this, 'object');
     new Assertion(fn).is.a('function');
 
     var initial;
     if (!prop) {
-      new Assertion(object).is.a('function');
-      initial = object();
+      new Assertion(target).is.a('function');
+      initial = target();
     } else {
-      new Assertion(object, msg).to.have.property(prop);
-      initial = object[prop];
+      new Assertion(target, msg).to.have.property(prop);
+      initial = target[prop];
     }
 
     fn();
 
-    var final = prop === undefined ? object() : object[prop];
-    var msgObj = prop === undefined ? initial : '.' + prop;
+    var final = prop === undefined || prop === null ? target() : target[prop];
+    var msgObj = prop === undefined || prop === null ? initial : '.' + prop;
+
+    // This gets flagged because of the .by(delta) assertion
+    flag(this, 'deltaMsgObj', msgObj);
+    flag(this, 'initialDeltaValue', initial);
+    flag(this, 'finalDeltaValue', final);
+    flag(this, 'deltaBehavior', 'change');
+    flag(this, 'realDelta', final !== initial);
 
     this.assert(
       initial !== final
@@ -1682,31 +1689,37 @@ module.exports = function (chai, _) {
    * @name increase
    * @alias increases
    * @alias Increase
-   * @param {String} object
+   * @param {String} target
    * @param {String} property name
    * @param {String} message _optional_
    * @namespace BDD
    * @api public
    */
 
-  function assertIncreases (object, prop, msg) {
+  function assertIncreases (target, prop, msg) {
     if (msg) flag(this, 'message', msg);
     var fn = flag(this, 'object');
     new Assertion(fn).is.a('function');
 
     var initial;
     if (!prop) {
-      new Assertion(object).is.a('function');
-      initial = object();
+      new Assertion(target).is.a('function');
+      initial = target();
     } else {
-      new Assertion(object, msg).to.have.property(prop);
-      initial = object[prop];
+      new Assertion(target, msg).to.have.property(prop);
+      initial = target[prop];
     }
 
     fn();
 
-    var final = prop === undefined ? object() : object[prop];
-    var msgObj = prop === undefined ? initial : '.' + prop;
+    var final = prop === undefined || prop === null ? target() : target[prop];
+    var msgObj = prop === undefined || prop === null ? initial : '.' + prop;
+
+    flag(this, 'deltaMsgObj', msgObj);
+    flag(this, 'initialDeltaValue', initial);
+    flag(this, 'finalDeltaValue', final);
+    flag(this, 'deltaBehavior', 'increase');
+    flag(this, 'realDelta', final - initial);
 
     this.assert(
       final - initial > 0
@@ -1730,31 +1743,37 @@ module.exports = function (chai, _) {
    * @name decrease
    * @alias decreases
    * @alias Decrease
-   * @param {String} object
+   * @param {String} target
    * @param {String} property name
    * @param {String} message _optional_
    * @namespace BDD
    * @api public
    */
 
-  function assertDecreases (object, prop, msg) {
+  function assertDecreases (target, prop, msg) {
     if (msg) flag(this, 'message', msg);
     var fn = flag(this, 'object');
     new Assertion(fn).is.a('function');
 
     var initial;
     if (!prop) {
-      new Assertion(object).is.a('function');
-      initial = object();
+      new Assertion(target).is.a('function');
+      initial = target();
     } else {
-      new Assertion(object, msg).to.have.property(prop);
-      initial = object[prop];
+      new Assertion(target, msg).to.have.property(prop);
+      initial = target[prop];
     }
 
     fn();
 
-    var final = prop === undefined ? object() : object[prop];
-    var msgObj = prop === undefined ? initial : '.' + prop;
+    var final = prop === undefined || prop === null ? target() : target[prop];
+    var msgObj = prop === undefined || prop === null ? initial : '.' + prop;
+
+    flag(this, 'deltaMsgObj', msgObj);
+    flag(this, 'initialDeltaValue', initial);
+    flag(this, 'finalDeltaValue', final);
+    flag(this, 'deltaBehavior', 'decrease');
+    flag(this, 'realDelta', initial - final);
 
     this.assert(
       final - initial < 0
@@ -1766,6 +1785,45 @@ module.exports = function (chai, _) {
   Assertion.addChainableMethod('decrease', assertDecreases);
   Assertion.addChainableMethod('decreases', assertDecreases);
 
+  /**
+   * ### .by
+   *
+   * Defines an amount for increase/decrease assertions.
+   *
+   *     var obj = { val: 10 };
+   *     var dec = function() { obj.val -= 5 };
+   *     var inc = function() {obj.val += 10};
+   *     expect(dec).to.decrease(obj, 'val').by(5);
+   *     expect(inc).to.increase(obj, 'val').by(10);
+   *
+   * @name by
+   * @namespace BDD
+   * @api public
+   */
+
+  function assertDelta(delta) {
+    var msgObj = flag(this, 'deltaMsgObj');
+    var initial = flag(this, 'initialDeltaValue');
+    var final = flag(this, 'finalDeltaValue');
+    var behavior = flag(this, 'deltaBehavior');
+    var realDelta = flag(this, 'realDelta');
+
+    var expression;
+    if (behavior === 'change') {  
+      expression = Math.abs(final - initial) === Math.abs(delta);
+    } else {
+      expression = realDelta === Math.abs(delta);
+    }
+
+    this.assert(
+      expression
+      , 'expected ' + msgObj + ' to ' + behavior + ' by ' + delta
+      , 'expected ' + msgObj + ' to not ' + behavior + ' by ' + delta
+    );
+  }
+
+  Assertion.addMethod('by', assertDelta);
+
   /**
    * ### .extensible
    *
diff --git a/lib/chai/interface/assert.js b/lib/chai/interface/assert.js
index 4152645..4681ee7 100644
--- a/lib/chai/interface/assert.js
+++ b/lib/chai/interface/assert.js
@@ -1343,7 +1343,7 @@ module.exports = function (chai, util) {
     new Assertion(inList, msg).to.be.oneOf(list);
   }
 
-   /**
+  /**
    * ### .changes(function, object, property, [message])
    *
    * Asserts that a function changes the value of a property
@@ -1370,10 +1370,42 @@ module.exports = function (chai, util) {
     new Assertion(fn, msg).to.change(obj, prop);
   }
 
+   /**
+   * ### .changesBy(function, object, property, delta, [message])
+   *
+   * Asserts that a function changes the value of a property by an amount (delta)
+   *
+   *     var obj = { val: 10 };
+   *     var fn = function() { obj.val += 2 };
+   *     assert.changesBy(fn, obj, 'val', 2);
+   *
+   * @name changesBy
+   * @param {Function} modifier function
+   * @param {Object} object or getter function
+   * @param {String} property name _optional_
+   * @param {Number} change amount (delta)
+   * @param {String} message _optional_
+   * @namespace Assert
+   * @api public
+   */
+
+  assert.changesBy = function (fn, obj, prop, delta, msg) {
+    if (arguments.length === 4 && typeof obj === 'function') {
+      var tmpMsg = delta;
+      delta = prop;
+      msg = tmpMsg;
+    } else if (arguments.length === 3) {
+      delta = prop;
+      prop = null;
+    }
+
+    new Assertion(fn, msg).to.change(obj, prop).by(delta);
+  }
+
    /**
    * ### .doesNotChange(function, object, property, [message])
    *
-   * Asserts that a function does not changes the value of a property
+   * Asserts that a function does not change the value of a property
    *
    *     var obj = { val: 10 };
    *     var fn = function() { console.log('foo'); };
@@ -1394,10 +1426,42 @@ module.exports = function (chai, util) {
       prop = null;
     }
 
-    new Assertion(fn, msg).to.not.change(obj, prop);
+    return new Assertion(fn, msg).to.not.change(obj, prop);
   }
 
-   /**
+  /**
+   * ### .changesButNotBy(function, object, property, delta, [message])
+   *
+   * Asserts that a function does not change the value of a property or of a function's return value by an amount (delta)
+   *
+   *     var obj = { val: 10 };
+   *     var fn = function() { obj.val += 10 };
+   *     assert.changesButNotBy(fn, obj, 'val', 5);
+   *
+   * @name changesButNotBy
+   * @param {Function} modifier function
+   * @param {Object} object or getter function
+   * @param {String} property name _optional_
+   * @param {Number} change amount (delta)
+   * @param {String} message _optional_
+   * @namespace Assert
+   * @api public
+   */
+
+  assert.changesButNotBy = function (fn, obj, prop, delta, msg) {
+    if (arguments.length === 4 && typeof obj === 'function') {
+      var tmpMsg = delta;
+      delta = prop;
+      msg = tmpMsg;
+    } else if (arguments.length === 3) {
+      delta = prop;
+      prop = null;
+    }
+
+    new Assertion(fn, msg).to.change(obj, prop).but.not.by(delta);
+  }
+
+  /**
    * ### .increases(function, object, property, [message])
    *
    * Asserts that a function increases an object property
@@ -1421,10 +1485,42 @@ module.exports = function (chai, util) {
       prop = null;
     }
 
-    new Assertion(fn, msg).to.increase(obj, prop);
+    return new Assertion(fn, msg).to.increase(obj, prop);
   }
 
-   /**
+  /**
+   * ### .increasesBy(function, object, property, delta, [message])
+   *
+   * Asserts that a function increases an object property or a function's return value by an amount (delta)
+   *
+   *     var obj = { val: 10 };
+   *     var fn = function() { obj.val += 10 };
+   *     assert.increasesBy(fn, obj, 'val', 10);
+   *
+   * @name increasesBy
+   * @param {Function} modifier function
+   * @param {Object} object or getter function
+   * @param {String} property name _optional_
+   * @param {Number} change amount (delta)
+   * @param {String} message _optional_
+   * @namespace Assert
+   * @api public
+   */
+
+  assert.increasesBy = function (fn, obj, prop, delta, msg) {
+    if (arguments.length === 4 && typeof obj === 'function') {
+      var tmpMsg = delta;
+      delta = prop;
+      msg = tmpMsg;
+    } else if (arguments.length === 3) {
+      delta = prop;
+      prop = null;
+    }
+
+    new Assertion(fn, msg).to.increase(obj, prop).by(delta);
+  }
+
+  /**
    * ### .doesNotIncrease(function, object, property, [message])
    *
    * Asserts that a function does not increase object property
@@ -1448,10 +1544,42 @@ module.exports = function (chai, util) {
       prop = null;
     }
 
-    new Assertion(fn, msg).to.not.increase(obj, prop);
+    return new Assertion(fn, msg).to.not.increase(obj, prop);
   }
 
-   /**
+  /**
+   * ### .increasesButNotBy(function, object, property, [message])
+   *
+   * Asserts that a function does not increase object property or function's return value by an amount (delta)
+   *
+   *     var obj = { val: 10 };
+   *     var fn = function() { obj.val = 15 };
+   *     assert.increasesButNotBy(fn, obj, 'val', 10);
+   *
+   * @name increasesButNotBy
+   * @param {Function} modifier function
+   * @param {Object} object or getter function
+   * @param {String} property name _optional_
+   * @param {Number} change amount (delta)
+   * @param {String} message _optional_
+   * @namespace Assert
+   * @api public
+   */
+
+  assert.increasesButNotBy = function (fn, obj, prop, delta, msg) {
+    if (arguments.length === 4 && typeof obj === 'function') {
+      var tmpMsg = delta;
+      delta = prop;
+      msg = tmpMsg;
+    } else if (arguments.length === 3) {
+      delta = prop;
+      prop = null;
+    }
+
+    new Assertion(fn, msg).to.increase(obj, prop).but.not.by(delta);
+  }
+
+  /**
    * ### .decreases(function, object, property, [message])
    *
    * Asserts that a function decreases an object property
@@ -1475,10 +1603,42 @@ module.exports = function (chai, util) {
       prop = null;
     }
 
-    new Assertion(fn, msg).to.decrease(obj, prop);
+    return new Assertion(fn, msg).to.decrease(obj, prop);
   }
 
-   /**
+  /**
+   * ### .decreasesBy(function, object, property, delta, [message])
+   *
+   * Asserts that a function decreases an object property or a function's return value by an amount (delta)
+   *
+   *     var obj = { val: 10 };
+   *     var fn = function() { obj.val -= 5 };
+   *     assert.decreasesBy(fn, obj, 'val', 5);
+   *
+   * @name decreasesBy
+   * @param {Function} modifier function
+   * @param {Object} object or getter function
+   * @param {String} property name _optional_
+   * @param {Number} change amount (delta)
+   * @param {String} message _optional_
+   * @namespace Assert
+   * @api public
+   */
+
+  assert.decreasesBy = function (fn, obj, prop, delta, msg) {
+    if (arguments.length === 4 && typeof obj === 'function') {
+      var tmpMsg = delta;
+      delta = prop;
+      msg = tmpMsg;
+    } else if (arguments.length === 3) {
+      delta = prop;
+      prop = null;
+    }
+
+    new Assertion(fn, msg).to.decrease(obj, prop).by(delta);
+  }
+
+  /**
    * ### .doesNotDecrease(function, object, property, [message])
    *
    * Asserts that a function does not decreases an object property
@@ -1502,7 +1662,71 @@ module.exports = function (chai, util) {
       prop = null;
     }
 
-    new Assertion(fn, msg).to.not.decrease(obj, prop);
+    return new Assertion(fn, msg).to.not.decrease(obj, prop);
+  }
+
+  /**
+   * ### .doesNotDecreaseBy(function, object, property, delta, [message])
+   *
+   * Asserts that a function does not decreases an object property or a function's return value by an amount (delta)
+   *
+   *     var obj = { val: 10 };
+   *     var fn = function() { obj.val = 5 };
+   *     assert.doesNotDecreaseBy(fn, obj, 'val', 1);
+   *
+   * @name doesNotDecrease
+   * @param {Function} modifier function
+   * @param {Object} object or getter function
+   * @param {String} property name _optional_
+   * @param {Number} change amount (delta)
+   * @param {String} message _optional_
+   * @namespace Assert
+   * @api public
+   */
+
+  assert.doesNotDecreaseBy = function (fn, obj, prop, delta, msg) {
+    if (arguments.length === 4 && typeof obj === 'function') {
+      var tmpMsg = delta;
+      delta = prop;
+      msg = tmpMsg;
+    } else if (arguments.length === 3) {
+      delta = prop;
+      prop = null;
+    }
+
+    return new Assertion(fn, msg).to.not.decrease(obj, prop).by(delta);
+  }
+
+  /**
+   * ### .decreasesButNotBy(function, object, property, delta, [message])
+   *
+   * Asserts that a function does not decreases an object property or a function's return value by an amount (delta)
+   *
+   *     var obj = { val: 10 };
+   *     var fn = function() { obj.val = 5 };
+   *     assert.decreasesButNotBy(fn, obj, 'val', 1);
+   *
+   * @name decreasesButNotBy
+   * @param {Function} modifier function
+   * @param {Object} object or getter function
+   * @param {String} property name _optional_
+   * @param {Number} change amount (delta)
+   * @param {String} message _optional_
+   * @namespace Assert
+   * @api public
+   */
+
+  assert.decreasesButNotBy = function (fn, obj, prop, delta, msg) {
+    if (arguments.length === 4 && typeof obj === 'function') {
+      var tmpMsg = delta;
+      delta = prop;
+      msg = tmpMsg;
+    } else if (arguments.length === 3) {
+      delta = prop;
+      prop = null;
+    }
+
+    new Assertion(fn, msg).to.decrease(obj, prop).but.not.by(delta);
   }
 
   /*!


Test patch:
diff --git a/test/assert.js b/test/assert.js
index 29b98fb..81ff78f 100644
--- a/test/assert.js
+++ b/test/assert.js
@@ -875,23 +875,31 @@ describe('assert', function () {
     var obj = { value: 10, str: 'foo' },
         heroes = ['spiderman', 'superman'],
         fn     = function() { obj.value += 5 },
+        fnDec  = function() { obj.value -= 20 },
         bangFn = function() { obj.str += '!' },
         smFn   = function() { 'foo' + 'bar' },
         batFn  = function() { heroes.push('batman') },
         lenFn  = function() { return heroes.length };
 
     assert.changes(fn, obj, 'value');
+    assert.changesBy(fn, obj, 'value', 5);
+    assert.changesBy(fn, obj, 'value', -5);
+    assert.changesBy(fnDec, obj, 'value', 20);
+
     assert.doesNotChange(smFn, obj, 'value');
+    assert.changesButNotBy(fnDec, obj, 'value', 1);
+
     assert.changes(bangFn, obj, 'str');
-    assert.changes(batFn, lenFn);
-    assert.doesNotChange(smFn, lenFn);
+
+    assert.changesBy(batFn, lenFn, 1);
+    assert.changesButNotBy(batFn, lenFn, 2);
   });
 
   it('increase, decrease', function() {
     var obj = { value: 10 },
         arr = ['one', 'two'],
         pFn   = function() { arr.push('three') },
-        popFn  = function() { arr.pop() },
+        popFn = function() { arr.pop() },
         lenFn = function() { return arr.length },
         incFn = function() { obj.value += 2 },
         decFn = function() { obj.value -= 3 },
@@ -899,15 +907,23 @@ describe('assert', function () {
 
     assert.decreases(decFn, obj, 'value');
     assert.doesNotDecrease(smFn, obj, 'value');
+    assert.decreasesBy(decFn, obj, 'value', 3);
+    assert.decreasesButNotBy(decFn, obj, 'value', 10);
 
     assert.increases(incFn, obj, 'value');
     assert.doesNotIncrease(smFn, obj, 'value');
+    assert.increasesBy(incFn, obj, 'value', 2);
+    assert.increasesButNotBy(incFn, obj, 'value', 1);
 
     assert.decreases(popFn, lenFn);
     assert.doesNotDecrease(pFn, lenFn);
+    assert.decreasesBy(popFn, lenFn, 1);
+    assert.decreasesButNotBy(popFn, lenFn, 2);
 
     assert.increases(pFn, lenFn);
     assert.doesNotIncrease(popFn, lenFn);
+    assert.increasesBy(pFn, lenFn, 1);
+    assert.increasesButNotBy(pFn, lenFn, 2);
   });
 
   it('isExtensible / extensible', function() {
diff --git a/test/expect.js b/test/expect.js
index df4d142..3daedbb 100644
--- a/test/expect.js
+++ b/test/expect.js
@@ -1123,17 +1123,26 @@ describe('expect', function () {
     var obj = { value: 10, str: 'foo' },
         heroes = ['spiderman', 'superman'],
         fn     = function() { obj.value += 5 },
+        decFn  = function() { obj.value -= 20 },
         sameFn = function() { 'foo' + 'bar' },
         bangFn = function() { obj.str += '!' },
         batFn  = function() { heroes.push('batman') },
         lenFn  = function() { return heroes.length };
 
     expect(fn).to.change(obj, 'value');
+    expect(fn).to.change(obj, 'value').by(5);
+    expect(fn).to.change(obj, 'value').by(-5);
+
+    expect(decFn).to.change(obj, 'value').by(20);
+    expect(decFn).to.change(obj, 'value').but.not.by(21);
+
     expect(sameFn).to.not.change(obj, 'value');
+
     expect(sameFn).to.not.change(obj, 'str');
     expect(bangFn).to.change(obj, 'str');
-    expect(batFn).to.change(lenFn);
-    expect(sameFn).to.not.change(lenFn);
+
+    expect(batFn).to.change(lenFn).by(1);
+    expect(batFn).to.change(lenFn).but.not.by(2);
   });
 
   it('increase, decrease', function() {
@@ -1150,16 +1159,24 @@ describe('expect', function () {
     expect(smFn).to.not.increase(obj, 'value');
     expect(decFn).to.not.increase(obj, 'value');
     expect(incFn).to.increase(obj, 'value');
+    expect(incFn).to.increase(obj, 'value').by(2);
+    expect(incFn).to.increase(obj, 'value').but.not.by(1);
 
     expect(smFn).to.not.decrease(obj, 'value');
     expect(incFn).to.not.decrease(obj, 'value');
     expect(decFn).to.decrease(obj, 'value');
+    expect(decFn).to.decrease(obj, 'value').by(3);
+    expect(decFn).to.decrease(obj, 'value').but.not.by(2);
 
     expect(popFn).to.not.increase(lenFn);
     expect(nFn).to.not.increase(lenFn);
     expect(pFn).to.increase(lenFn);
+    expect(pFn).to.increase(lenFn).by(1);
+    expect(pFn).to.increase(lenFn).but.not.by(2);
 
     expect(popFn).to.decrease(lenFn);
+    expect(popFn).to.decrease(lenFn).by(1);
+    expect(popFn).to.decrease(lenFn).but.not.by(2);
     expect(nFn).to.not.decrease(lenFn);
     expect(pFn).to.not.decrease(lenFn);
   });
diff --git a/test/should.js b/test/should.js
index 9e35e9a..0d272ba 100644
--- a/test/should.js
+++ b/test/should.js
@@ -967,17 +967,29 @@ describe('should', function() {
         heroes = ['spiderman', 'superman'],
         fn     = function() { obj.value += 5 },
         sameFn = function() { obj.value += 0 },
-        decFn  = function() { obj.value -= 3 },
+        decFn  = function() { obj.value -= 20 },
         bangFn = function() { obj.str += '!' },
         batFn  = function() { heroes.push('batman') },
         lenFn  = function() { return heroes.length },
         noFn   = function() { return null };
 
     fn.should.change(obj, 'value');
+    fn.should.change(obj, 'value').by(5);
+    fn.should.change(obj, 'value').by(-5);
+    fn.should.change(obj, 'value').but.not.by(10);
+
+    decFn.should.change(obj, 'value');
+    decFn.should.change(obj, 'value').by(20);
+    decFn.should.change(obj, 'value').but.not.by(19);
+
     sameFn.should.not.change(obj, 'value');
+
     sameFn.should.not.change(obj, 'str');
     bangFn.should.change(obj, 'str');
+
     batFn.should.change(lenFn);
+    batFn.should.change(lenFn).by(1);
+    batFn.should.change(lenFn).but.not.by(2);
     noFn.should.not.change(lenFn);
   });
 
@@ -995,18 +1007,29 @@ describe('should', function() {
     smFn.should.not.increase(obj, 'value');
     decFn.should.not.increase(obj, 'value');
     incFn.should.increase(obj, 'value');
+    incFn.should.increase(obj, 'value').by(2);
+    incFn.should.increase(obj, 'value').but.not.by(1);
 
     smFn.should.not.decrease(obj, 'value');
     incFn.should.not.decrease(obj, 'value');
     decFn.should.decrease(obj, 'value');
+    decFn.should.decrease(obj, 'value').by(3);
+    decFn.should.decrease(obj, 'value').but.not.by(2);
 
     nFn.should.not.increase(lenFn);
+    nFn.should.not.decrease(lenFn);
+
+    popFn.should.decrease(lenFn);
     popFn.should.not.increase(lenFn);
-    pFn.should.increase(lenFn);
 
-    nFn.should.not.decrease(lenFn);
+    pFn.should.increase(lenFn);
     pFn.should.not.decrease(lenFn);
-    popFn.should.decrease(lenFn);
+
+    pFn.should.increase(lenFn).by(1);
+    pFn.should.increase(lenFn).but.not.by(2);
+
+    popFn.should.decrease(lenFn).by(1);
+    popFn.should.decrease(lenFn).but.not.by(2);
   });
 
   it('extensible', function() {