frisby
Version:
Frisby.js v2.0: REST API Endpoint Testing built on Jasmine
459 lines (390 loc) • 12 kB
JavaScript
'use strict';
// NPM
const _ = require('lodash');
const fetch = require('node-fetch');
const FormData = require('form-data');
const TIMEOUT_DEFAULT = 5000;
// Frisby
const FrisbyResponse = require('./response');
const expectHandlers = require('./expects');
class FrisbySpec {
constructor() {
this._fetch;
this._request;
this._response;
this._timeout;
this._setupDefaults = {};
}
/**
* Call function to do some setup for this spec/test
*/
use(fn) {
fn(this);
return this;
}
/**
* Setup defaults (probably from globalSetup(), but can be also be called per test)
*/
setup(opts, replace) {
this._setupDefaults = replace ? opts : _.merge(this._setupDefaults, opts);
return this;
}
/**
* Timeout getter/setter
*
* @param {number} timeout - Max. timeout in milliseconds
*/
timeout(timeout) {
// GETTER
if (!timeout) {
return this._timeout || (this._setupDefaults.request && this._setupDefaults.request.timeout) || TIMEOUT_DEFAULT;
}
// SETTER
this._timeout = timeout;
return this;
}
/**
* Load JSON directly for use
*
* @param {Object} json - JSON to use as HTTP response
*/
fromJSON(json) {
let jsonString = JSON.stringify(json);
// Prepare headers
let headers = new fetch.Headers();
headers.set('Content-Type', 'application/json');
// Prepare Response object
let fetchResponse = new fetch.Response(jsonString, {
url: '/',
status: 200,
statusText: 'OK',
headers: headers,
size: _.isUndefined(jsonString) ? 0 : jsonString.length,
timeout: 0
});
// Resolve as promise
this._fetch = Promise.resolve(fetchResponse)
.then(response => {
this._response = new FrisbyResponse(response);
this._response._responseTimeMs = 0;
return response.text()
.then(text => {
let response = this._response;
response._body = text;
if (text.length > 0) {
response._json = JSON.parse(text);
}
});
})
.then(() => {
return this._response;
});
return this;
}
getBaseUrl() {
return this._setupDefaults.request && this._setupDefaults.request.baseUrl ? this._setupDefaults.request.baseUrl : false;
}
_formatUrl(url, urlEncode = true) {
let baseUrl = this.getBaseUrl();
let newUrl = urlEncode && _.isString(url) ? encodeURI(url) : url;
// Legacy urlObject cannot be set to URL.
newUrl = newUrl.href ? newUrl.href : newUrl;
return baseUrl ? new URL(newUrl, baseUrl) : new URL(newUrl);
}
_fetchParams(params = {}) {
let fetchParams = _.cloneDeep(this._setupDefaults.request);
// Form handling - send correct form headers
if (params.body instanceof FormData) {
delete fetchParams.headers['Content-Type'];
}
return _.merge(fetchParams, params);
}
/**
* Fetch given URL with params (passthru to 'fetch' API)
*/
fetch(url, params = {}, options = {}) {
let fetchParams = this._fetchParams(params);
this._request = new fetch.Request(this._formatUrl(url, options.urlEncode), fetchParams);
let requestStartTime = Date.now();
this._fetch = fetch(this._request, { timeout: this.timeout() }) // 'timeout' is a node-fetch option
.then(response => {
this._response = new FrisbyResponse(response);
this._response._responseTimeMs = Date.now() - requestStartTime;
if (this._setupDefaults.request && this._setupDefaults.request.rawBody) {
return response.arrayBuffer()
.then(buffer => {
this._response._body = buffer;
});
}
return response.textConverted()
.then(text => {
let response = this._response;
response._body = text;
// Auto-parse JSON
if (response.headers.has('Content-Type') && response.headers.get('Content-Type').includes('json') && response.status !== 204 && text.length > 0) {
try {
response._json = JSON.parse(text);
} catch(e) {
return Promise.reject(new TypeError(`Invalid json response body: '${text}' at ${this._request.url} reason: '${e.message}'`));
}
}
});
})
.then(() => {
return this._response;
});
return this;
}
/**
* GET convenience wrapper
*/
get(url, params) {
return this.fetch(url, params);
}
/**
* HEAD convenience wrapper
*/
head(url, params = {}) {
return this.fetch(url, Object.assign(params, {method: 'HEAD'}));
}
/**
* OPTIONS convenience wrapper
*/
options(url, params = {}) {
return this.fetch(url, Object.assign(params, {method: 'OPTIONS'}));
}
/**
* PATCH convenience wrapper
*/
patch(url, params) {
return this._requestWithBody('PATCH', url, params);
}
/**
* POST convenience wrapper
*/
post(url, params) {
return this._requestWithBody('POST', url, params);
}
/**
* PUT convenience wrapper
*/
put(url, params) {
return this._requestWithBody('PUT', url, params);
}
/**
* DELETE convenience wrapper
*/
del(url, params) {
return this._requestWithBody('DELETE', url, params);
}
delete(url, params) {
return this.del(url, params);
}
/**
*
*/
_requestWithBody(method, url, params) {
let postParams = { method };
// Auto-encode JSON body if NOT FormData
if (params && _.isObject(params.body)) {
if (!(params.body instanceof FormData)) {
params.body = JSON.stringify(params.body);
}
}
// Auto-set 'body' from 'params' JSON if 'body' and 'headers' are not provided (assume sending raw body only)
if (params && _.isUndefined(params.body) && _.isUndefined(params.headers)) {
postParams.body = JSON.stringify(params);
}
return this.fetch(url, Object.assign(postParams, params || {}));
}
/**
* Chain calls to execute after fetch()
*/
then(onFulfilled, onRejected) {
if (onFulfilled instanceof FrisbySpec) {
return onFulfilled;
}
this._ensureHasFetched();
this._fetch = this._fetch.then(response => {
let result = onFulfilled ? onFulfilled(response) : null;
if (result) {
return result;
} else {
return response;
}
}, err => onRejected ? onRejected(err) : Promise.reject(err));
return this;
}
/**
* Used for 'done' function in Jasmine async tests
* Ensures any errors get pass
*/
done(doneFn) {
this._ensureHasFetched();
this._fetch = this._fetch.then(() => doneFn());
return this;
}
/**
* Custom error handler (Promise catch)
*/
catch(onRejected) {
this._ensureHasFetched();
this._fetch = this._fetch.catch(err => onRejected ? onRejected(err) : Promise.reject(err));
return this;
}
/**
* Custom finally handler (Promise finally)
*/
finally(onFinally) {
this._ensureHasFetched();
if (_.isFunction(this._fetch.finally)) {
this._fetch = this._fetch.finally(() => onFinally ? onFinally() : undefined);
} else {
// Return Promise.reject from finally handler is not supported.
this._fetch = this._fetch.then(value => {
if (onFinally)
onFinally();
return value;
}, reason => {
if (onFinally)
onFinally();
return Promise.reject(reason);
});
}
return this;
}
/**
* Return internal promise used by Frisby.js
* Note: Using this will break the chainability of Frisby.js method calls
*/
promise() {
return this._fetch;
}
/**
* Inspectors (to inspect data that the test is returning)
* ==========================================================================
*/
inspectResponse() {
return this.then(() => { this.inspectLog('\nResponse:', this._response); });
}
inspectRequest() {
return this.then(() => { this.inspectLog('\nRequest:', this._request); });
}
inspectRequestHeaders() {
return this.then(() => {
this.inspectLog('\nRequest Headers:');
let headers = this._request.headers.raw();
for (let key in headers) {
this.inspectLog(`\t${key}: ${headers[key]}`);
}
});
}
inspectBody() {
return this.then(() => { this.inspectLog('\nBody:', this._response.body); });
}
inspectJSON() {
return this.then(() => { this.inspectLog('\nJSON:', JSON.stringify(this._response.json, null, 4)); });
}
inspectStatus() {
return this.then(() => { this.inspectLog('\nStatus:', this._response.status); });
}
inspectHeaders() {
return this.then(() => {
this.inspectLog('\nResponse Headers:');
let headers = this._response.headers.raw();
for (let key in headers) {
this.inspectLog(`\t${key}: ${headers[key]}`);
}
});
}
inspectLog(...args) {
console.log.call(null, ...args); // eslint-disable-line no-console
return this;
}
_inspectOnFailure() {
if (this._setupDefaults.request && this._setupDefaults.request.inspectOnFailure) {
if (this._response) {
let response = this._response;
if (response.json) {
this.inspectLog('\nFAILURE Status:', response.status, '\nJSON:', JSON.stringify(response.json, null, 4));
} else {
this.inspectLog('\nFAILURE Status:', response.status, '\nBody:', response.body);
}
}
}
}
/**
* Expectations (wrappers around Jasmine methods)
* ==========================================================================
*/
/**
* Add expectation for current test (expects)
*/
expect(expectName) {
let expectArgs = Array.prototype.slice.call(arguments).slice(1);
return this.then(this._getExpectRunner(expectName, expectArgs, true));
}
/**
* Add negative expectation for current test (expects.not)
*/
expectNot(expectName) {
let expectArgs = Array.prototype.slice.call(arguments).slice(1);
return this.then(this._getExpectRunner(expectName, expectArgs, false));
}
/**
* Private methods (not meant to be part of the public API, and NOT to be
* relied upon by consuming code - these names may change!)
* ==========================================================================
*/
/**
* Used internally for expect and expectNot to return expectation function and then run it
*/
_getExpectRunner(expectName, expectArgs, expectPass) {
let expectHandler;
if (_.isFunction(expectName)) {
expectHandler = expectName;
} else {
expectHandler = expectHandlers[expectName];
if (typeof expectHandler === 'undefined') {
throw new Error("Expectation '" + expectName + "' is not defined.");
}
}
return response => {
let didFail = false;
try {
expectHandler.apply(this, [response].concat(expectArgs));
} catch(e) {
didFail = true;
// Re-throw error if pass is expected; else bury it
if (expectPass === true) {
this._inspectOnFailure();
throw e;
}
}
if (!expectPass && !didFail) {
this._inspectOnFailure();
let fnArgs = expectArgs.map(a => a.toString()).join(', ');
throw new Error(`expectNot('${expectName}', ${fnArgs}) passed and was supposed to fail`);
}
};
}
/**
* Ensure fetch() has been called already
*/
_ensureHasFetched() {
if (typeof this._fetch === 'undefined') {
throw new Error('Frisby spec not started. You must call fetch() first to begin a Frisby test.');
}
}
/**
* Static methods (mainly ones that affect all Frisby tests)
* ==========================================================================
*/
static addExpectHandler(expectName, expectFn) {
expectHandlers[expectName] = expectFn;
}
static removeExpectHandler(expectName) {
delete expectHandlers[expectName];
}
}
module.exports = FrisbySpec;