简体   繁体   中英

Testing floating point logic using chai-almost and sinon `calledWithMatch`

I have a test case which is failing because a value being tested is off by Number.EPSILON . I understand why this is happening and believe I need to alter my test case so that it tolerates this discrepancy. I believe using chai-almost to assist in this makes sense, but I'm struggling to figure out how to integrate chai-almost with sinon-chai and am looking for ideas.

Specifically, I am using the calledWithMatch method provided by sinon-chai . The calledWithMatch method performs a deep equality check between two objects and does not consider reference equality. I would like to relax this method to tolerate Number.EPSILON differences.

The code snippet below highlights the problem with a failing test case. The test case fails because persist is called with a bounding box which fails our expectations due to top being off by Number.EPSILON . In this scenario, the test case should pass as there is nothing incorrect with the data.

 mocha.setup('bdd'); const updater = { updateBoundingBox(boundingBox) { const newBoundingBox = { ...boundingBox }; newBoundingBox.top -= .2; newBoundingBox.top += .2; this.persist(newBoundingBox); }, persist(boundingBox) { console.log('persisting bounding box', boundingBox); } }; describe('example', () => { it('should pass', () => { const persistSpy = sinon.spy(updater, 'persist'); const originalBoundingBox = { top: 0.01, left: 0.01, bottom: 0.01, right: 0.01, }; updater.updateBoundingBox(originalBoundingBox); chai.expect(persistSpy).calledWithMatch(originalBoundingBox); }); }); mocha.run(); 
 <script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/6.1.4/mocha.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/chai/4.2.0/chai.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/sinon.js/7.3.2/sinon.min.js"></script> <script> "use strict"; /* eslint-disable no-invalid-this */ (function (sinonChai) { // Module systems magic dance. /* istanbul ignore else */ if (typeof require === "function" && typeof exports === "object" && typeof module === "object") { // NodeJS module.exports = sinonChai; } else if (typeof define === "function" && define.amd) { // AMD define(function () { return sinonChai; }); } else { // Other environment (usually <script> tag): plug in to global chai instance directly. /* global chai: false */ chai.use(sinonChai); } }(function (chai, utils) { var slice = Array.prototype.slice; function isSpy(putativeSpy) { return typeof putativeSpy === "function" && typeof putativeSpy.getCall === "function" && typeof putativeSpy.calledWithExactly === "function"; } function timesInWords(count) { switch (count) { case 1: { return "once"; } case 2: { return "twice"; } case 3: { return "thrice"; } default: { return (count || 0) + " times"; } } } function isCall(putativeCall) { return putativeCall && isSpy(putativeCall.proxy); } function assertCanWorkWith(assertion) { if (!isSpy(assertion._obj) && !isCall(assertion._obj)) { throw new TypeError(utils.inspect(assertion._obj) + " is not a spy or a call to a spy!"); } } function getMessages(spy, action, nonNegatedSuffix, always, args) { var verbPhrase = always ? "always have " : "have "; nonNegatedSuffix = nonNegatedSuffix || ""; if (isSpy(spy.proxy)) { spy = spy.proxy; } function printfArray(array) { return spy.printf.apply(spy, array); } return { affirmative: function () { return printfArray(["expected %n to " + verbPhrase + action + nonNegatedSuffix].concat(args)); }, negative: function () { return printfArray(["expected %n to not " + verbPhrase + action].concat(args)); } }; } function sinonProperty(name, action, nonNegatedSuffix) { utils.addProperty(chai.Assertion.prototype, name, function () { assertCanWorkWith(this); var messages = getMessages(this._obj, action, nonNegatedSuffix, false); this.assert(this._obj[name], messages.affirmative, messages.negative); }); } function sinonPropertyAsBooleanMethod(name, action, nonNegatedSuffix) { utils.addMethod(chai.Assertion.prototype, name, function (arg) { assertCanWorkWith(this); var messages = getMessages(this._obj, action, nonNegatedSuffix, false, [timesInWords(arg)]); this.assert(this._obj[name] === arg, messages.affirmative, messages.negative); }); } function createSinonMethodHandler(sinonName, action, nonNegatedSuffix) { return function () { assertCanWorkWith(this); var alwaysSinonMethod = "always" + sinonName[0].toUpperCase() + sinonName.substring(1); var shouldBeAlways = utils.flag(this, "always") && typeof this._obj[alwaysSinonMethod] === "function"; var sinonMethodName = shouldBeAlways ? alwaysSinonMethod : sinonName; var messages = getMessages(this._obj, action, nonNegatedSuffix, shouldBeAlways, slice.call(arguments)); this.assert( this._obj[sinonMethodName].apply(this._obj, arguments), messages.affirmative, messages.negative ); }; } function sinonMethodAsProperty(name, action, nonNegatedSuffix) { var handler = createSinonMethodHandler(name, action, nonNegatedSuffix); utils.addProperty(chai.Assertion.prototype, name, handler); } function exceptionalSinonMethod(chaiName, sinonName, action, nonNegatedSuffix) { var handler = createSinonMethodHandler(sinonName, action, nonNegatedSuffix); utils.addMethod(chai.Assertion.prototype, chaiName, handler); } function sinonMethod(name, action, nonNegatedSuffix) { exceptionalSinonMethod(name, name, action, nonNegatedSuffix); } utils.addProperty(chai.Assertion.prototype, "always", function () { utils.flag(this, "always", true); }); sinonProperty("called", "been called", " at least once, but it was never called"); sinonPropertyAsBooleanMethod("callCount", "been called exactly %1", ", but it was called %c%C"); sinonProperty("calledOnce", "been called exactly once", ", but it was called %c%C"); sinonProperty("calledTwice", "been called exactly twice", ", but it was called %c%C"); sinonProperty("calledThrice", "been called exactly thrice", ", but it was called %c%C"); sinonMethodAsProperty("calledWithNew", "been called with new"); sinonMethod("calledBefore", "been called before %1"); sinonMethod("calledAfter", "been called after %1"); sinonMethod("calledImmediatelyBefore", "been called immediately before %1"); sinonMethod("calledImmediatelyAfter", "been called immediately after %1"); sinonMethod("calledOn", "been called with %1 as this", ", but it was called with %t instead"); sinonMethod("calledWith", "been called with arguments %*", "%D"); sinonMethod("calledOnceWith", "been called exactly once with arguments %*", "%D"); sinonMethod("calledWithExactly", "been called with exact arguments %*", "%D"); sinonMethod("calledOnceWithExactly", "been called exactly once with exact arguments %*", "%D"); sinonMethod("calledWithMatch", "been called with arguments matching %*", "%D"); sinonMethod("returned", "returned %1"); exceptionalSinonMethod("thrown", "threw", "thrown %1"); })); </script> <div id="mocha"></div> 

I am not really sure where to go from here. If I were working with the two entities directly, rather than using calledWithMatch , I would explicitly check the top , bottom , left , and right values using chai-almost . Something similar to:

expect(newBoundingBox.top).to.almost.equal(boundingBox.top)
expect(newBoundingBox.bottom).to.almost.equal(boundingBox.bottom)
expect(newBoundingBox.left).to.almost.equal(boundingBox.left)
expect(newBoundingBox.right).to.almost.equal(boundingBox.right)

but I haven't been able to see a way to achieve this when using calledWithMatch .

Am I missing something? Is there a simple approach to this?

EDIT: Just updating this as I tinker.

I think it's likely the correct approach is to use a custom matcher, but I don't have working code yet: https://sinonjs.org/releases/latest/matchers/#custom-matchers

It looks like the functional equivalent of calledWithMatch(foo) is calledWith(sinon.match(foo)) which makes it more clear how to introduce usage of a custom matcher.

Alrighty, I figured it out.

The trick is to replace the sinon-chai method calledWithMatch with its lower-level implementation calledWith(sinon.match . This allows one to define a custom matcher. I show the one I went with in the example below.

 mocha.setup('bdd'); const updater = { updateBoundingBox(boundingBox) { const newBoundingBox = { ...boundingBox }; newBoundingBox.top -= .2; newBoundingBox.top += .2; this.persist(newBoundingBox); }, persist(boundingBox) { console.log('persisting bounding box', boundingBox); } }; describe('example', () => { it('should pass', () => { const persistSpy = sinon.spy(updater, 'persist'); const originalBoundingBox = { top: 0.01, left: 0.01, bottom: 0.01, right: 0.01, }; updater.updateBoundingBox(originalBoundingBox); chai.expect(persistSpy).calledWith(sinon.match((boundingBox) => { if (!boundingBox) return false; const isLeftEqual = Math.abs(originalBoundingBox.left - boundingBox.left) < Number.EPSILON; const isRightEqual = Math.abs(originalBoundingBox.right - boundingBox.right) < Number.EPSILON; const isTopEqual = Math.abs(originalBoundingBox.top - boundingBox.top) < Number.EPSILON; const isBottomEqual = Math.abs(originalBoundingBox.bottom - boundingBox.bottom) < Number.EPSILON; return isLeftEqual && isRightEqual && isTopEqual && isBottomEqual; })); }); }); mocha.run(); 
 <script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/6.1.4/mocha.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/chai/4.2.0/chai.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/sinon.js/7.3.2/sinon.min.js"></script> <script> "use strict"; /* eslint-disable no-invalid-this */ (function (sinonChai) { // Module systems magic dance. /* istanbul ignore else */ if (typeof require === "function" && typeof exports === "object" && typeof module === "object") { // NodeJS module.exports = sinonChai; } else if (typeof define === "function" && define.amd) { // AMD define(function () { return sinonChai; }); } else { // Other environment (usually <script> tag): plug in to global chai instance directly. /* global chai: false */ chai.use(sinonChai); } }(function (chai, utils) { var slice = Array.prototype.slice; function isSpy(putativeSpy) { return typeof putativeSpy === "function" && typeof putativeSpy.getCall === "function" && typeof putativeSpy.calledWithExactly === "function"; } function timesInWords(count) { switch (count) { case 1: { return "once"; } case 2: { return "twice"; } case 3: { return "thrice"; } default: { return (count || 0) + " times"; } } } function isCall(putativeCall) { return putativeCall && isSpy(putativeCall.proxy); } function assertCanWorkWith(assertion) { if (!isSpy(assertion._obj) && !isCall(assertion._obj)) { throw new TypeError(utils.inspect(assertion._obj) + " is not a spy or a call to a spy!"); } } function getMessages(spy, action, nonNegatedSuffix, always, args) { var verbPhrase = always ? "always have " : "have "; nonNegatedSuffix = nonNegatedSuffix || ""; if (isSpy(spy.proxy)) { spy = spy.proxy; } function printfArray(array) { return spy.printf.apply(spy, array); } return { affirmative: function () { return printfArray(["expected %n to " + verbPhrase + action + nonNegatedSuffix].concat(args)); }, negative: function () { return printfArray(["expected %n to not " + verbPhrase + action].concat(args)); } }; } function sinonProperty(name, action, nonNegatedSuffix) { utils.addProperty(chai.Assertion.prototype, name, function () { assertCanWorkWith(this); var messages = getMessages(this._obj, action, nonNegatedSuffix, false); this.assert(this._obj[name], messages.affirmative, messages.negative); }); } function sinonPropertyAsBooleanMethod(name, action, nonNegatedSuffix) { utils.addMethod(chai.Assertion.prototype, name, function (arg) { assertCanWorkWith(this); var messages = getMessages(this._obj, action, nonNegatedSuffix, false, [timesInWords(arg)]); this.assert(this._obj[name] === arg, messages.affirmative, messages.negative); }); } function createSinonMethodHandler(sinonName, action, nonNegatedSuffix) { return function () { assertCanWorkWith(this); var alwaysSinonMethod = "always" + sinonName[0].toUpperCase() + sinonName.substring(1); var shouldBeAlways = utils.flag(this, "always") && typeof this._obj[alwaysSinonMethod] === "function"; var sinonMethodName = shouldBeAlways ? alwaysSinonMethod : sinonName; var messages = getMessages(this._obj, action, nonNegatedSuffix, shouldBeAlways, slice.call(arguments)); this.assert( this._obj[sinonMethodName].apply(this._obj, arguments), messages.affirmative, messages.negative ); }; } function sinonMethodAsProperty(name, action, nonNegatedSuffix) { var handler = createSinonMethodHandler(name, action, nonNegatedSuffix); utils.addProperty(chai.Assertion.prototype, name, handler); } function exceptionalSinonMethod(chaiName, sinonName, action, nonNegatedSuffix) { var handler = createSinonMethodHandler(sinonName, action, nonNegatedSuffix); utils.addMethod(chai.Assertion.prototype, chaiName, handler); } function sinonMethod(name, action, nonNegatedSuffix) { exceptionalSinonMethod(name, name, action, nonNegatedSuffix); } utils.addProperty(chai.Assertion.prototype, "always", function () { utils.flag(this, "always", true); }); sinonProperty("called", "been called", " at least once, but it was never called"); sinonPropertyAsBooleanMethod("callCount", "been called exactly %1", ", but it was called %c%C"); sinonProperty("calledOnce", "been called exactly once", ", but it was called %c%C"); sinonProperty("calledTwice", "been called exactly twice", ", but it was called %c%C"); sinonProperty("calledThrice", "been called exactly thrice", ", but it was called %c%C"); sinonMethodAsProperty("calledWithNew", "been called with new"); sinonMethod("calledBefore", "been called before %1"); sinonMethod("calledAfter", "been called after %1"); sinonMethod("calledImmediatelyBefore", "been called immediately before %1"); sinonMethod("calledImmediatelyAfter", "been called immediately after %1"); sinonMethod("calledOn", "been called with %1 as this", ", but it was called with %t instead"); sinonMethod("calledWith", "been called with arguments %*", "%D"); sinonMethod("calledOnceWith", "been called exactly once with arguments %*", "%D"); sinonMethod("calledWithExactly", "been called with exact arguments %*", "%D"); sinonMethod("calledOnceWithExactly", "been called exactly once with exact arguments %*", "%D"); sinonMethod("calledWithMatch", "been called with arguments matching %*", "%D"); sinonMethod("returned", "returned %1"); exceptionalSinonMethod("thrown", "threw", "thrown %1"); })); </script> <div id="mocha"></div> 

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM