Repository: WP-API/node-wpapi
Pull request #409 (2018-12-21 21:19:10)

Separate Superagent transport into optional module
The core of `WPAPI` is the query builder: this can be used with any HTTP library, or with native XHR or `fetch`. It seems odd therefore that we require superagent no matter how you intend to use the library.

This WIP / Experimental PR creates a new module you can use via `require( 'wpapi/superagent' )`, to pull in the WPAPI object configured with a Superagent-specific http transport object. This is a breaking change, because it means that by default `require( 'wpapi' )` will yield an object incapable of making HTTP requests; but it sets us up for a lot of flexibility down the road.

**Input welcome on this proposal, and more to follow after the holidays!**

Open questions:

- Do we continue to include `superagent` as a dependency, or make it a peerDependency or drop it as a dependency altogether, instead instructing users they must install it if they wish to require `wpapi/superagent`?
- Should this be `wpapi/superagent` (part of this repo), or a separate npm package `wpapi-superagent`? If the latter, it clarifies the dependency question somewhat.
- Should we include transport options using Axios and Fetch in the core package, as well?
- What gotcha's or issues might I be missing?


Patch:
diff --git a/README.md b/README.md
index 349408c..b010542 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ This library is an isomorphic client for the [WordPress REST API](http://develop
 
 - [About](#about)
 - [Installation](#installation)
+  - [Upgrading from v1](#upgrading-from-v1)
 - [Using the Client](#using-the-client)
   - [Auto-Discovery](#auto-discovery)
   - [Creating Posts](#creating-posts)
@@ -18,7 +19,7 @@ This library is an isomorphic client for the [WordPress REST API](http://develop
   - [Requesting Different Resources](#requesting-different-resources)
   - [API Query Parameters & Filtering Collections](#api-query-parameters)
   - [Uploading Media](#uploading-media)
-- [Custom Routes](#custom-routes)
+- [Querying Custom Routes](#custom-routes)
   - [Setter Method Naming](#setter-method-naming-for-named-route-components)
   - [Query Parameters & Filtering](#query-parameters--filtering-custom-routes)
   - [Mixins](#mixins)
@@ -35,7 +36,7 @@ This library is an isomorphic client for the [WordPress REST API](http://develop
 
 `node-wpapi` is an isomorphic JavaScript client for the [WordPress REST API](https://developer.wordpress.org/rest-api) that makes it easy for your JavaScript application to request specific resources from a [WordPress](https://wordpress.org) website. It uses a query builder-style syntax to let you craft the request being made to REST API endpoints, then returns the API's response to your application as a JSON object. And don't let the name fool you: with [Webpack](https://webpack.github.io/) or [Browserify](http://browserify.org/), `node-wpapi` works just as well in the browser as it does on the server!
 
-This library is maintained by K. Adam White at [Bocoup](https://bocoup.com), with contributions from a [great community](https://github.com/WP-API/node-wpapi/graphs/contributors) of WordPress and JavaScript developers.
+This library is maintained by K. Adam White at [Human Made](https://humanmade.com), with contributions from a [great community](https://github.com/WP-API/node-wpapi/graphs/contributors) of WordPress and JavaScript developers.
 
 To get started, `npm install wpapi` or [download the browser build](https://wp-api.github.io/node-wpapi/wpapi.zip) and check out "Installation" and "Using the Client" below.
 
@@ -53,24 +54,51 @@ To use the library from Node, install it with [npm](http://npmjs.org):
 npm install --save wpapi
 ```
 
-Then, within your application's script files, `require` the module to gain access to it:
+Then, within your application's script files, `require` the module to gain access to it. As `wpapi` is both a query builder and a transport layer (_i.e._ a tool for getting and sending HTTP requests), we leave it up to you as the author of your application whether you need both parts of this functionality. You may use `wpapi` with [superagent](https://www.npmjs.com/package/superagent) if you wish to send and receive HTTP requests using this library, but you may also use only the query builder part of the library if you intend to submit your HTTP requests with `fetch`, `axios` or other tools.
+
+To import only the query builder (without the `.get()`, `.create()`, `.delete()`, `.update()` or `.then()` chaining methods):
 
 ```javascript
 var WPAPI = require( 'wpapi' );
 ```
 
-This library is designed to work in the browser as well, via a build system such as Browserify or Webpack; just install the package and `require( 'wpapi' )` from your application code.
+To import the superagent bundle, which contains the full suite of HTTP interaction methods:
+
+```js
+var WPAPI = require( 'wpapi/superagent' );
+```
+
+This library is designed to work in the browser as well, via a build system such as Browserify or Webpack; just install the package and `require( 'wpapi' )` (or `'wpapi/superagent'`) from your application code.
 
 ### Download the UMD Bundle
 
 Alternatively, you may download a [ZIP archive of the bundled library code](https://wp-api.github.io/node-wpapi/wpapi.zip). These files are UMD modules, which may be included directly on a page using a regular `<script>` tag _or_ required via AMD or CommonJS module systems. In the absence of a module system, the UMD modules will export the browser global variable `WPAPI`, which can be used in place of `require( 'wpapi' )` to access the library from your code.
 
+At present this browser bundle tracks the `wpapi/superagent` module, and includes Superagent itself.
+
+### Upgrading from v1
+
+Prior to version 2.0 (currently `alpha` status) this library shipped with built-in HTTP functionality using Superagent.
+
+If you maintain an existing project which uses this library and wish to upgrade to v2, you may do so by manually installing Superagent:
+
+```sh
+npm i --save wpapi@alpha superagent
+```
+
+and then changing your `require` statements to use the `wpapi/superagent` entrypoint:
+
+```diff
+--- const WPAPI = require( 'wpapi' );
++++ const WPAPI = require( 'wpapi/superagent' );
+```
+
 ## Using the Client
 
 The module is a constructor, so you can create an instance of the API client bound to the endpoint for your WordPress install:
 
 ```javascript
-var WPAPI = require( 'wpapi' );
+var WPAPI = require( 'wpapi/superagent' );
 var wp = new WPAPI({ endpoint: 'http://src.wordpress-develop.dev/wp-json' });
 ```
 Once an instance is constructed, you can chain off of it to construct a specific request. (Think of it as a query-builder for WordPress!)
@@ -263,8 +291,11 @@ A WPAPI instance object provides the following basic request methods:
 * `wp.categories()...`: Get or create categories with the `/categories` endpoint
 * `wp.statuses()...`: Get resources within the `/statuses` endpoints
 * `wp.users()...`: Get resources within the `/users` endpoints
+* `wp.search()...`: Find resources of any [REST-enabled] post type matching a `?search=` string
 * `wp.media()...`: Get Media collections and objects from the `/media` endpoints
+* `wp.themes()...`: Read information about the active theme from the `/themes` endpoint (always requires authentication)
 * `wp.settings()...`: Read or update site settings from the `/settings` endpoint (always requires authentication)
+* `wp.blocks()...`: Create queries against the `blocks` endpoint
 
 All of these methods return a customizable request object. The request object can be further refined with chaining methods, and/or sent to the server via `.get()`, `.create()`, `.update()`, `.delete()`, `.headers()`, or `.then()`. (Not all endpoints support all methods; for example, you cannot POST or PUT records on `/types`, as these are defined in WordPress plugin or theme code.)
 
@@ -921,7 +952,7 @@ add_action( 'wp_enqueue_scripts', 'my_enqueue_scripts' );
 And then use this nonce when initializing the library:
 
 ```javascript
-var WPAPI = require( 'wpapi' );
+var WPAPI = require( 'wpapi/superagent' );
 var wp = new WPAPI({
     endpoint: window.WP_API_Settings.endpoint,
     nonce: window.WP_API_Settings.nonce
diff --git a/package.json b/package.json
index f386de0..f021a5d 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
     "url": "http://www.kadamwhite.com"
   },
   "name": "wpapi",
-  "version": "1.1.2",
+  "version": "2.0.0-alpha.1",
   "description": "An isomorphic JavaScript client for interacting with the WordPress REST API",
   "main": "wpapi.js",
   "repository": {
@@ -52,7 +52,7 @@
     "lint": "npm run eslint",
     "watch": "jest --watch",
     "test:all": "jest",
-    "test:unit": "jest tests/unit",
+    "test:unit": "jest tests/unit superagent/tests/unit",
     "test:integration": "jest tests/integration",
     "test:ci": "npm run eslint && jest tests/unit --coverage",
     "pretest": "npm run lint || true",
@@ -60,8 +60,10 @@
   },
   "dependencies": {
     "parse-link-header": "^1.0.1",
-    "qs": "^6.6.0",
-    "superagent": "^4.0.0"
+    "qs": "^6.6.0"
+  },
+  "optionalDependencies": {
+    "superagent": "^4.1.0"
   },
   "devDependencies": {
     "@babel/core": "^7.2.2",
diff --git a/superagent/README.md b/superagent/README.md
new file mode 100644
index 0000000..532f647
--- /dev/null
+++ b/superagent/README.md
@@ -0,0 +1,14 @@
+# `wpapi/superagent`
+
+This endpoint returns a version of the WPAPI library configured to use Superagent for HTTP requests.
+
+## Installation & Usage
+
+Install both `wpapi` and `superagent` using the command `npm install --save wpapi superagent`.
+
+```js
+import WPAPI from 'wpapi/superagent';
+
+// Configure and use WPAPI as normal
+const site = new WPAPI( { /* ... */ } );
+```
diff --git a/lib/http-transport.js b/superagent/http-transport.js
similarity index 97%
rename from lib/http-transport.js
rename to superagent/http-transport.js
index f9ad9dc..db65b20 100644
--- a/lib/http-transport.js
+++ b/superagent/http-transport.js
@@ -6,10 +6,10 @@
 const agent = require( 'superagent' );
 const parseLinkHeader = require( 'li' ).parse;
 
-const WPRequest = require( './constructors/wp-request' );
-const checkMethodSupport = require( './util/check-method-support' );
-const objectReduce = require( './util/object-reduce' );
-const isEmptyObject = require( './util/is-empty-object' );
+const WPRequest = require( '../lib/constructors/wp-request' );
+const checkMethodSupport = require( '../lib/util/check-method-support' );
+const objectReduce = require( '../lib/util/object-reduce' );
+const isEmptyObject = require( '../lib/util/is-empty-object' );
 
 /**
  * Set any provided headers on the outgoing request object. Runs after _auth.
diff --git a/superagent/index.js b/superagent/index.js
new file mode 100644
index 0000000..7aa1d70
--- /dev/null
+++ b/superagent/index.js
@@ -0,0 +1,19 @@
+const WPAPI = require( '../wpapi' );
+
+// Pull in superagent-based HTTP transport
+const httpTransport = require( './http-transport' );
+
+/**
+ * The HTTP transport methods object used by all WPAPI instances
+ *
+ * These methods may be extended or replaced on an instance-by-instance basis
+ *
+ * @memberof! WPAPI
+ * @static
+ * @property transport
+ * @type {Object}
+ */
+WPAPI.transport = Object.create( httpTransport );
+Object.freeze( WPAPI.transport );
+
+module.exports = WPAPI;
diff --git a/wpapi.js b/wpapi.js
index 602f17e..7859d0d 100644
--- a/wpapi.js
+++ b/wpapi.js
@@ -36,9 +36,6 @@ const autodiscovery = require( './lib/autodiscovery' );
 // Pull in base module constructors
 const WPRequest = require( './lib/constructors/wp-request' );
 
-// Pull in default HTTP transport
-const httpTransport = require( './lib/http-transport' );
-
 /**
  * Construct a REST API client instance object to create
  *
@@ -149,7 +146,9 @@ WPAPI.prototype.transport = function( transport ) {
 
 	// Create the default transport if it does not exist
 	if ( ! _options.transport ) {
-		_options.transport = Object.create( WPAPI.transport );
+		_options.transport = WPAPI.transport ?
+			Object.create( WPAPI.transport ) :
+			{};
 	}
 
 	// Whitelist the methods that may be applied
@@ -162,19 +161,6 @@ WPAPI.prototype.transport = function( transport ) {
 	return this;
 };
 
-/**
- * Default HTTP transport methods object for all WPAPI instances
- *
- * These methods may be extended or replaced on an instance-by-instance basis
- *
- * @memberof! WPAPI
- * @static
- * @property transport
- * @type {Object}
- */
-WPAPI.transport = Object.create( httpTransport );
-Object.freeze( WPAPI.transport );
-
 /**
  * Convenience method for making a new WPAPI instance
  *


Test patch:
diff --git a/superagent/tests/.eslintrc.js b/superagent/tests/.eslintrc.js
new file mode 100644
index 0000000..bd527d7
--- /dev/null
+++ b/superagent/tests/.eslintrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+	'env': {
+		jest: true,
+	},
+};
diff --git a/superagent/tests/unit/superagent.js b/superagent/tests/unit/superagent.js
new file mode 100644
index 0000000..80c3f6b
--- /dev/null
+++ b/superagent/tests/unit/superagent.js
@@ -0,0 +1,256 @@
+'use strict';
+
+const WPAPI = require( '../../' );
+
+// HTTP transport, for stubbing
+const httpTransport = require( '../../http-transport' );
+
+// Variable to use as our "success token" in promise assertions
+const SUCCESS = 'success';
+
+describe( 'WPAPI', () => {
+
+	describe( 'constructor', () => {
+
+		describe( 'assigns default HTTP transport', () => {
+
+			it( 'for GET requests', () => {
+				jest.spyOn( httpTransport, 'get' ).mockImplementation( () => {} );
+				const site = new WPAPI( {
+					endpoint: 'http://some.url.com/wp-json',
+				} );
+				const query = site.root( '' );
+				query.get();
+				expect( httpTransport.get ).toHaveBeenCalledWith( query, undefined );
+				httpTransport.get.mockRestore();
+			} );
+
+			it( 'for POST requests', () => {
+				jest.spyOn( httpTransport, 'post' ).mockImplementation( () => {} );
+				const site = new WPAPI( {
+					endpoint: 'http://some.url.com/wp-json',
+				} );
+				const query = site.root( '' );
+				const data = {};
+				query.create( data );
+				expect( httpTransport.post ).toHaveBeenCalledWith( query, data, undefined );
+				httpTransport.post.mockRestore();
+			} );
+
+			it( 'for POST requests', () => {
+				jest.spyOn( httpTransport, 'post' ).mockImplementation( () => {} );
+				const site = new WPAPI( {
+					endpoint: 'http://some.url.com/wp-json',
+				} );
+				const query = site.root( '' );
+				const data = {};
+				query.create( data );
+				expect( httpTransport.post ).toHaveBeenCalledWith( query, data, undefined );
+				httpTransport.post.mockRestore();
+			} );
+
+			it( 'for PUT requests', () => {
+				jest.spyOn( httpTransport, 'put' ).mockImplementation( () => {} );
+				const site = new WPAPI( {
+					endpoint: 'http://some.url.com/wp-json',
+				} );
+				const query = site.root( 'a-resource' );
+				const data = {};
+				query.update( data );
+				expect( httpTransport.put ).toHaveBeenCalledWith( query, data, undefined );
+				httpTransport.put.mockRestore();
+			} );
+
+			it( 'for DELETE requests', () => {
+				jest.spyOn( httpTransport, 'delete' ).mockImplementation( () => {} );
+				const site = new WPAPI( {
+					endpoint: 'http://some.url.com/wp-json',
+				} );
+				const query = site.root( 'a-resource' );
+				const data = {
+					force: true,
+				};
+				query.delete( data );
+				expect( httpTransport.delete ).toHaveBeenCalledWith( query, data, undefined );
+				httpTransport.delete.mockRestore();
+			} );
+
+		} );
+
+	} );
+
+	describe( '.transport constructor property', () => {
+
+		it( 'is defined', () => {
+			expect( WPAPI ).toHaveProperty( 'transport' );
+		} );
+
+		it( 'is an object', () => {
+			expect( typeof WPAPI.transport ).toBe( 'object' );
+		} );
+
+		it( 'has methods for each http transport action', () => {
+			expect( typeof WPAPI.transport.delete ).toBe( 'function' );
+			expect( typeof WPAPI.transport.get ).toBe( 'function' );
+			expect( typeof WPAPI.transport.head ).toBe( 'function' );
+			expect( typeof WPAPI.transport.post ).toBe( 'function' );
+			expect( typeof WPAPI.transport.put ).toBe( 'function' );
+		} );
+
+		it( 'is frozen (properties cannot be modified directly)', () => {
+			expect( () => {
+				WPAPI.transport.get = () => {};
+			} ).toThrow();
+		} );
+
+	} );
+
+	describe( '.discover() constructor method', () => {
+		let responses;
+
+		beforeEach( () => {
+			responses = {
+				head: {},
+				get: {},
+			};
+			responses.head.withLink = {
+				'content-type': 'text/html; charset=UTF-8',
+				link: '<http://mozarts.house/wp-json/>; rel="https://api.w.org/"',
+			};
+			responses.head.withoutLink = {
+				'content-type': 'text/html; charset=utf-8',
+			};
+			responses.get.withLink = {
+				headers: {
+					'content-type': 'text/html; charset=UTF-8',
+					link: '<http://mozarts.house/wp-json/>; rel="https://api.w.org/"',
+				},
+			};
+			responses.get.withoutLink = {
+				headers: {
+					'content-type': 'text/html; charset=UTF-8',
+				},
+			};
+			responses.apiRoot = {
+				name: 'Skip Beats',
+				descrition: 'Just another WordPress weblog',
+				routes: {
+					'list': {},
+					'of': {},
+					'routes': {},
+				},
+			};
+			// Stub HTTP methods
+			jest.spyOn( httpTransport, 'head' ).mockImplementation( () => {} );
+			jest.spyOn( httpTransport, 'get' ).mockImplementation( () => {} );
+			// Stub warn and error
+			jest.spyOn( global.console, 'warn' ).mockImplementation( () => {} );
+			jest.spyOn( global.console, 'error' ).mockImplementation( () => {} );
+		} );
+
+		afterEach( () => {
+			// Restore HTTP methods
+			httpTransport.head.mockRestore();
+			httpTransport.get.mockRestore();
+		} );
+
+		it( 'is a function', () => {
+			expect( WPAPI ).toHaveProperty( 'discover' );
+			expect( typeof WPAPI.discover ).toBe( 'function' );
+		} );
+
+		it( 'throws an error if no API endpoint can be discovered', () => {
+			const url = 'http://we.made.it/to/mozarts/house';
+			httpTransport.head.mockImplementationOnce( () => Promise.reject() );
+			httpTransport.get.mockImplementationOnce( () => Promise.reject( 'Some error' ) );
+			const prom = WPAPI.discover( url )
+				.catch( ( err ) => {
+					expect( global.console.error ).toHaveBeenCalledWith( 'Some error' );
+					expect( err.message ).toBe( 'Autodiscovery failed' );
+					return SUCCESS;
+				} );
+			return expect( prom ).resolves.toBe( SUCCESS );
+		} );
+
+		it( 'discovers the API root with a HEAD request', () => {
+			const url = 'http://mozarts.house';
+			httpTransport.head.mockImplementation( () => Promise.resolve( responses.head.withLink ) );
+			httpTransport.get.mockImplementation( () => Promise.resolve( responses.apiRoot ) );
+			const prom = WPAPI.discover( url )
+				.then( ( result ) => {
+					expect( result ).toBeInstanceOf( WPAPI );
+					expect( httpTransport.head ).toBeCalledTimes( 1 );
+					expect( httpTransport.get ).toBeCalledTimes( 1 );
+					expect( result.root().toString() ).toBe( 'http://mozarts.house/wp-json/' );
+					return SUCCESS;
+				} );
+			return expect( prom ).resolves.toBe( SUCCESS );
+		} );
+
+		it( 'throws an error if HEAD succeeds but no link is present', () => {
+			const url = 'http://we.made.it/to/mozarts/house';
+			httpTransport.head.mockImplementationOnce( () => Promise.resolve( responses.head.withoutLink ) );
+			const prom = WPAPI.discover( url )
+				.catch( ( err ) => {
+					expect( global.console.error ).toHaveBeenCalledWith(
+						new Error( 'No header link found with rel="https://api.w.org/"' )
+					);
+					expect( err.message ).toBe( 'Autodiscovery failed' );
+					return SUCCESS;
+				} );
+			return expect( prom ).resolves.toBe( SUCCESS );
+		} );
+
+		it( 'retries the initial site request as a GET if HEAD fails', () => {
+			const url = 'http://mozarts.house';
+			httpTransport.head.mockImplementation( () => Promise.reject() );
+			httpTransport.get.mockImplementationOnce( () => Promise.resolve( responses.get.withLink ) );
+			httpTransport.get.mockImplementationOnce( () => Promise.resolve( responses.apiRoot ) );
+			const prom = WPAPI.discover( url )
+				.then( ( result ) => {
+					expect( result ).toBeInstanceOf( WPAPI );
+					expect( httpTransport.head ).toBeCalledTimes( 1 );
+					expect( httpTransport.get ).toBeCalledTimes( 2 );
+					expect( result.root().toString() ).toBe( 'http://mozarts.house/wp-json/' );
+					return SUCCESS;
+				} );
+			return expect( prom ).resolves.toBe( SUCCESS );
+		} );
+
+		it( 'throws an error if GET retry succeeds but no link is present', () => {
+			const url = 'http://we.made.it/to/mozarts/house';
+			httpTransport.head.mockImplementation( () => Promise.reject() );
+			httpTransport.get.mockImplementationOnce( () => Promise.resolve( responses.get.withoutLink ) );
+			const prom = WPAPI.discover( url )
+				.catch( ( err ) => {
+					expect( global.console.error ).toHaveBeenCalledWith(
+						new Error( 'No header link found with rel="https://api.w.org/"' )
+					);
+					expect( err.message ).toBe( 'Autodiscovery failed' );
+					return SUCCESS;
+				} );
+			return expect( prom ).resolves.toBe( SUCCESS );
+		} );
+
+		it( 'returns WPAPI instance bound to discovered root even when route request errors', () => {
+			const url = 'http://mozarts.house';
+			httpTransport.head.mockImplementation( () => Promise.reject() );
+			httpTransport.get
+				.mockImplementationOnce( () => Promise.resolve( responses.get.withLink ) )
+				.mockImplementationOnce( () => Promise.reject( 'Some error' ) );
+			const prom = WPAPI.discover( url )
+				.then( ( result ) => {
+					expect( result ).toBeInstanceOf( WPAPI );
+					expect( httpTransport.head ).toBeCalledTimes( 1 );
+					expect( httpTransport.get ).toBeCalledTimes( 2 );
+					expect( global.console.error ).toHaveBeenCalledWith( 'Some error' );
+					expect( global.console.warn ).toHaveBeenCalledWith( 'Endpoint detected, proceeding despite error...' );
+					expect( result.root().toString() ).toBe( 'http://mozarts.house/wp-json/' );
+					return SUCCESS;
+				} );
+			return expect( prom ).resolves.toBe( SUCCESS );
+		} );
+
+	} );
+
+} );
diff --git a/tests/integration/autodiscovery.js b/tests/integration/autodiscovery.js
index ff77726..8e781be 100644
--- a/tests/integration/autodiscovery.js
+++ b/tests/integration/autodiscovery.js
@@ -1,6 +1,5 @@
 'use strict';
 
-const WPAPI = require( '../../' );
 const WPRequest = require( '../../lib/constructors/wp-request.js' );
 
 // Inspecting the titles of the returned posts arrays is an easy way to
@@ -17,7 +16,9 @@ const expectedResults = {
 	firstPostTitle: 'Markup: HTML Tags and Formatting',
 };
 
-describe( 'integration: discover()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: discover', ( transportName, WPAPI ) => {
 	let apiPromise;
 
 	beforeAll( () => {
diff --git a/tests/integration/categories.js b/tests/integration/categories.js
index 4a8e9ea..6c39c9e 100644
--- a/tests/integration/categories.js
+++ b/tests/integration/categories.js
@@ -1,6 +1,5 @@
 'use strict';
 
-const WPAPI = require( '../../' );
 const WPRequest = require( '../../lib/constructors/wp-request.js' );
 
 // Inspecting the names of the returned categories is an easy way to validate
@@ -48,7 +47,9 @@ const expectedResults = {
 	},
 };
 
-describe( 'integration: categories()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: categories()', ( transportName, WPAPI ) => {
 	let wp;
 
 	beforeEach( () => {
diff --git a/tests/integration/comments.js b/tests/integration/comments.js
index d9f401f..5d873d4 100644
--- a/tests/integration/comments.js
+++ b/tests/integration/comments.js
@@ -1,6 +1,5 @@
 'use strict';
 
-const WPAPI = require( '../../' );
 const WPRequest = require( '../../lib/constructors/wp-request.js' );
 
 // Variable to use as our 'success token' in promise assertions
@@ -62,7 +61,9 @@ const expectedResults = {
 const getPostsAndAuthors = comments => comments
 	.map( comment => comment.post + comment.author_name );
 
-describe( 'integration: comments()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: comments()', ( transportName, WPAPI ) => {
 	let wp;
 
 	beforeEach( () => {
diff --git a/tests/integration/custom-http-headers.js b/tests/integration/custom-http-headers.js
index 92bc0e3..a61ff03 100644
--- a/tests/integration/custom-http-headers.js
+++ b/tests/integration/custom-http-headers.js
@@ -1,7 +1,5 @@
 'use strict';
 
-const WPAPI = require( '../../' );
-
 // Inspecting the titles of the returned posts arrays is an easy way to
 // validate that the right page of results was returned
 const getTitles = require( '../helpers/get-rendered-prop' ).bind( null, 'title' );
@@ -11,7 +9,9 @@ const base64credentials = Buffer.from( `${ credentials.username }:${ credentials
 // Variable to use as our "success token" in promise assertions
 const SUCCESS = 'success';
 
-describe( 'integration: custom HTTP Headers', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: custom HTTP Headers', ( transportName, WPAPI ) => {
 	let wp;
 
 	beforeEach( () => {
diff --git a/tests/integration/custom-http-transport.js b/tests/integration/custom-http-transport.js
index aa482d8..2668709 100644
--- a/tests/integration/custom-http-transport.js
+++ b/tests/integration/custom-http-transport.js
@@ -1,15 +1,13 @@
 'use strict';
 
-const WPAPI = require( '../../' );
-
-const httpTransport = require( '../../lib/http-transport' );
-
 const credentials = require( '../helpers/constants' ).credentials;
 
 // Variable to use as our "success token" in promise assertions
 const SUCCESS = 'success';
 
-describe( 'integration: custom HTTP transport methods', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ), require( '../../superagent/http-transport' ) ],
+] )( '%s: custom HTTP transport methods', ( transportName, WPAPI, httpTransport ) => {
 	let wp;
 	let id;
 	let cache;
diff --git a/tests/integration/error-states.js b/tests/integration/error-states.js
index d55494a..aa266c8 100644
--- a/tests/integration/error-states.js
+++ b/tests/integration/error-states.js
@@ -1,11 +1,11 @@
 'use strict';
 
-const WPAPI = require( '../../' );
-
 // Variable to use as our "success token" in promise assertions
 const SUCCESS = 'success';
 
-describe( 'error states:', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: error states:', ( transportName, WPAPI ) => {
 
 	it( 'invalid root endpoint causes a transport-level (superagent) 404 error', () => {
 		const wp = WPAPI.site( 'http://wpapi.local/wrong-root-endpoint' );
diff --git a/tests/integration/media.js b/tests/integration/media.js
index e190005..41d450c 100644
--- a/tests/integration/media.js
+++ b/tests/integration/media.js
@@ -5,7 +5,6 @@ const objectReduce = require( '../../lib/util/object-reduce' );
 const httpTestUtils = require( '../helpers/http-test-utils' );
 const unique = require( '../../lib/util/unique' );
 
-const WPAPI = require( '../../' );
 const WPRequest = require( '../../lib/constructors/wp-request.js' );
 
 // Inspecting the titles of the returned posts arrays is an easy way to
@@ -57,7 +56,9 @@ const expectedResults = {
 	},
 };
 
-describe( 'integration: media()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: media()', ( transportName, WPAPI ) => {
 	let wp;
 	let authenticated;
 
diff --git a/tests/integration/pages.js b/tests/integration/pages.js
index bcf84d7..4fd75ac 100644
--- a/tests/integration/pages.js
+++ b/tests/integration/pages.js
@@ -1,6 +1,5 @@
 'use strict';
 
-const WPAPI = require( '../../' );
 const WPRequest = require( '../../lib/constructors/wp-request.js' );
 
 // Inspecting the titles of the returned posts arrays is an easy way to
@@ -39,7 +38,9 @@ const expectedResults = {
 	},
 };
 
-describe( 'integration: pages()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: pages()', ( transportName, WPAPI ) => {
 	let wp;
 
 	beforeEach( () => {
diff --git a/tests/integration/posts.js b/tests/integration/posts.js
index f01afd7..4df2df5 100644
--- a/tests/integration/posts.js
+++ b/tests/integration/posts.js
@@ -2,7 +2,6 @@
 
 const path = require( 'path' );
 
-const WPAPI = require( '../../' );
 const WPRequest = require( '../../lib/constructors/wp-request.js' );
 
 // Inspecting the titles of the returned posts arrays is an easy way to
@@ -54,7 +53,9 @@ const expectedResults = {
 	},
 };
 
-describe( 'integration: posts()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: posts()', ( transportName, WPAPI ) => {
 	let wp;
 	let authenticated;
 
diff --git a/tests/integration/settings.js b/tests/integration/settings.js
index 8ee90ae..3375ef9 100644
--- a/tests/integration/settings.js
+++ b/tests/integration/settings.js
@@ -1,13 +1,13 @@
 'use strict';
 
-const WPAPI = require( '../../' );
-
 const credentials = require( '../helpers/constants' ).credentials;
 
 // Variable to use as our "success token" in promise assertions
 const SUCCESS = 'success';
 
-describe( 'integration: settings()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: settings()', ( transportName, WPAPI ) => {
 	let wp;
 	let authenticated;
 
diff --git a/tests/integration/tags.js b/tests/integration/tags.js
index 6e179f5..8a0d4fb 100644
--- a/tests/integration/tags.js
+++ b/tests/integration/tags.js
@@ -1,6 +1,5 @@
 'use strict';
 
-const WPAPI = require( '../../' );
 const WPRequest = require( '../../lib/constructors/wp-request.js' );
 
 // Inspecting the names of the returned terms is an easy way to validate
@@ -53,7 +52,9 @@ const expectedResults = {
 	},
 };
 
-describe( 'integration: tags()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: tags()', ( transportName, WPAPI ) => {
 	let wp;
 
 	beforeEach( () => {
diff --git a/tests/integration/taxonomies.js b/tests/integration/taxonomies.js
index 9b2b7ce..11463f5 100644
--- a/tests/integration/taxonomies.js
+++ b/tests/integration/taxonomies.js
@@ -1,11 +1,11 @@
 'use strict';
 
-const WPAPI = require( '../../' );
-
 // Variable to use as our "success token" in promise assertions
 const SUCCESS = 'success';
 
-describe( 'integration: taxonomies()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: taxonomies()', ( transportName, WPAPI ) => {
 	let wp;
 
 	beforeEach( () => {
diff --git a/tests/integration/types.js b/tests/integration/types.js
index 1a05cdb..33e9742 100644
--- a/tests/integration/types.js
+++ b/tests/integration/types.js
@@ -1,11 +1,11 @@
 'use strict';
 
-const WPAPI = require( '../../' );
-
 // Variable to use as our "success token" in promise assertions
 const SUCCESS = 'success';
 
-describe( 'integration: types()', () => {
+describe.each( [
+	[ 'wpapi/superagent', require( '../../superagent' ) ],
+] )( '%s: types()', ( transportName, WPAPI ) => {
 	let wp;
 
 	beforeEach( () => {
diff --git a/tests/unit/wpapi.js b/tests/unit/wpapi.js
index 5b2d888..5beba16 100644
--- a/tests/unit/wpapi.js
+++ b/tests/unit/wpapi.js
@@ -5,12 +5,6 @@ const WPAPI = require( '../../' );
 // Constructors, for use with instanceof checks
 const WPRequest = require( '../../lib/constructors/wp-request' );
 
-// HTTP transport, for stubbing
-const httpTransport = require( '../../lib/http-transport' );
-
-// Variable to use as our "success token" in promise assertions
-const SUCCESS = 'success';
-
 describe( 'WPAPI', () => {
 
 	let site;
@@ -71,75 +65,17 @@ describe( 'WPAPI', () => {
 			expect( site._options.auth ).toBe( true );
 		} );
 
-		describe( 'assigns default HTTP transport', () => {
-
-			it( 'for GET requests', () => {
-				jest.spyOn( httpTransport, 'get' ).mockImplementation( () => {} );
-				const site = new WPAPI( {
-					endpoint: 'http://some.url.com/wp-json',
-				} );
-				const query = site.root( '' );
-				query.get();
-				expect( httpTransport.get ).toHaveBeenCalledWith( query, undefined );
-				httpTransport.get.mockRestore();
-			} );
-
-			it( 'for POST requests', () => {
-				jest.spyOn( httpTransport, 'post' ).mockImplementation( () => {} );
-				const site = new WPAPI( {
-					endpoint: 'http://some.url.com/wp-json',
-				} );
-				const query = site.root( '' );
-				const data = {};
-				query.create( data );
-				expect( httpTransport.post ).toHaveBeenCalledWith( query, data, undefined );
-				httpTransport.post.mockRestore();
-			} );
-
-			it( 'for POST requests', () => {
-				jest.spyOn( httpTransport, 'post' ).mockImplementation( () => {} );
-				const site = new WPAPI( {
-					endpoint: 'http://some.url.com/wp-json',
-				} );
-				const query = site.root( '' );
-				const data = {};
-				query.create( data );
-				expect( httpTransport.post ).toHaveBeenCalledWith( query, data, undefined );
-				httpTransport.post.mockRestore();
-			} );
-
-			it( 'for PUT requests', () => {
-				jest.spyOn( httpTransport, 'put' ).mockImplementation( () => {} );
-				const site = new WPAPI( {
-					endpoint: 'http://some.url.com/wp-json',
-				} );
-				const query = site.root( 'a-resource' );
-				const data = {};
-				query.update( data );
-				expect( httpTransport.put ).toHaveBeenCalledWith( query, data, undefined );
-				httpTransport.put.mockRestore();
-			} );
+		describe( 'custom HTTP transport methods', () => {
 
-			it( 'for DELETE requests', () => {
-				jest.spyOn( httpTransport, 'delete' ).mockImplementation( () => {} );
-				const site = new WPAPI( {
-					endpoint: 'http://some.url.com/wp-json',
-				} );
-				const query = site.root( 'a-resource' );
-				const data = {
-					force: true,
+			beforeEach( () => {
+				WPAPI.transport = {
+					get: jest.fn().mockImplementation( () => {} ),
+					post: jest.fn().mockImplementation( () => {} ),
+					put: jest.fn().mockImplementation( () => {} ),
 				};
-				query.delete( data );
-				expect( httpTransport.delete ).toHaveBeenCalledWith( query, data, undefined );
-				httpTransport.delete.mockRestore();
 			} );
 
-		} );
-
-		describe( 'custom HTTP transport methods', () => {
-
 			it( 'can be set for an individual HTTP action', () => {
-				jest.spyOn( httpTransport, 'get' ).mockImplementation( () => {} );
 				const customGet = jest.fn();
 				const site = new WPAPI( {
 					endpoint: 'http://some.url.com/wp-json',
@@ -150,12 +86,11 @@ describe( 'WPAPI', () => {
 				const query = site.root( '' );
 				query.get();
 				expect( customGet ).toHaveBeenCalledWith( query, undefined );
-				expect( httpTransport.get ).not.toHaveBeenCalled();
-				httpTransport.get.mockRestore();
+				expect( WPAPI.transport.get ).not.toHaveBeenCalled();
+				WPAPI.transport.get.mockRestore();
 			} );
 
 			it( 'can extend the default HTTP transport methods', () => {
-				jest.spyOn( httpTransport, 'get' ).mockImplementation( () => {} );
 				const customGet = jest.fn( ( ...args ) => {
 					WPAPI.transport.get.apply( null, args );
 				} );
@@ -168,13 +103,11 @@ describe( 'WPAPI', () => {
 				const query = site.root( '' );
 				query.get();
 				expect( customGet ).toHaveBeenCalledWith( query, undefined );
-				expect( httpTransport.get ).toHaveBeenCalledWith( query, undefined );
-				httpTransport.get.mockRestore();
+				expect( WPAPI.transport.get ).toHaveBeenCalledWith( query, undefined );
+				WPAPI.transport.get.mockRestore();
 			} );
 
 			it( 'can be set for multiple HTTP actions', () => {
-				jest.spyOn( httpTransport, 'post' ).mockImplementation( () => {} );
-				jest.spyOn( httpTransport, 'put' ).mockImplementation( () => {} );
 				const customPost = jest.fn();
 				const customPut = jest.fn();
 				const site = new WPAPI( {
@@ -188,16 +121,15 @@ describe( 'WPAPI', () => {
 				const data = {};
 				query.create( data );
 				expect( customPost ).toHaveBeenCalledWith( query, data, undefined );
-				expect( httpTransport.post ).not.toHaveBeenCalled();
+				expect( WPAPI.transport.post ).not.toHaveBeenCalled();
 				query.update( data );
 				expect( customPut ).toHaveBeenCalledWith( query, data, undefined );
-				expect( httpTransport.put ).not.toHaveBeenCalled();
-				httpTransport.post.mockRestore();
-				httpTransport.put.mockRestore();
+				expect( WPAPI.transport.put ).not.toHaveBeenCalled();
+				WPAPI.transport.post.mockRestore();
+				WPAPI.transport.put.mockRestore();
 			} );
 
 			it( 'only apply to a specific WPAPI instance', () => {
-				jest.spyOn( httpTransport, 'get' ).mockImplementation( () => {} );
 				const customGet = jest.fn();
 				const site = new WPAPI( {
 					endpoint: 'http://some.url.com/wp-json',
@@ -211,41 +143,15 @@ describe( 'WPAPI', () => {
 				expect( site ).not.toBe( site2 );
 				const query = site2.root( '' );
 				query.get();
-				expect( httpTransport.get ).toHaveBeenCalledWith( query, undefined );
+				expect( WPAPI.transport.get ).toHaveBeenCalledWith( query, undefined );
 				expect( customGet ).not.toHaveBeenCalled();
-				httpTransport.get.mockRestore();
+				WPAPI.transport.get.mockRestore();
 			} );
 
 		} );
 
 	} );
 
-	describe( '.transport constructor property', () => {
-
-		it( 'is defined', () => {
-			expect( WPAPI ).toHaveProperty( 'transport' );
-		} );
-
-		it( 'is an object', () => {
-			expect( typeof WPAPI.transport ).toBe( 'object' );
-		} );
-
-		it( 'has methods for each http transport action', () => {
-			expect( typeof WPAPI.transport.delete ).toBe( 'function' );
-			expect( typeof WPAPI.transport.get ).toBe( 'function' );
-			expect( typeof WPAPI.transport.head ).toBe( 'function' );
-			expect( typeof WPAPI.transport.post ).toBe( 'function' );
-			expect( typeof WPAPI.transport.put ).toBe( 'function' );
-		} );
-
-		it( 'is frozen (properties cannot be modified directly)', () => {
-			expect( () => {
-				WPAPI.transport.get = () => {};
-			} ).toThrow();
-		} );
-
-	} );
-
 	describe( '.site() constructor method', () => {
 
 		it( 'is a function', () => {
@@ -282,154 +188,6 @@ describe( 'WPAPI', () => {
 
 	} );
 
-	describe( '.discover() constructor method', () => {
-		let responses;
-
-		beforeEach( () => {
-			responses = {
-				head: {},
-				get: {},
-			};
-			responses.head.withLink = {
-				'content-type': 'text/html; charset=UTF-8',
-				link: '<http://mozarts.house/wp-json/>; rel="https://api.w.org/"',
-			};
-			responses.head.withoutLink = {
-				'content-type': 'text/html; charset=utf-8',
-			};
-			responses.get.withLink = {
-				headers: {
-					'content-type': 'text/html; charset=UTF-8',
-					link: '<http://mozarts.house/wp-json/>; rel="https://api.w.org/"',
-				},
-			};
-			responses.get.withoutLink = {
-				headers: {
-					'content-type': 'text/html; charset=UTF-8',
-				},
-			};
-			responses.apiRoot = {
-				name: 'Skip Beats',
-				descrition: 'Just another WordPress weblog',
-				routes: {
-					'list': {},
-					'of': {},
-					'routes': {},
-				},
-			};
-			// Stub HTTP methods
-			jest.spyOn( httpTransport, 'head' ).mockImplementation( () => {} );
-			jest.spyOn( httpTransport, 'get' ).mockImplementation( () => {} );
-			// Stub warn and error
-			jest.spyOn( global.console, 'warn' ).mockImplementation( () => {} );
-			jest.spyOn( global.console, 'error' ).mockImplementation( () => {} );
-		} );
-
-		afterEach( () => {
-			// Restore HTTP methods
-			httpTransport.head.mockRestore();
-			httpTransport.get.mockRestore();
-		} );
-
-		it( 'is a function', () => {
-			expect( WPAPI ).toHaveProperty( 'discover' );
-			expect( typeof WPAPI.discover ).toBe( 'function' );
-		} );
-
-		it( 'throws an error if no API endpoint can be discovered', () => {
-			const url = 'http://we.made.it/to/mozarts/house';
-			httpTransport.head.mockImplementationOnce( () => Promise.reject() );
-			httpTransport.get.mockImplementationOnce( () => Promise.reject( 'Some error' ) );
-			const prom = WPAPI.discover( url )
-				.catch( ( err ) => {
-					expect( global.console.error ).toHaveBeenCalledWith( 'Some error' );
-					expect( err.message ).toBe( 'Autodiscovery failed' );
-					return SUCCESS;
-				} );
-			return expect( prom ).resolves.toBe( SUCCESS );
-		} );
-
-		it( 'discovers the API root with a HEAD request', () => {
-			const url = 'http://mozarts.house';
-			httpTransport.head.mockImplementation( () => Promise.resolve( responses.head.withLink ) );
-			httpTransport.get.mockImplementation( () => Promise.resolve( responses.apiRoot ) );
-			const prom = WPAPI.discover( url )
-				.then( ( result ) => {
-					expect( result ).toBeInstanceOf( WPAPI );
-					expect( httpTransport.head ).toBeCalledTimes( 1 );
-					expect( httpTransport.get ).toBeCalledTimes( 1 );
-					expect( result.root().toString() ).toBe( 'http://mozarts.house/wp-json/' );
-					return SUCCESS;
-				} );
-			return expect( prom ).resolves.toBe( SUCCESS );
-		} );
-
-		it( 'throws an error if HEAD succeeds but no link is present', () => {
-			const url = 'http://we.made.it/to/mozarts/house';
-			httpTransport.head.mockImplementationOnce( () => Promise.resolve( responses.head.withoutLink ) );
-			const prom = WPAPI.discover( url )
-				.catch( ( err ) => {
-					expect( global.console.error ).toHaveBeenCalledWith(
-						new Error( 'No header link found with rel="https://api.w.org/"' )
-					);
-					expect( err.message ).toBe( 'Autodiscovery failed' );
-					return SUCCESS;
-				} );
-			return expect( prom ).resolves.toBe( SUCCESS );
-		} );
-
-		it( 'retries the initial site request as a GET if HEAD fails', () => {
-			const url = 'http://mozarts.house';
-			httpTransport.head.mockImplementation( () => Promise.reject() );
-			httpTransport.get.mockImplementationOnce( () => Promise.resolve( responses.get.withLink ) );
-			httpTransport.get.mockImplementationOnce( () => Promise.resolve( responses.apiRoot ) );
-			const prom = WPAPI.discover( url )
-				.then( ( result ) => {
-					expect( result ).toBeInstanceOf( WPAPI );
-					expect( httpTransport.head ).toBeCalledTimes( 1 );
-					expect( httpTransport.get ).toBeCalledTimes( 2 );
-					expect( result.root().toString() ).toBe( 'http://mozarts.house/wp-json/' );
-					return SUCCESS;
-				} );
-			return expect( prom ).resolves.toBe( SUCCESS );
-		} );
-
-		it( 'throws an error if GET retry succeeds but no link is present', () => {
-			const url = 'http://we.made.it/to/mozarts/house';
-			httpTransport.head.mockImplementation( () => Promise.reject() );
-			httpTransport.get.mockImplementationOnce( () => Promise.resolve( responses.get.withoutLink ) );
-			const prom = WPAPI.discover( url )
-				.catch( ( err ) => {
-					expect( global.console.error ).toHaveBeenCalledWith(
-						new Error( 'No header link found with rel="https://api.w.org/"' )
-					);
-					expect( err.message ).toBe( 'Autodiscovery failed' );
-					return SUCCESS;
-				} );
-			return expect( prom ).resolves.toBe( SUCCESS );
-		} );
-
-		it( 'returns WPAPI instance bound to discovered root even when route request errors', () => {
-			const url = 'http://mozarts.house';
-			httpTransport.head.mockImplementation( () => Promise.reject() );
-			httpTransport.get
-				.mockImplementationOnce( () => Promise.resolve( responses.get.withLink ) )
-				.mockImplementationOnce( () => Promise.reject( 'Some error' ) );
-			const prom = WPAPI.discover( url )
-				.then( ( result ) => {
-					expect( result ).toBeInstanceOf( WPAPI );
-					expect( httpTransport.head ).toBeCalledTimes( 1 );
-					expect( httpTransport.get ).toBeCalledTimes( 2 );
-					expect( global.console.error ).toHaveBeenCalledWith( 'Some error' );
-					expect( global.console.warn ).toHaveBeenCalledWith( 'Endpoint detected, proceeding despite error...' );
-					expect( result.root().toString() ).toBe( 'http://mozarts.house/wp-json/' );
-					return SUCCESS;
-				} );
-			return expect( prom ).resolves.toBe( SUCCESS );
-		} );
-
-	} );
-
 	describe( '.prototype', () => {
 
 		describe( '.namespace()', () => {
@@ -574,7 +332,6 @@ describe( 'WPAPI', () => {
 			} );
 
 			it( 'sets transport methods on the instance', () => {
-				jest.spyOn( httpTransport, 'get' );
 				const customGet = jest.fn();
 				site.transport( {
 					get: customGet,
@@ -583,8 +340,6 @@ describe( 'WPAPI', () => {
 				const query = site.root( '' );
 				query.get( cb );
 				expect( customGet ).toHaveBeenCalledWith( query, cb );
-				expect( httpTransport.get ).not.toHaveBeenCalled();
-				httpTransport.get.mockRestore();
 			} );
 
 			it( 'does not impact or overwrite unspecified transport methods', () => {