Skip to content

Commit

Permalink
Added expectAsync(...).already
Browse files Browse the repository at this point in the history
* Causes async matchers to immediately fail if the promise is pending
* Fixes #1845
  • Loading branch information
sgravrock committed Jun 24, 2021
1 parent 5862b22 commit dbc1a0a
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 0 deletions.
57 changes: 57 additions & 0 deletions lib/jasmine-core/jasmine.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,19 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) {
return matches ? matches[1] : '<anonymous>';
};

j$.isPending_ = function(promise) {
var sentinel = {};
// eslint-disable-next-line compat/compat
return Promise.race([promise, Promise.resolve(sentinel)]).then(
function(result) {
return result === sentinel;
},
function() {
return false;
}
);
};

/**
* Get a matcher, usable in any {@link matchers|matcher} that uses Jasmine's equality (e.g. {@link matchers#toEqual|toEqual}, {@link matchers#toContain|toContain}, or {@link matchers#toHaveBeenCalledWith|toHaveBeenCalledWith}),
* that will succeed if the actual value being compared is an instance of the specified class/constructor.
Expand Down Expand Up @@ -3729,6 +3742,12 @@ getJasmineRequireObj().Expectation = function(j$) {
* Asynchronous matchers that operate on an actual value which is a promise,
* and return a promise.
*
* Most async matchers will wait indefinitely for the promise to be resolved
* or rejected, resulting in a spec timeout if that never happens. If you
* expect that the promise will already be resolved or rejected at the time
* the matcher is called, you can use the {@link async-matchers#already}
* modifier to get a faster failure with a more helpful message.
*
* Note: Specs must await the result of each async matcher, return the
* promise returned by the matcher, or return a promise that's derived from
* the one returned by the matcher. Otherwise the matcher will not be
Expand Down Expand Up @@ -3798,6 +3817,23 @@ getJasmineRequireObj().Expectation = function(j$) {
}
});

/**
* Fail as soon as possible if the actual is pending.
* Otherwise evaluate the matcher.
* @member
* @name async-matchers#already
* @type {async-matchers}
* @example
* await expectAsync(myPromise).already.toBeResolved();
* @example
* return expectAsync(myPromise).already.toBeResolved();
*/
Object.defineProperty(AsyncExpectation.prototype, 'already', {
get: function() {
return addFilter(this, expectSettledPromiseFilter);
}
});

function wrapSyncCompare(name, matcherFactory) {
return function() {
var result = this.expector.compare(name, matcherFactory, arguments);
Expand Down Expand Up @@ -3876,6 +3912,27 @@ getJasmineRequireObj().Expectation = function(j$) {
buildFailureMessage: negatedFailureMessage
};

var expectSettledPromiseFilter = {
selectComparisonFunc: function(matcher) {
return function(actual) {
var matcherArgs = arguments;

return j$.isPending_(actual).then(function(isPending) {
if (isPending) {
return {
pass: false,
message:
'Expected a promise to be settled (via ' +
'expectAsync(...).already) but it was pending.'
};
} else {
return matcher.compare.apply(null, matcherArgs);
}
});
};
}
};

function ContextAddingFilter(message) {
this.message = message;
}
Expand Down
29 changes: 29 additions & 0 deletions spec/core/baseSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,33 @@ describe('base helpers', function() {
expect(jasmineUnderTest.isURL({})).toBe(false);
});
});

describe('isPending_', function() {
it('returns a promise that resolves to true when the promise is pending', function() {
jasmine.getEnv().requirePromises();
// eslint-disable-next-line compat/compat
var promise = new Promise(function() {});
return expectAsync(jasmineUnderTest.isPending_(promise)).toBeResolvedTo(
true
);
});

it('returns a promise that resolves to false when the promise is resolved', function() {
jasmine.getEnv().requirePromises();
// eslint-disable-next-line compat/compat
var promise = Promise.resolve();
return expectAsync(jasmineUnderTest.isPending_(promise)).toBeResolvedTo(
false
);
});

it('returns a promise that resolves to false when the promise is rejected', function() {
jasmine.getEnv().requirePromises();
// eslint-disable-next-line compat/compat
var promise = Promise.reject();
return expectAsync(jasmineUnderTest.isPending_(promise)).toBeResolvedTo(
false
);
});
});
});
85 changes: 85 additions & 0 deletions spec/core/integration/MatchersSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -754,4 +754,89 @@ describe('Matchers (Integration)', function() {
'a predicate, but it threw Error with message |nope|.'
});
});

describe('When an async matcher is used with .already()', function() {
it('propagates the matcher result when the promise is resolved', function(done) {
jasmine.getEnv().requirePromises();

env.it('a spec', function() {
// eslint-disable-next-line compat/compat
return env.expectAsync(Promise.resolve()).already.toBeRejected();
});

var specExpectations = function(result) {
expect(result.status).toEqual('failed');
expect(result.failedExpectations.length)
.withContext('Number of failed expectations')
.toEqual(1);
expect(result.failedExpectations[0].message).toEqual(
'Expected [object Promise] to be rejected.'
);
expect(result.failedExpectations[0].matcherName)
.withContext('Matcher name')
.not.toEqual('');
};

env.addReporter({ specDone: specExpectations });
env.execute(null, done);
});

it('propagates the matcher result when the promise is rejected', function(done) {
jasmine.getEnv().requirePromises();

env.it('a spec', function() {
return (
env
// eslint-disable-next-line compat/compat
.expectAsync(Promise.reject(new Error('nope')))
.already.toBeResolved()
);
});

var specExpectations = function(result) {
expect(result.status).toEqual('failed');
expect(result.failedExpectations.length)
.withContext('Number of failed expectations')
.toEqual(1);
expect(result.failedExpectations[0].message).toEqual(
'Expected a promise to be resolved but it was ' +
'rejected with Error: nope.'
);
expect(result.failedExpectations[0].matcherName)
.withContext('Matcher name')
.not.toEqual('');
};

env.addReporter({ specDone: specExpectations });
env.execute(null, done);
});

it('fails when the promise is pending', function(done) {
jasmine.getEnv().requirePromises();

// eslint-disable-next-line compat/compat
var promise = new Promise(function() {});

env.it('a spec', function() {
return env.expectAsync(promise).already.toBeResolved();
});

var specExpectations = function(result) {
expect(result.status).toEqual('failed');
expect(result.failedExpectations.length)
.withContext('Number of failed expectations')
.toEqual(1);
expect(result.failedExpectations[0].message).toEqual(
'Expected a promise to be settled ' +
'(via expectAsync(...).already) but it was pending.'
);
expect(result.failedExpectations[0].matcherName)
.withContext('Matcher name')
.not.toEqual('');
};

env.addReporter({ specDone: specExpectations });
env.execute(null, done);
});
});
});
44 changes: 44 additions & 0 deletions src/core/Expectation.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ getJasmineRequireObj().Expectation = function(j$) {
* Asynchronous matchers that operate on an actual value which is a promise,
* and return a promise.
*
* Most async matchers will wait indefinitely for the promise to be resolved
* or rejected, resulting in a spec timeout if that never happens. If you
* expect that the promise will already be resolved or rejected at the time
* the matcher is called, you can use the {@link async-matchers#already}
* modifier to get a faster failure with a more helpful message.
*
* Note: Specs must await the result of each async matcher, return the
* promise returned by the matcher, or return a promise that's derived from
* the one returned by the matcher. Otherwise the matcher will not be
Expand Down Expand Up @@ -115,6 +121,23 @@ getJasmineRequireObj().Expectation = function(j$) {
}
});

/**
* Fail as soon as possible if the actual is pending.
* Otherwise evaluate the matcher.
* @member
* @name async-matchers#already
* @type {async-matchers}
* @example
* await expectAsync(myPromise).already.toBeResolved();
* @example
* return expectAsync(myPromise).already.toBeResolved();
*/
Object.defineProperty(AsyncExpectation.prototype, 'already', {
get: function() {
return addFilter(this, expectSettledPromiseFilter);
}
});

function wrapSyncCompare(name, matcherFactory) {
return function() {
var result = this.expector.compare(name, matcherFactory, arguments);
Expand Down Expand Up @@ -193,6 +216,27 @@ getJasmineRequireObj().Expectation = function(j$) {
buildFailureMessage: negatedFailureMessage
};

var expectSettledPromiseFilter = {
selectComparisonFunc: function(matcher) {
return function(actual) {
var matcherArgs = arguments;

return j$.isPending_(actual).then(function(isPending) {
if (isPending) {
return {
pass: false,
message:
'Expected a promise to be settled (via ' +
'expectAsync(...).already) but it was pending.'
};
} else {
return matcher.compare.apply(null, matcherArgs);
}
});
};
}
};

function ContextAddingFilter(message) {
this.message = message;
}
Expand Down
13 changes: 13 additions & 0 deletions src/core/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,19 @@ getJasmineRequireObj().base = function(j$, jasmineGlobal) {
return matches ? matches[1] : '<anonymous>';
};

j$.isPending_ = function(promise) {
var sentinel = {};
// eslint-disable-next-line compat/compat
return Promise.race([promise, Promise.resolve(sentinel)]).then(
function(result) {
return result === sentinel;
},
function() {
return false;
}
);
};

/**
* Get a matcher, usable in any {@link matchers|matcher} that uses Jasmine's equality (e.g. {@link matchers#toEqual|toEqual}, {@link matchers#toContain|toContain}, or {@link matchers#toHaveBeenCalledWith|toHaveBeenCalledWith}),
* that will succeed if the actual value being compared is an instance of the specified class/constructor.
Expand Down

0 comments on commit dbc1a0a

Please sign in to comment.