frisby
Version:
Frisby.js v2.0: REST API Endpoint Testing built on Jasmine
415 lines (342 loc) • 10 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._expects = [];
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: jsonString.length,
timeout: 0
});
this._response = new FrisbyResponse(fetchResponse);
// Resolve as promise
this._fetch = fetch.Promise.resolve(fetchResponse)
.then(response => response.json())
.then(responseBody => {
this._response._body = responseBody;
this._runExpects();
return this._response;
});
return this;
}
getBaseUrl() {
return this._setupDefaults.request.baseUrl ? this._setupDefaults.request.baseUrl : false;
}
_formatUrl(url, urlEncode = true) {
let newUrl = urlEncode ? encodeURI(url) : url;
let baseUrl = this.getBaseUrl();
// Prepend baseUrl if set, and if URL supplied is a path
if (url.startsWith('/') && baseUrl) {
newUrl = baseUrl + url;
}
return newUrl;
}
_fetchParams(params = {}) {
let fetchParams = _.merge({}, this._setupDefaults.request, params);
// Form handling - send correct form headers
if (params.body instanceof FormData) {
fetchParams.headers = _.merge(fetchParams.headers, fetchParams.body.getHeaders());
}
return fetchParams;
}
/**
* 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);
this._fetch = fetch(this._request, { timeout: this.timeout() }) // 'timeout' is a node-fetch option
.then(response => {
this._response = new FrisbyResponse(response);
// Auto-parse JSON
if (response.headers.has('Content-Type') && response.headers.get('Content-Type').includes('json') && response.status !== 204) {
return response.json();
}
return response.text();
}).then(responseBody => {
this._response._body = responseBody;
this._runExpects();
return this._response;
});
return this;
}
/**
* GET convenience wrapper
*/
get(url, params) {
return this.fetch(url, params);
}
/**
* 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) {
params = params || {};
params.method = 'delete';
return this.fetch(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(fn) {
if (fn instanceof FrisbySpec) {
return fn;
}
this._ensureHasFetched();
this._fetch = this._fetch.then(response => {
let result = fn(response);
if (result) {
return result;
} else {
return response;
}
});
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(fn) {
this._ensureHasFetched();
this._fetch = this._fetch.catch(err => fn(err));
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;
}
/**
* Run test expectations
*/
_runExpects() {
// Run all expectations
for(let i = 0; i < this._expects.length; i++) {
this._expects[i].call(this, this._response);
}
return this;
}
/**
* 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("\n");
this.inspectLog('Request 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.body, null, 4)); });
}
inspectStatus() {
return this.then(() => { this.inspectLog("\nStatus:", this._response.status); });
}
inspectHeaders() {
return this.then(() => {
this.inspectLog("\n");
this.inspectLog('Response Headers:');
let headers = this._response.headers.raw();
for (let key in headers) {
this.inspectLog("\t" + key + ': ' + headers[key]);
}
});
}
inspectLog() {
let params = Array.prototype.slice.call(arguments);
console.log.apply(null, params); // eslint-disable-line no-console
return this;
}
/**
* Expectations (wrappers around Jasmine methods)
* ==========================================================================
*/
/**
* Add expectation for current test (expects)
*/
expect(expectName) {
let expectArgs = Array.prototype.slice.call(arguments).slice(1);
return 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._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 add expectations and then run them
*/
_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 this._addExpect(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) {
throw e;
}
}
if (!expectPass && !didFail) {
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.');
}
}
/**
* Add expectation to execute after HTTP call is done
*/
_addExpect(fn) {
this._expects.push(fn);
return this;
}
/**
* 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;