Added `change`, `increase` and `decrease` assertions with `by` chain (#330) #333

Merged
merged 8 commits into from Jan 2, 2015

Projects

None yet

4 participants

@cmpolis
cmpolis commented Dec 28, 2014

Added change, increase and decrease assertions with by chain:

buildUser = function() { ... };
removeUsers = function() { ... };
buildUser.should.change(users, 'length');
buildUser.should.change(users, 'length').by(1);
removeUsers.should.decrease(users, 'length');
...

I've used change a bunch in other testing setups and it allows for more readable and terse tests (instead of before=val ... val.should.eq(before+1)).

@keithamus keithamus and 1 other commented on an outdated diff Dec 28, 2014
lib/chai/core/assertions.js
@@ -1403,4 +1403,143 @@ module.exports = function (chai, _) {
, subset
);
});
+
+ /**
+ * ### .change(function)
+ *
+ * Asserts that a function changes an object property
+ *
+ * var obj = { val: 10 };
+ * var fn = function() { obj.val = 10 };
+ * var noChangeFn = function() { return 'foo' + 'bar'; }
+ * expect(fn).to.change(obj, 'val');
@keithamus
keithamus Dec 28, 2014 Member

Doesn't look like fn actually changes the value of val, right?

@cmpolis
cmpolis Dec 29, 2014

Ahhh, good catch.

@keithamus keithamus and 1 other commented on an outdated diff Dec 28, 2014
lib/chai/core/assertions.js
+ * @alias changes
+ * @alias Change
+ * @param {String} object
+ * @param {String} property name
+ * @param {String} message _optional_
+ * @api public
+ */
+
+ function assertChanges (object, prop, msg) {
+ if (msg) flag(this, 'message', msg);
+ var fn = flag(this, 'object');
+ new Assertion(object, msg).to.have.property(prop);
+
+ var initial = object[prop];
+ fn();
+ var delta = object[prop] - initial;
@keithamus
keithamus Dec 28, 2014 Member

It might be nice if .change just tests for inequality, rather than be restricted to number types.

So I can do person.capitalizeName().should.change('name')

@cmpolis
cmpolis Dec 29, 2014

Agreed - will put in tests + fix for this.

@keithamus keithamus and 1 other commented on an outdated diff Dec 28, 2014
lib/chai/core/assertions.js
+ var initial = object[prop];
+ fn();
+ var delta = object[prop] - initial;
+ flag(this, 'delta', delta);
+ flag(this, 'property', prop);
+
+ this.assert(
+ delta != 0
+ , 'expected .' + prop + ' to change'
+ , 'expected .' + prop + ' to not change'
+ );
+ }
+
+ Assertion.addChainableMethod('change', assertChanges);
+ Assertion.addChainableMethod('changes', assertChanges);
+ Assertion.addChainableMethod('Change', assertChanges);
@keithamus
keithamus Dec 28, 2014 Member

I can't recall any other chainable methods that have capitals. I think this could probably be left out.

@cmpolis
cmpolis Dec 29, 2014

Thanks! I wasn't too sure about this - I saw the capitals in some other places in this file and was trying to follow those patterns: Assertion.addProperty('Arguments', checkArguments); Assertion.addMethod('Throw', assertThrows);

@keithamus
keithamus Dec 29, 2014 Member

Ah, I forgot about those. They are just because arguments and throw are reserved property names in ES3, while Arguments and Throw aren't. However change is not a reserved word, and so should be fine to omit.

@keithamus keithamus commented on an outdated diff Dec 28, 2014
lib/chai/core/assertions.js
+ var initial = object[prop];
+ fn();
+ var delta = object[prop] - initial;
+ flag(this, 'delta', delta);
+ flag(this, 'property', prop);
+
+ this.assert(
+ delta > 0
+ , 'expected .' + prop + ' to increase'
+ , 'expected .' + prop + ' to not increase'
+ );
+ }
+
+ Assertion.addChainableMethod('increase', assertIncreases);
+ Assertion.addChainableMethod('increases', assertIncreases);
+ Assertion.addChainableMethod('Increase', assertIncreases);
@keithamus
keithamus Dec 28, 2014 Member

Same as above, aliases with capitals aren't really used elsewhere AFAIK

@keithamus keithamus commented on an outdated diff Dec 28, 2014
lib/chai/core/assertions.js
+ var initial = object[prop];
+ fn();
+ var delta = object[prop] - initial;
+ flag(this, 'delta', -delta);
+ flag(this, 'property', prop);
+
+ this.assert(
+ delta < 0
+ , 'expected .' + prop + ' to decrease'
+ , 'expected .' + prop + ' to not decrease'
+ );
+ }
+
+ Assertion.addChainableMethod('decrease', assertDecreases);
+ Assertion.addChainableMethod('decreases', assertDecreases);
+ Assertion.addChainableMethod('Decrease', assertDecreases);
@keithamus
keithamus Dec 28, 2014 Member

And again

@keithamus
Member

Great PR @cmpolis! Thanks for adding docs and tests!

I've made a couple of comments on the code above, that I'd like to see addressed. I also have some general notes...

  • I can see the value in .change() and .increase() and .decrease(), and I'm mostly happy with the functionality (apart from the above comments).
  • I'm not entirely sold on the .by() assertion; it is a very generic keyword for such a specific set of functionality. It seems like it could be folded into the other assertions, for example:
foo.should.change('bar', 5)
foo.should.increase('bar', 5)
foo.should.decrease('bar', 5)
@cmpolis
cmpolis commented Dec 29, 2014

Sweet, thanks! I updated the pr w/ the notes above. As far as by goes - I'm fine either way, my preference is to have more natural language oriented tests and I think by aids in that. Also, it takes out some ambiguity on parameter order.

@keithamus
Member

@cmpolis thanks for addressing all of my above points. PR is looking really good to me.

As for the .by method, I can definitely see your points; .by does read better, but I worry about it for the reasons mentioned. To get this PR moving though, if anyone else has any opinions on this (especially @logicalparadox or @vesln, or the original reporter - @oveddan) then I'd like to read through them!

@logicalparadox
Member

I am going to go with an additional argument for the change value.

expect(fn).to.change(foo, 'bar', 5);
  • It is consistent with existing principles: expect(foo).to.have.property('bar', 'baz')
  • .by is uncomfortably generic (but could be implemented easily as a plugin).
@oveddan
oveddan commented Dec 29, 2014

I think by is better as it's more readable.

Also, using optional arguments is less flexible to enhancements and extensibility to the matcher down the line, as that argument can only be one thing.

by doesn't have to be generic; when change is used, it can change the subject of the assertion to be the changed value, just as property does:

expect(obj).to.have.property('foo')
  .that.is.a('string');

And then by could only be allowed to be chained to change matchers.

@oveddan
oveddan commented Dec 29, 2014

Another option is instead of overloading change, could have a changeBy matcher as well that would require the changed amount for an argument.

@keithamus
Member

You may be on to something - about .change modifying the assertion subject. Here are some thoughts about it:

  1. If .change modified the subject to be the property questioned (flag('obj', object[prop])), you could have more flexibility in your assertions - but would lose the ability to assert on delta without extra matchers. e.g.:

    bar = { val: 5 };
    function foo() { bar.val += 5; }
    foo.should.change(bar, 'val').and.be.a('number').and.be.above(5)
    //                           ^ here the new prop is `bar.val` (10)
  2. If .change modified the subject to be the delta (flag('obj', delta)) you could still see how much a value was changed by - but the resulting code would be a little more magical

    bar = { val: 5 };
    function foo() { bar.val += 5; }
    foo.should.change(bar, 'val').and.be.a('number').and.eql(5)
    //                           ^ here the new prop is `delta` (5)

My complete personal opinion: I don't like either of these. But prefer both to adding .by.

@cmpolis
cmpolis commented Dec 30, 2014
foo.should.change(bar, 'val').and.be.a('number').and.be.above(5);
foo.should.change(bar, 'val').and.be.a('number').and.eql(5);

This syntax would be ambiguous to me if I hadn't seen the internals: is it the change that should == 5 or bar.val that should == 5

Also, change becomes kind of useless in the first example since you know(or are making an assumption) about the initial state:

bar = { val: 5 };
function foo() { bar.val += 5; }
foo.should.change(bar, 'val').and.be.a('number').and.be.above(5)

might as well be:

bar = { val: 5 };
function foo() { bar.val += 5; }
foo();
bar.val.should.be.a('number').and.be.above(5)
@keithamus
Member

I agree with all of your points @cmpolis. Perhaps for now we agree to disagree about the .by functionality, and remove it from the PR, and bring it up in a new issue. I think everyone is on board with .change, .increase and .decrease, so we could work on getting just that set of functionality merged with this PR, and then go into further discussions on extending this.

@cmpolis
cmpolis commented Dec 30, 2014

Okay, sounds good! I'll stash .by and update the pr.

If everyone is cool with expect(fn).to.change(foo, 'bar', 5);, I can make that rewrite and put it in this pr.

@cmpolis cmpolis added a commit to cmpolis/chai that referenced this pull request Dec 30, 2014
@cmpolis cmpolis cleaned out `.by` for #333 b1fceb8
@cmpolis cmpolis added a commit to cmpolis/chai that referenced this pull request Dec 30, 2014
@cmpolis cmpolis cleaned out `.by` for #333 07dfb46
@cmpolis
cmpolis commented Dec 30, 2014

messed up on b1fceb8, should be an easy fix/merge, but lmk if I should rebase my branch

@keithamus keithamus commented on an outdated diff Dec 31, 2014
lib/chai/core/assertions.js
+ , 'expected .' + prop + ' to not change'
+ );
+ }
+
+ Assertion.addChainableMethod('change', assertChanges);
+ Assertion.addChainableMethod('changes', assertChanges);
+
+ /**
+ * ### .increase(function)
+ *
+ * Asserts that a function increases an object property
+ *
+ * var obj = { val: 10 };
+ * var fn = function() { obj.val = 15 };
+ * expect(fn).to.increase(obj, 'val');
+ * expect(fn).to.increase(obj, 'val').by(5);
@keithamus
keithamus Dec 31, 2014 Member

.by still exists in the documentation

@keithamus keithamus commented on an outdated diff Dec 31, 2014
lib/chai/core/assertions.js
+ , 'expected .' + prop + ' to not increase'
+ );
+ }
+
+ Assertion.addChainableMethod('increase', assertIncreases);
+ Assertion.addChainableMethod('increases', assertIncreases);
+
+ /**
+ * ### .decrease(function)
+ *
+ * Asserts that a function decreases an object property
+ *
+ * var obj = { val: 10 };
+ * var fn = function() { obj.val = 5 };
+ * expect(fn).to.decrease(obj, 'val');
+ * expect(fn).to.decrease(obj, 'val').by(5);
@keithamus
keithamus Dec 31, 2014 Member

and again (.by is documented but doesnt exist)

@keithamus keithamus commented on an outdated diff Dec 31, 2014
lib/chai/interface/assert.js
+ * var obj = { val: 10 };
+ * var fn = function() { obj.val = 13 };
+ * assert.increasesBy(fn, obj, 'val', 3);
+ *
+ * @name increasesBy
+ * @param {Function} modifier function
+ * @param {Object} object
+ * @param {String} property name
+ * @param {Number} amount to increase by
+ * @param {String} message _optional_
+ * @api public
+ */
+
+ assert.increasesBy = function (fn, obj, prop, amount) {
+ new Assertion(fn).to.increase(obj, prop).by(amount);
+ }
@keithamus
keithamus Dec 31, 2014 Member

Should these be removed, because the .by method doesn't exist?

@keithamus keithamus commented on an outdated diff Dec 31, 2014
lib/chai/interface/assert.js
+ * 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
+ * @param {String} property name
+ * @param {Number} amount to decrease by
+ * @param {String} message _optional_
+ * @api public
+ */
+
+ assert.decreasesBy = function (fn, obj, prop, amount) {
+ new Assertion(fn).to.decrease(obj, prop).by(amount);
+ }
@keithamus
keithamus Dec 31, 2014 Member

Same as above (probably needs removing as .by doesnt exist any more)

@keithamus
Member

Hey @cmpolis - I've made a couple of notes about lingering .by docs/methods which should be removed.

Also, I just noticed that in your commits you've removed chai.js. If you could rebase your commits to leave that file alone, that'd be swell ๐Ÿ˜„

As soon as that's done, I'll do another quick once over but it should be good to merge! At which point I'll also draw up a new issue discussing adding .by or similar.

@cmpolis
cmpolis commented Jan 2, 2015

Sorry... accidentally committed before coffee ๐Ÿ˜„ Rebased and put in those changes you noted, thanks!

@keithamus
Member

LGTM ๐Ÿ˜„

@keithamus keithamus merged commit 5fc486b into chaijs:master Jan 2, 2015

1 check passed

continuous-integration/travis-ci The Travis CI build passed
Details
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment