diff --git a/docs/release-source/release.md b/docs/release-source/release.md index e1de08b5b..6fd530eba 100644 --- a/docs/release-source/release.md +++ b/docs/release-source/release.md @@ -15,6 +15,7 @@ This page contains the entire Sinon.JS API documentation along with brief introd - [Stubs](./stubs) - [Mocks](./mocks) - [Spy calls](./spy-call) +- [Promises](./promises) - [Fake timers](./fake-timers) - [Fake XHR and server](./fake-xhr-and-server) - [JSON-P](./json-p) diff --git a/docs/release-source/release/promises.md b/docs/release-source/release/promises.md new file mode 100644 index 000000000..cbc836c80 --- /dev/null +++ b/docs/release-source/release/promises.md @@ -0,0 +1,52 @@ +--- +layout: page +title: Promises - Sinon.JS +breadcrumb: promises +--- + +### Introduction + +`promise` allows to create fake promises that expose their internal state and can be resolved or rejected on demand. + +### Creating a promise + +```js +var promise = sinon.promise(); +``` + +#### Creating a promise with a fake executor + +```js +var executor = sinon.fake(); +var promise = sinon.promise(executor); +``` + +#### Creating a promise with custom executor + +```js +var promise = sinon.promise(function (resolve, reject) { + // ... +}); +``` + +### Promise API + +#### `promise.status` + +The internal status of the promise. One of `pending`, `resolved`, `rejected`. + +#### `promise.resolvedValue` + +The promise resolved value. + +#### `promise.rejectedValue` + +The promise rejected value. + +#### `promise.resolve(value)` + +Resolves the promise with the given value. Throws if the promise is not `pending`. + +#### `promise.reject(value)` + +Rejects the promise with the given value. Throws if the promise is not `pending`. diff --git a/lib/sinon.js b/lib/sinon.js index 045b04b66..8f1dedc8c 100644 --- a/lib/sinon.js +++ b/lib/sinon.js @@ -8,6 +8,7 @@ var format = require("./sinon/util/core/format"); var nise = require("nise"); var Sandbox = require("./sinon/sandbox"); var stub = require("./sinon/stub"); +var promise = require("./sinon/promise"); var apiMethods = { createSandbox: createSandbox, @@ -38,6 +39,9 @@ var apiMethods = { addBehavior: function (name, fn) { behavior.addBehavior(stub, name, fn); }, + + // fake promise + promise: promise, }; var sandbox = new Sandbox(); diff --git a/lib/sinon/promise.js b/lib/sinon/promise.js new file mode 100644 index 000000000..75bcf4b5a --- /dev/null +++ b/lib/sinon/promise.js @@ -0,0 +1,84 @@ +"use strict"; + +var fake = require("./fake"); +var isRestorable = require("./util/core/is-restorable"); + +var STATUS_PENDING = "pending"; +var STATUS_RESOLVED = "resolved"; +var STATUS_REJECTED = "rejected"; + +/** + * Returns a fake for a given function or undefined. If no functino is given, a + * new fake is returned. If the given function is already a fake, it is + * returned as is. Otherwise the given function is wrapped in a new fake. + * + * @param {Function} [executor] The optional executor function. + * @returns {Function} + */ +function getFakeExecutor(executor) { + if (isRestorable(executor)) { + return executor; + } + if (executor) { + return fake(executor); + } + return fake(); +} + +/** + * Returns a new promise that exposes it's internal `status`, `resolvedValue` + * and `rejectedValue` and can be resolved or rejected from the outside by + * calling `resolve(value)` or `reject(reason)`. + * + * @param {Function} [executor] The optional executor function. + * @returns {Promise} + */ +function promise(executor) { + var fakeExecutor = getFakeExecutor(executor); + var sinonPromise = new Promise(fakeExecutor); + + sinonPromise.status = STATUS_PENDING; + sinonPromise + .then(function (value) { + sinonPromise.status = STATUS_RESOLVED; + sinonPromise.resolvedValue = value; + }) + .catch(function (reason) { + sinonPromise.status = STATUS_REJECTED; + sinonPromise.rejectedValue = reason; + }); + + /** + * Resolves or rejects the promise with the given status and value. + * + * @param {string} status + * @param {*} value + * @param {Function} callback + */ + function finalize(status, value, callback) { + if (sinonPromise.status !== STATUS_PENDING) { + throw new Error(`Promise already ${sinonPromise.status}`); + } + + sinonPromise.status = status; + callback(value); + } + + sinonPromise.resolve = function (value) { + finalize(STATUS_RESOLVED, value, fakeExecutor.firstCall.args[0]); + // Return the promise so that callers can await it: + return sinonPromise; + }; + sinonPromise.reject = function (reason) { + finalize(STATUS_REJECTED, reason, fakeExecutor.firstCall.args[1]); + // Return a new promise that resolves when the sinon promise was + // rejected, so that callers can await it: + return new Promise(function (resolve) { + sinonPromise.catch(() => resolve()); + }); + }; + + return sinonPromise; +} + +module.exports = promise; diff --git a/test/promise-test.js b/test/promise-test.js new file mode 100644 index 000000000..8fb73e59e --- /dev/null +++ b/test/promise-test.js @@ -0,0 +1,225 @@ +"use strict"; + +var sinon = require("../lib/sinon.js"); +var { assert, refute } = require("@sinonjs/referee"); + +async function getPromiseStatus(promise) { + var status = "pending"; + var value = null; + promise + .then(function (val) { + status = "resolved"; + value = val; + }) + .catch(function (reason) { + status = "rejected"; + value = reason; + }); + await new Promise(function (resolve) { + setTimeout(resolve, 0); + }); + return { status, value }; +} + +describe("promise", function () { + context("with default executor", function () { + it("returns an unresolved promise", async function () { + var promise = sinon.promise(); + + var { status, value } = await getPromiseStatus(promise); + assert.equals(promise.toString(), "[object Promise]"); + assert.equals(status, "pending"); + assert.isNull(value); + assert.equals(promise.status, status); + assert.isUndefined(promise.resolvedValue); + assert.isUndefined(promise.rejectedValue); + }); + + it("resolves the promise", async function () { + var result = Symbol("promise result"); + var promise = sinon.promise(); + + var returnValue = promise.resolve(result); + + var { status, value } = await getPromiseStatus(promise); + assert.equals(status, "resolved"); + assert.same(value, result); + assert.equals(promise.status, status); + assert.same(promise.resolvedValue, result); + assert.isUndefined(promise.rejectedValue); + assert.same(returnValue, promise); + }); + + it("rejects the promise", async function () { + var error = new Error("promise error"); + var promise = sinon.promise(); + + var returnValue = promise.reject(error); + + var { status, value } = await getPromiseStatus(promise); + assert.equals(status, "rejected"); + assert.same(value, error); + assert.equals(promise.status, status); + assert.isUndefined(promise.resolvedValue); + assert.same(promise.rejectedValue, error); + refute.same(returnValue, promise); + assert.equals(returnValue.toString(), "[object Promise]"); + await assert.resolves(returnValue, undefined); + }); + + context("with resolved promise", function () { + var promise; + + beforeEach(function () { + promise = sinon.promise(); + promise.resolve(1); + }); + + it("fails to resolve again", function () { + assert.exception( + () => { + promise.resolve(2); + }, + { + name: "Error", + message: "Promise already resolved", + } + ); + }); + + it("fails to reject", function () { + assert.exception( + () => { + promise.reject(2); + }, + { + name: "Error", + message: "Promise already resolved", + } + ); + }); + }); + + context("with rejected promise", function () { + var promise; + + beforeEach(function () { + promise = sinon.promise(); + promise.reject(1); + }); + + it("fails to reject again", function () { + assert.exception( + () => { + promise.reject(2); + }, + { + name: "Error", + message: "Promise already rejected", + } + ); + }); + + it("fails to resolve", function () { + assert.exception( + () => { + promise.resolve(2); + }, + { + name: "Error", + message: "Promise already rejected", + } + ); + }); + }); + }); + + context("with custom executor", function () { + it("accepts a fake as the custom executor", function () { + var executor = sinon.fake(); + + sinon.promise(executor); + + assert.equals(executor.callCount, 1); + assert.equals(executor.firstCall.args.length, 2); + assert.isFunction(executor.firstCall.firstArg); + assert.isFunction(executor.firstCall.lastArg); + }); + + it("accepts a stub as the custom executor", function () { + var executor = sinon.stub(); + + sinon.promise(executor); + + assert.equals(executor.callCount, 1); + assert.equals(executor.firstCall.args.length, 2); + assert.isFunction(executor.firstCall.firstArg); + assert.isFunction(executor.firstCall.lastArg); + }); + + it("accepts a function as the custom executor", function () { + var args; + function executor(resolve, reject) { + args = [resolve, reject]; + } + + sinon.promise(executor); + + assert.equals(args.length, 2); + assert.isFunction(args[0]); + assert.isFunction(args[1]); + }); + + it("sets resolvedValue when custom executor resolves", async function () { + var result = Symbol("executor result"); + function executor(resolve) { + resolve(result); + } + + var promise = sinon.promise(executor); + + await assert.resolves(promise, result); + assert.equals(promise.status, "resolved"); + assert.same(promise.resolvedValue, result); + assert.isUndefined(promise.rejectedValue); + }); + + it("sets rejectedValue when custom executor fails", async function () { + var reason = new Error("executor failure"); + function executor(resolve, reject) { + reject(reason); + } + + var promise = sinon.promise(executor); + + await assert.rejects(promise, reason); + assert.equals(promise.status, "rejected"); + assert.same(promise.rejectedValue, reason); + assert.isUndefined(promise.resolvedValue); + }); + + it("resolves the promise", async function () { + var result = Symbol("promise result"); + var promise = sinon.promise(sinon.fake()); + + promise.resolve(result); + + await assert.resolves(promise, result); + assert.equals(promise.status, "resolved"); + assert.same(promise.resolvedValue, result); + assert.isUndefined(promise.rejectedValue); + }); + + it("rejects the promise", async function () { + var error = new Error("promise error"); + var promise = sinon.promise(sinon.fake()); + + promise.reject(error); + + await assert.rejects(promise, error); + assert.equals(promise.status, "rejected"); + assert.isUndefined(promise.resolvedValue); + assert.same(promise.rejectedValue, error); + }); + }); +});