From 9472df0db499679534143b7956ea83281bb0e02e Mon Sep 17 00:00:00 2001 From: Steve Gravrock Date: Mon, 4 Jun 2018 21:01:22 -0700 Subject: [PATCH] Added a basic set of async matchers - Fixes #1447 - Fixes #1547 --- lib/jasmine-core/jasmine.js | 248 +++++++++++++++++++++++-- spec/core/AsyncExpectationSpec.js | 288 +++++++++++++++++++++++++++++ spec/core/ExpectationResultSpec.js | 15 ++ spec/core/integration/EnvSpec.js | 102 ++++++++++ spec/helpers/promises.js | 8 + spec/support/jasmine.json | 1 + spec/support/jasmine.yml | 1 + src/core/AsyncExpectation.js | 153 +++++++++++++++ src/core/Env.js | 24 +++ src/core/Expectation.js | 34 ++-- src/core/ExpectationResult.js | 4 +- src/core/Spec.js | 5 + src/core/Suite.js | 5 + src/core/base.js | 2 +- src/core/requireCore.js | 1 + src/core/requireInterface.js | 19 ++ 16 files changed, 876 insertions(+), 34 deletions(-) create mode 100644 spec/core/AsyncExpectationSpec.js create mode 100644 spec/helpers/promises.js create mode 100644 src/core/AsyncExpectation.js diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index ae5fec59..8d4d2728 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -60,6 +60,7 @@ var getJasmineRequireObj = (function (jasmineGlobal) { j$.StackTrace = jRequire.StackTrace(j$); j$.ExceptionFormatter = jRequire.ExceptionFormatter(j$); j$.Expectation = jRequire.Expectation(); + j$.AsyncExpectation = jRequire.AsyncExpectation(j$); j$.buildExpectationResult = jRequire.buildExpectationResult(); j$.JsApiReporter = jRequire.JsApiReporter(); j$.matchersUtil = jRequire.matchersUtil(j$); @@ -260,7 +261,7 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) { }; j$.isPromise = function(obj) { - return typeof jasmineGlobal.Promise !== 'undefined' && obj.constructor === jasmineGlobal.Promise; + return typeof jasmineGlobal.Promise !== 'undefined' && obj && obj.constructor === jasmineGlobal.Promise; }; j$.fnNameFor = function(func) { @@ -536,6 +537,7 @@ getJasmineRequireObj().util = function(j$) { getJasmineRequireObj().Spec = function(j$) { function Spec(attrs) { this.expectationFactory = attrs.expectationFactory; + this.asyncExpectationFactory = attrs.asyncExpectationFactory; this.resultCallback = attrs.resultCallback || function() {}; this.id = attrs.id; this.description = attrs.description || ''; @@ -592,6 +594,10 @@ getJasmineRequireObj().Spec = function(j$) { return this.expectationFactory(actual, this); }; + Spec.prototype.expectAsync = function(actual) { + return this.asyncExpectationFactory(actual, this); + }; + Spec.prototype.execute = function(onComplete, excluded) { var self = this; @@ -882,6 +888,19 @@ getJasmineRequireObj().Env = function(j$) { } }; + var asyncExpectationFactory = function(actual, spec) { + return j$.AsyncExpectation.factory({ + util: j$.matchersUtil, + customEqualityTesters: runnableResources[spec.id].customEqualityTesters, + actual: actual, + addExpectationResult: addExpectationResult + }); + + function addExpectationResult(passed, result) { + return spec.addExpectationResult(passed, result); + } + }; + var defaultResourcesForRunnable = function(id, parentRunnableId) { var resources = {spies: [], customEqualityTesters: [], customMatchers: {}, customSpyStrategies: {}}; @@ -1012,6 +1031,7 @@ getJasmineRequireObj().Env = function(j$) { id: getNextSuiteId(), description: 'Jasmine__TopLevel__Suite', expectationFactory: expectationFactory, + asyncExpectationFactory: asyncExpectationFactory, expectationResultFactory: expectationResultFactory }); defaultResourcesForRunnable(topSuite.id); @@ -1286,6 +1306,7 @@ getJasmineRequireObj().Env = function(j$) { description: description, parentSuite: currentDeclarationSuite, expectationFactory: expectationFactory, + asyncExpectationFactory: asyncExpectationFactory, expectationResultFactory: expectationResultFactory, throwOnExpectationFailure: throwOnExpectationFailure }); @@ -1379,6 +1400,7 @@ getJasmineRequireObj().Env = function(j$) { id: getNextSpecId(), beforeAndAfterFns: beforeAndAfterFns(suite), expectationFactory: expectationFactory, + asyncExpectationFactory: asyncExpectationFactory, resultCallback: specResultCallback, getSpecName: function(spec) { return getSpecName(spec, suite); @@ -1460,6 +1482,14 @@ getJasmineRequireObj().Env = function(j$) { return currentRunnable().expect(actual); }; + this.expectAsync = function(actual) { + if (!currentRunnable()) { + throw new Error('\'expectAsync\' was used when there was no current spec, this could be because an asynchronous test timed out'); + } + + return currentRunnable().expectAsync(actual); + }; + this.beforeEach = function(beforeEachFunction, timeout) { ensureIsNotNested('beforeEach'); ensureIsFunctionOrAsync(beforeEachFunction, 'beforeEach'); @@ -1950,6 +1980,160 @@ getJasmineRequireObj().Truthy = function(j$) { return Truthy; }; +getJasmineRequireObj().AsyncExpectation = function(j$) { + var promiseForMessage = { + jasmineToString: function() { return 'a promise'; } + }; + + /** + * Asynchronous matchers. + * @namespace async-matchers + */ + function AsyncExpectation(options) { + var global = options.global || j$.getGlobal(); + this.util = options.util || { buildFailureMessage: function() {} }; + this.customEqualityTesters = options.customEqualityTesters || []; + this.addExpectationResult = options.addExpectationResult || function(){}; + this.actual = options.actual; + this.isNot = options.isNot; + + if (!global.Promise) { + throw new Error('expectAsync is unavailable because the environment does not support promises.'); + } + + if (!j$.isPromise(this.actual)) { + throw new Error('Expected expectAsync to be called with a promise.'); + } + + ['toBeResolved', 'toBeRejected', 'toBeResolvedTo'].forEach(wrapCompare.bind(this)); + } + + function wrapCompare(name) { + var compare = this[name]; + this[name] = function() { + var self = this; + var args = Array.prototype.slice.call(arguments); + args.unshift(this.actual); + + // Capture the call stack here, before we go async, so that it will + // contain frames that are relevant to the user instead of just parts + // of Jasmine. + var errorForStack = j$.util.errorWithStack(); + + return compare.apply(self, args).then(function(result) { + var message; + + if (self.isNot) { + result.pass = !result.pass; + } + + args[0] = promiseForMessage; + message = j$.Expectation.finalizeMessage(self.util, name, self.isNot, args, result); + + self.addExpectationResult(result.pass, { + matcherName: name, + passed: result.pass, + message: message, + error: undefined, + errorForStack: errorForStack, + actual: self.actual + }); + }); + }; + } + + /** + * Expect a promise to be resolved. + * @function + * @async + * @name async-matchers#toBeResolved + * @example + * await expectAsync(aPromise).toBeResolved(); + * @example + * return expectAsync(aPromise).toBeResolved(); + */ + AsyncExpectation.prototype.toBeResolved = function(actual) { + return actual.then( + function() { return {pass: true}; }, + function() { return {pass: false}; } + ); + }; + + /** + * Expect a promise to be rejected. + * @function + * @async + * @name async-matchers#toBeRejected + * @example + * await expectAsync(aPromise).toBeRejected(); + * @example + * return expectAsync(aPromise).toBeRejected(); + */ + AsyncExpectation.prototype.toBeRejected = function(actual) { + return actual.then( + function() { return {pass: false}; }, + function() { return {pass: true}; } + ); + }; + + /** + * Expect a promise to be resolved to a value equal to the expected, using deep equality comparison. + * @function + * @async + * @name async-matchers#toBeResolvedTo + * @param {Object} expected - Value that the promise is expected to resolve to + * @example + * await expectAsync(aPromise).toBeResolvedTo({prop: 'value'}); + * @example + * return expectAsync(aPromise).toBeResolvedTo({prop: 'value'}); + */ + AsyncExpectation.prototype.toBeResolvedTo = function(actualPromise, expectedValue) { + var self = this; + + function prefix(passed) { + return 'Expected a promise ' + + (passed ? 'not ' : '') + + 'to be resolved to ' + j$.pp(expectedValue); + } + + return actualPromise.then( + function(actualValue) { + if (self.util.equals(actualValue, expectedValue, self.customEqualityTesters)) { + return { + pass: true, + message: prefix(true) + '.' + }; + } else { + return { + pass: false, + message: prefix(false) + ' but it was resolved to ' + j$.pp(actualValue) + '.' + }; + } + }, + function() { + return { + pass: false, + message: prefix(false) + ' but it was rejected.' + }; + } + ); + }; + + + AsyncExpectation.factory = function(options) { + var expect = new AsyncExpectation(options); + + options = j$.util.clone(options); + options.isNot = true; + expect.not = new AsyncExpectation(options); + + return expect; + }; + + + return AsyncExpectation; +}; + getJasmineRequireObj().CallTracker = function(j$) { /** @@ -2608,7 +2792,7 @@ getJasmineRequireObj().Expectation = function() { return function() { var args = Array.prototype.slice.call(arguments, 0), expected = args.slice(0), - message = ''; + message; args.unshift(this.actual); @@ -2626,20 +2810,7 @@ getJasmineRequireObj().Expectation = function() { } var result = matcherCompare.apply(null, args); - - if (!result.pass) { - if (!result.message) { - args.unshift(this.isNot); - args.unshift(name); - message = this.util.buildFailureMessage.apply(null, args); - } else { - if (Object.prototype.toString.apply(result.message) === '[object Function]') { - message = result.message(); - } else { - message = result.message; - } - } - } + message = Expectation.finalizeMessage(this.util, name, this.isNot, args, result); if (expected.length == 1) { expected = expected[0]; @@ -2660,6 +2831,23 @@ getJasmineRequireObj().Expectation = function() { }; }; + Expectation.finalizeMessage = function(util, name, isNot, args, result) { + if (result.pass) { + return ''; + } else if (result.message) { + if (Object.prototype.toString.apply(result.message) === '[object Function]') { + return result.message(); + } else { + return result.message; + } + } else { + args = args.slice(); + args.unshift(isNot); + args.unshift(name); + return util.buildFailureMessage.apply(null, args); + } + }; + Expectation.addCoreMatchers = function(matchers) { var prototype = Expectation.prototype; for (var matcherName in matchers) { @@ -2731,7 +2919,9 @@ getJasmineRequireObj().buildExpectationResult = function() { var error = options.error; if (!error) { - if (options.stack) { + if (options.errorForStack) { + error = options.errorForStack; + } else if (options.stack) { error = options; } else { try { @@ -5177,6 +5367,25 @@ getJasmineRequireObj().interface = function(jasmine, env) { return env.expect(actual); }, + /** + * Create an asynchronous expectation for a spec. Note that the matchers + * that are provided by an asynchronous expectation all return promises + * which must be either returned from the spec or waited for using `await` + * in order for Jasmine to associate them with the correct spec. + * @name expectAsync + * @function + * @global + * @param {Object} actual - Actual computed value to test expectations against. + * @return {async-matchers} + * @example + * await expectAsync(somePromise).toBeResolved(); + * @example + * return expectAsync(somePromise).toBeResolved(); + */ + expectAsync: function(actual) { + return env.expectAsync(actual); + }, + /** * Mark a spec as pending, expectation results will be ignored. * @name pending @@ -5904,6 +6113,7 @@ getJasmineRequireObj().Suite = function(j$) { this.parentSuite = attrs.parentSuite; this.description = attrs.description; this.expectationFactory = attrs.expectationFactory; + this.asyncExpectationFactory = attrs.asyncExpectationFactory; this.expectationResultFactory = attrs.expectationResultFactory; this.throwOnExpectationFailure = !!attrs.throwOnExpectationFailure; @@ -5936,6 +6146,10 @@ getJasmineRequireObj().Suite = function(j$) { return this.expectationFactory(actual, this); }; + Suite.prototype.expectAsync = function(actual) { + return this.asyncExpectationFactory(actual, this); + }; + Suite.prototype.getFullName = function() { var fullName = []; for (var parentSuite = this; parentSuite; parentSuite = parentSuite.parentSuite) { diff --git a/spec/core/AsyncExpectationSpec.js b/spec/core/AsyncExpectationSpec.js new file mode 100644 index 00000000..b42901b2 --- /dev/null +++ b/spec/core/AsyncExpectationSpec.js @@ -0,0 +1,288 @@ +describe('AsyncExpectation', function() { + describe('Factory', function() { + it('throws an Error if promises are not available', function() { + var thenable = {then: function() {}}, + options = {global: {}, actual: thenable} + function f() { jasmineUnderTest.AsyncExpectation.factory(options); } + expect(f).toThrowError('expectAsync is unavailable because the environment does not support promises.'); + }); + + it('throws an Error if the argument is not a promise', function() { + jasmine.getEnv().requirePromises(); + function f() { + jasmineUnderTest.AsyncExpectation.factory({actual: 'not a promise'}); + } + expect(f).toThrowError('Expected expectAsync to be called with a promise.'); + }); + }); + + describe('#toBeResolved', function() { + it('passes if the actual is resolved', function() { + jasmine.getEnv().requirePromises(); + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + actual = Promise.resolve(), + expectation = new jasmineUnderTest.AsyncExpectation({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.toBeResolved().then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(true, { + matcherName: 'toBeResolved', + passed: true, + message: '', + error: undefined, + errorForStack: jasmine.any(Error), + actual: actual + }); + }); + }); + + it('fails if the actual is rejected', function() { + jasmine.getEnv().requirePromises(); + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + actual = Promise.reject('AsyncExpectationSpec rejection'), + expectation = new jasmineUnderTest.AsyncExpectation({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.toBeResolved().then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(false, { + matcherName: 'toBeResolved', + passed: false, + message: 'Expected a promise to be resolved.', + error: undefined, + errorForStack: jasmine.any(Error), + actual: actual + }); + }); + }); + }); + + describe('#toBeRejected', function() { + it('passes if the actual is rejected', function() { + jasmine.getEnv().requirePromises(); + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + actual = Promise.reject('AsyncExpectationSpec rejection'), + expectation = new jasmineUnderTest.AsyncExpectation({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.toBeRejected().then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(true, { + matcherName: 'toBeRejected', + passed: true, + message: '', + error: undefined, + errorForStack: jasmine.any(Error), + actual: actual + }); + }); + }); + + it('fails if the actual is resolved', function() { + jasmine.getEnv().requirePromises(); + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + actual = Promise.resolve(), + expectation = new jasmineUnderTest.AsyncExpectation({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.toBeRejected().then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(false, { + matcherName: 'toBeRejected', + passed: false, + message: 'Expected a promise to be rejected.', + error: undefined, + errorForStack: jasmine.any(Error), + actual: actual + }); + }); + }); + }); + + describe('#toBeResolvedTo', function() { + it('passes if the promise is resolved to the expected value', function() { + jasmine.getEnv().requirePromises(); + + var actual = Promise.resolve({foo: 42}); + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + expectation = new jasmineUnderTest.AsyncExpectation({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.toBeResolvedTo({foo: 42}).then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(true, { + matcherName: 'toBeResolvedTo', + passed: true, + message: '', + error: undefined, + errorForStack: jasmine.any(Error), + actual: actual + }); + }); + }); + + it('fails if the promise is rejected', function() { + jasmine.getEnv().requirePromises(); + + var actual = Promise.reject('AsyncExpectationSpec error'); + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + expectation = new jasmineUnderTest.AsyncExpectation({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.toBeResolvedTo('').then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(false, { + matcherName: 'toBeResolvedTo', + passed: false, + message: "Expected a promise to be resolved to '' but it was rejected.", + error: undefined, + errorForStack: jasmine.any(Error), + actual: actual + }); + }); + }); + + it('fails if the promise is resolved to a different value', function() { + jasmine.getEnv().requirePromises(); + + var actual = Promise.resolve({foo: 17}); + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + expectation = new jasmineUnderTest.AsyncExpectation({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.toBeResolvedTo({foo: 42}).then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(false, { + matcherName: 'toBeResolvedTo', + passed: false, + message: 'Expected a promise to be resolved to Object({ foo: 42 }) but it was resolved to Object({ foo: 17 }).', + error: undefined, + errorForStack: jasmine.any(Error), + actual: actual + }); + }); + }); + + it('builds its message correctly when negated', function() { + jasmine.getEnv().requirePromises(); + + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + expectation = jasmineUnderTest.AsyncExpectation.factory({ + util: jasmineUnderTest.matchersUtil, + actual: Promise.resolve(true), + addExpectationResult: addExpectationResult + }); + + return expectation.not.toBeResolvedTo(true).then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(false, + jasmine.objectContaining({ + passed: false, + message: 'Expected a promise not to be resolved to true.' + }) + ); + }); + }); + + it('supports custom equality testers', function() { + jasmine.getEnv().requirePromises(); + + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + expectation = new jasmineUnderTest.AsyncExpectation({ + util: jasmineUnderTest.matchersUtil, + customEqualityTesters: [function() { return true; }], + actual: Promise.resolve('actual'), + addExpectationResult: addExpectationResult + }); + + return expectation.toBeResolvedTo('expected').then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(true, + jasmine.objectContaining({passed: true})); + }); + }); + }); + + describe('#not', function() { + it('converts a pass to a fail', function() { + jasmine.getEnv().requirePromises(); + + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + actual = Promise.resolve(), + expectation = jasmineUnderTest.AsyncExpectation.factory({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.not.toBeResolved().then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(false, + jasmine.objectContaining({ + passed: false, + message: 'Expected a promise not to be resolved.' + }) + ); + }); + }); + + it('converts a fail to a pass', function() { + jasmine.getEnv().requirePromises(); + + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + actual = Promise.reject(), + expectation = jasmineUnderTest.AsyncExpectation.factory({ + util: jasmineUnderTest.matchersUtil, + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.not.toBeResolved().then(function() { + expect(addExpectationResult).toHaveBeenCalledWith(true, + jasmine.objectContaining({ + passed: true, + message: '' + }) + ); + }); + }); + }); + + it('propagates rejections from the comparison function', function() { + jasmine.getEnv().requirePromises(); + var error = new Error('AsyncExpectationSpec failure'); + + spyOn(jasmineUnderTest.AsyncExpectation.prototype, 'toBeResolved') + .and.returnValue(Promise.reject(error)); + + var addExpectationResult = jasmine.createSpy('addExpectationResult'), + actual = dummyPromise(), + expectation = new jasmineUnderTest.AsyncExpectation({ + actual: actual, + addExpectationResult: addExpectationResult + }); + + return expectation.toBeResolved() + .then( + function() { fail('Expected a rejection'); }, + function(e) { expect(e).toBe(error); } + ); + }); + + function dummyPromise() { + return new Promise(function(resolve, reject) { + }); + } +}); diff --git a/spec/core/ExpectationResultSpec.js b/spec/core/ExpectationResultSpec.js index 41785287..53d2c7e3 100644 --- a/spec/core/ExpectationResultSpec.js +++ b/spec/core/ExpectationResultSpec.js @@ -44,6 +44,21 @@ describe("buildExpectationResult", function() { expect(result.stack).toEqual('foo'); }); + it("delegates stack formatting to the provided formatter if there was a provided errorForStack", function() { + var fakeError = {stack: 'foo'}, + stackFormatter = jasmine.createSpy("stack formatter").and.returnValue(fakeError.stack); + + var result = jasmineUnderTest.buildExpectationResult( + { + passed: false, + errorForStack: fakeError, + stackFormatter: stackFormatter + }); + + expect(stackFormatter).toHaveBeenCalledWith(fakeError); + expect(result.stack).toEqual('foo'); + }); + it("matcherName returns passed matcherName", function() { var result = jasmineUnderTest.buildExpectationResult({matcherName: 'some-value'}); expect(result.matcherName).toBe('some-value'); diff --git a/spec/core/integration/EnvSpec.js b/spec/core/integration/EnvSpec.js index 9098cefe..a691de06 100644 --- a/spec/core/integration/EnvSpec.js +++ b/spec/core/integration/EnvSpec.js @@ -2415,4 +2415,106 @@ describe("Env integration", function() { env.execute(); }); + + it('supports async matchers', function(done) { + jasmine.getEnv().requirePromises(); + + var env = new jasmineUnderTest.Env(), + specDone = jasmine.createSpy('specDone'), + suiteDone = jasmine.createSpy('suiteDone'); + + env.addReporter({ + specDone: specDone, + suiteDone: suiteDone, + jasmineDone: function(result) { + expect(result.failedExpectations).toEqual([jasmine.objectContaining({ + message: 'Expected a promise to be rejected.' + })]); + + expect(specDone).toHaveBeenCalledWith(jasmine.objectContaining({ + description: 'has an async failure', + failedExpectations: [jasmine.objectContaining({ + message: 'Expected a promise to be rejected.' + })] + })); + + expect(suiteDone).toHaveBeenCalledWith(jasmine.objectContaining({ + description: 'a suite', + failedExpectations: [jasmine.objectContaining({ + message: 'Expected a promise to be rejected.' + })] + })); + + done(); + } + }); + + function fail(innerDone) { + var resolve; + var p = new Promise(function(res, rej) { resolve = res }); + env.expectAsync(p).toBeRejected().then(innerDone); + resolve(); + } + + env.afterAll(fail); + env.describe('a suite', function() { + env.afterAll(fail); + env.it('has an async failure', fail); + }); + + env.execute(); + }); + + it('provides custom equality testers to async matchers', function(done) { + jasmine.getEnv().requirePromises(); + + var env = new jasmineUnderTest.Env(), + specDone = jasmine.createSpy('specDone'); + + env.addReporter({ + specDone: specDone, + jasmineDone: function() { + expect(specDone).toHaveBeenCalledWith(jasmine.objectContaining({ + description: 'has an async failure', + failedExpectations: [] + })); + done(); + } + }); + + env.it('has an async failure', function() { + env.addCustomEqualityTester(function() { return true; }); + var p = Promise.resolve('something'); + return env.expectAsync(p).toBeResolvedTo('something else'); + }); + + env.execute(); + }); + + it('includes useful stack frames in async matcher failures', function(done) { + jasmine.getEnv().requirePromises(); + + var env = new jasmineUnderTest.Env(), + specDone = jasmine.createSpy('specDone'); + + env.addReporter({ + specDone: specDone, + jasmineDone: function() { + expect(specDone).toHaveBeenCalledWith(jasmine.objectContaining({ + failedExpectations: [jasmine.objectContaining({ + stack: jasmine.stringMatching('EnvSpec.js') + })] + })); + done(); + } + }); + + env.it('has an async failure', function() { + env.addCustomEqualityTester(function() { return true; }); + var p = Promise.resolve(); + return env.expectAsync(p).toBeRejected(); + }); + + env.execute(); + }); }); diff --git a/spec/helpers/promises.js b/spec/helpers/promises.js new file mode 100644 index 00000000..76c6c024 --- /dev/null +++ b/spec/helpers/promises.js @@ -0,0 +1,8 @@ +(function(env) { + env.requirePromises = function() { + if (typeof Promise !== 'function') { + env.pending("Environment does not support promises"); + } + }; +})(jasmine.getEnv()); + diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index b2c83485..1d94e2f5 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -11,6 +11,7 @@ "helpers/checkForSymbol.js", "helpers/checkForTypedArrays.js", "helpers/integrationMatchers.js", + "helpers/promises.js", "helpers/nodeDefineJasmineUnderTest.js" ], "random": true diff --git a/spec/support/jasmine.yml b/spec/support/jasmine.yml index 88560b1a..6460624e 100644 --- a/spec/support/jasmine.yml +++ b/spec/support/jasmine.yml @@ -24,6 +24,7 @@ helpers: - 'helpers/checkForSymbol.js' - 'helpers/checkForTypedArrays.js' - 'helpers/integrationMatchers.js' + - 'helpers/promises.js' - 'helpers/defineJasmineUnderTest.js' spec_files: - '**/*[Ss]pec.js' diff --git a/src/core/AsyncExpectation.js b/src/core/AsyncExpectation.js new file mode 100644 index 00000000..f622f145 --- /dev/null +++ b/src/core/AsyncExpectation.js @@ -0,0 +1,153 @@ +getJasmineRequireObj().AsyncExpectation = function(j$) { + var promiseForMessage = { + jasmineToString: function() { return 'a promise'; } + }; + + /** + * Asynchronous matchers. + * @namespace async-matchers + */ + function AsyncExpectation(options) { + var global = options.global || j$.getGlobal(); + this.util = options.util || { buildFailureMessage: function() {} }; + this.customEqualityTesters = options.customEqualityTesters || []; + this.addExpectationResult = options.addExpectationResult || function(){}; + this.actual = options.actual; + this.isNot = options.isNot; + + if (!global.Promise) { + throw new Error('expectAsync is unavailable because the environment does not support promises.'); + } + + if (!j$.isPromise(this.actual)) { + throw new Error('Expected expectAsync to be called with a promise.'); + } + + ['toBeResolved', 'toBeRejected', 'toBeResolvedTo'].forEach(wrapCompare.bind(this)); + } + + function wrapCompare(name) { + var compare = this[name]; + this[name] = function() { + var self = this; + var args = Array.prototype.slice.call(arguments); + args.unshift(this.actual); + + // Capture the call stack here, before we go async, so that it will + // contain frames that are relevant to the user instead of just parts + // of Jasmine. + var errorForStack = j$.util.errorWithStack(); + + return compare.apply(self, args).then(function(result) { + var message; + + if (self.isNot) { + result.pass = !result.pass; + } + + args[0] = promiseForMessage; + message = j$.Expectation.finalizeMessage(self.util, name, self.isNot, args, result); + + self.addExpectationResult(result.pass, { + matcherName: name, + passed: result.pass, + message: message, + error: undefined, + errorForStack: errorForStack, + actual: self.actual + }); + }); + }; + } + + /** + * Expect a promise to be resolved. + * @function + * @async + * @name async-matchers#toBeResolved + * @example + * await expectAsync(aPromise).toBeResolved(); + * @example + * return expectAsync(aPromise).toBeResolved(); + */ + AsyncExpectation.prototype.toBeResolved = function(actual) { + return actual.then( + function() { return {pass: true}; }, + function() { return {pass: false}; } + ); + }; + + /** + * Expect a promise to be rejected. + * @function + * @async + * @name async-matchers#toBeRejected + * @example + * await expectAsync(aPromise).toBeRejected(); + * @example + * return expectAsync(aPromise).toBeRejected(); + */ + AsyncExpectation.prototype.toBeRejected = function(actual) { + return actual.then( + function() { return {pass: false}; }, + function() { return {pass: true}; } + ); + }; + + /** + * Expect a promise to be resolved to a value equal to the expected, using deep equality comparison. + * @function + * @async + * @name async-matchers#toBeResolvedTo + * @param {Object} expected - Value that the promise is expected to resolve to + * @example + * await expectAsync(aPromise).toBeResolvedTo({prop: 'value'}); + * @example + * return expectAsync(aPromise).toBeResolvedTo({prop: 'value'}); + */ + AsyncExpectation.prototype.toBeResolvedTo = function(actualPromise, expectedValue) { + var self = this; + + function prefix(passed) { + return 'Expected a promise ' + + (passed ? 'not ' : '') + + 'to be resolved to ' + j$.pp(expectedValue); + } + + return actualPromise.then( + function(actualValue) { + if (self.util.equals(actualValue, expectedValue, self.customEqualityTesters)) { + return { + pass: true, + message: prefix(true) + '.' + }; + } else { + return { + pass: false, + message: prefix(false) + ' but it was resolved to ' + j$.pp(actualValue) + '.' + }; + } + }, + function() { + return { + pass: false, + message: prefix(false) + ' but it was rejected.' + }; + } + ); + }; + + + AsyncExpectation.factory = function(options) { + var expect = new AsyncExpectation(options); + + options = j$.util.clone(options); + options.isNot = true; + expect.not = new AsyncExpectation(options); + + return expect; + }; + + + return AsyncExpectation; +}; diff --git a/src/core/Env.js b/src/core/Env.js index 9d87d26c..b109f797 100644 --- a/src/core/Env.js +++ b/src/core/Env.js @@ -117,6 +117,19 @@ getJasmineRequireObj().Env = function(j$) { } }; + var asyncExpectationFactory = function(actual, spec) { + return j$.AsyncExpectation.factory({ + util: j$.matchersUtil, + customEqualityTesters: runnableResources[spec.id].customEqualityTesters, + actual: actual, + addExpectationResult: addExpectationResult + }); + + function addExpectationResult(passed, result) { + return spec.addExpectationResult(passed, result); + } + }; + var defaultResourcesForRunnable = function(id, parentRunnableId) { var resources = {spies: [], customEqualityTesters: [], customMatchers: {}, customSpyStrategies: {}}; @@ -247,6 +260,7 @@ getJasmineRequireObj().Env = function(j$) { id: getNextSuiteId(), description: 'Jasmine__TopLevel__Suite', expectationFactory: expectationFactory, + asyncExpectationFactory: asyncExpectationFactory, expectationResultFactory: expectationResultFactory }); defaultResourcesForRunnable(topSuite.id); @@ -521,6 +535,7 @@ getJasmineRequireObj().Env = function(j$) { description: description, parentSuite: currentDeclarationSuite, expectationFactory: expectationFactory, + asyncExpectationFactory: asyncExpectationFactory, expectationResultFactory: expectationResultFactory, throwOnExpectationFailure: throwOnExpectationFailure }); @@ -614,6 +629,7 @@ getJasmineRequireObj().Env = function(j$) { id: getNextSpecId(), beforeAndAfterFns: beforeAndAfterFns(suite), expectationFactory: expectationFactory, + asyncExpectationFactory: asyncExpectationFactory, resultCallback: specResultCallback, getSpecName: function(spec) { return getSpecName(spec, suite); @@ -695,6 +711,14 @@ getJasmineRequireObj().Env = function(j$) { return currentRunnable().expect(actual); }; + this.expectAsync = function(actual) { + if (!currentRunnable()) { + throw new Error('\'expectAsync\' was used when there was no current spec, this could be because an asynchronous test timed out'); + } + + return currentRunnable().expectAsync(actual); + }; + this.beforeEach = function(beforeEachFunction, timeout) { ensureIsNotNested('beforeEach'); ensureIsFunctionOrAsync(beforeEachFunction, 'beforeEach'); diff --git a/src/core/Expectation.js b/src/core/Expectation.js index 8ebba620..e302cc29 100644 --- a/src/core/Expectation.js +++ b/src/core/Expectation.js @@ -21,7 +21,7 @@ getJasmineRequireObj().Expectation = function() { return function() { var args = Array.prototype.slice.call(arguments, 0), expected = args.slice(0), - message = ''; + message; args.unshift(this.actual); @@ -39,20 +39,7 @@ getJasmineRequireObj().Expectation = function() { } var result = matcherCompare.apply(null, args); - - if (!result.pass) { - if (!result.message) { - args.unshift(this.isNot); - args.unshift(name); - message = this.util.buildFailureMessage.apply(null, args); - } else { - if (Object.prototype.toString.apply(result.message) === '[object Function]') { - message = result.message(); - } else { - message = result.message; - } - } - } + message = Expectation.finalizeMessage(this.util, name, this.isNot, args, result); if (expected.length == 1) { expected = expected[0]; @@ -73,6 +60,23 @@ getJasmineRequireObj().Expectation = function() { }; }; + Expectation.finalizeMessage = function(util, name, isNot, args, result) { + if (result.pass) { + return ''; + } else if (result.message) { + if (Object.prototype.toString.apply(result.message) === '[object Function]') { + return result.message(); + } else { + return result.message; + } + } else { + args = args.slice(); + args.unshift(isNot); + args.unshift(name); + return util.buildFailureMessage.apply(null, args); + } + }; + Expectation.addCoreMatchers = function(matchers) { var prototype = Expectation.prototype; for (var matcherName in matchers) { diff --git a/src/core/ExpectationResult.js b/src/core/ExpectationResult.js index b4a43e4f..869d5d24 100644 --- a/src/core/ExpectationResult.js +++ b/src/core/ExpectationResult.js @@ -45,7 +45,9 @@ getJasmineRequireObj().buildExpectationResult = function() { var error = options.error; if (!error) { - if (options.stack) { + if (options.errorForStack) { + error = options.errorForStack; + } else if (options.stack) { error = options; } else { try { diff --git a/src/core/Spec.js b/src/core/Spec.js index 3049ea95..524dece6 100644 --- a/src/core/Spec.js +++ b/src/core/Spec.js @@ -1,6 +1,7 @@ getJasmineRequireObj().Spec = function(j$) { function Spec(attrs) { this.expectationFactory = attrs.expectationFactory; + this.asyncExpectationFactory = attrs.asyncExpectationFactory; this.resultCallback = attrs.resultCallback || function() {}; this.id = attrs.id; this.description = attrs.description || ''; @@ -57,6 +58,10 @@ getJasmineRequireObj().Spec = function(j$) { return this.expectationFactory(actual, this); }; + Spec.prototype.expectAsync = function(actual) { + return this.asyncExpectationFactory(actual, this); + }; + Spec.prototype.execute = function(onComplete, excluded) { var self = this; diff --git a/src/core/Suite.js b/src/core/Suite.js index 7ea2c168..c85f10c7 100644 --- a/src/core/Suite.js +++ b/src/core/Suite.js @@ -5,6 +5,7 @@ getJasmineRequireObj().Suite = function(j$) { this.parentSuite = attrs.parentSuite; this.description = attrs.description; this.expectationFactory = attrs.expectationFactory; + this.asyncExpectationFactory = attrs.asyncExpectationFactory; this.expectationResultFactory = attrs.expectationResultFactory; this.throwOnExpectationFailure = !!attrs.throwOnExpectationFailure; @@ -37,6 +38,10 @@ getJasmineRequireObj().Suite = function(j$) { return this.expectationFactory(actual, this); }; + Suite.prototype.expectAsync = function(actual) { + return this.asyncExpectationFactory(actual, this); + }; + Suite.prototype.getFullName = function() { var fullName = []; for (var parentSuite = this; parentSuite; parentSuite = parentSuite.parentSuite) { diff --git a/src/core/base.js b/src/core/base.js index 2c29d4da..3a281eee 100644 --- a/src/core/base.js +++ b/src/core/base.js @@ -120,7 +120,7 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) { }; j$.isPromise = function(obj) { - return typeof jasmineGlobal.Promise !== 'undefined' && obj.constructor === jasmineGlobal.Promise; + return typeof jasmineGlobal.Promise !== 'undefined' && obj && obj.constructor === jasmineGlobal.Promise; }; j$.fnNameFor = function(func) { diff --git a/src/core/requireCore.js b/src/core/requireCore.js index 95636ea8..64645f68 100644 --- a/src/core/requireCore.js +++ b/src/core/requireCore.js @@ -38,6 +38,7 @@ var getJasmineRequireObj = (function (jasmineGlobal) { j$.StackTrace = jRequire.StackTrace(j$); j$.ExceptionFormatter = jRequire.ExceptionFormatter(j$); j$.Expectation = jRequire.Expectation(); + j$.AsyncExpectation = jRequire.AsyncExpectation(j$); j$.buildExpectationResult = jRequire.buildExpectationResult(); j$.JsApiReporter = jRequire.JsApiReporter(); j$.matchersUtil = jRequire.matchersUtil(j$); diff --git a/src/core/requireInterface.js b/src/core/requireInterface.js index ee43d438..8a7df358 100644 --- a/src/core/requireInterface.js +++ b/src/core/requireInterface.js @@ -167,6 +167,25 @@ getJasmineRequireObj().interface = function(jasmine, env) { return env.expect(actual); }, + /** + * Create an asynchronous expectation for a spec. Note that the matchers + * that are provided by an asynchronous expectation all return promises + * which must be either returned from the spec or waited for using `await` + * in order for Jasmine to associate them with the correct spec. + * @name expectAsync + * @function + * @global + * @param {Object} actual - Actual computed value to test expectations against. + * @return {async-matchers} + * @example + * await expectAsync(somePromise).toBeResolved(); + * @example + * return expectAsync(somePromise).toBeResolved(); + */ + expectAsync: function(actual) { + return env.expectAsync(actual); + }, + /** * Mark a spec as pending, expectation results will be ignored. * @name pending