Article source: github.com/Haixiang612…
Reference the wheels: www.npmjs.com/package/sup…
Supertest is a short and elegant interface test tool, such as a login interface test for example:
import request from 'supertest'
it('Login successful'.() = > {
request('https://127.0.0.1:8080')
.post('/login')
.send({ username: 'HaiGuai'.password: '123456' })
.expect(200)})Copy the code
The whole use case is very simple and understandable in feel. The library is small, well designed and written by TJ Holowaychuk! Today we will take you to realize a supertest wheel, do a test framework!
Train of thought
Before writing the code, design the entire framework based on the classic example above.
As can be seen from the above example, sending the request, processing the request, and expecting the result constitute the link of the whole framework and the life cycle of a use case.
request -> process -> expect(200)
Copy the code
The request step can be implemented by third-party HTTP libraries such as AXIos, Node-FETCH, and SuperAgent.
The process step is left out of business code, and finally Expect can use node.js’s assert library to execute assertions. So let’s focus on how to enforce these assertions.
The last expect step is the whole heart of our framework, and it’s all about managing all assertions, because developers are likely to execute assertions as many times as the following:
xxx
.expect(1 + 1, 2)
.expect(200)
.expect({ result: 'success'})
.expect((res) => console.log(res))
Copy the code
Therefore, we need an array this._implies = [] to hold these assertions, and then provide an end() function for the last one-time execution of these assertions:
Expect ({result: 'success'}). Expect ((res) => console.log(res)).end(Copy the code
It’s a bit like the event center, except that each expect is like adding a listener to the “expect” event, and the end is like triggering the “Expect” event, executing all the listeners.
We also note that some Expect functions may be used to check the status code, some for the returned body and some for the headers, so every time we call the Expect function, in addition to pushing the assertion callback to this. _implies, Also determine whether the assertion callback pushed is for a HEADERS, body, or status assertion.
The above ideas are sorted out as follows:
We only need to focus on the yellow and red parts.
Simple implementation
The “send request” step can be done by the third party library, here we choose superagent as the send NPM package, because the use of this library is also the chain call is more suitable for our expectations, for example:
superagent
.post('/api/pet')
.send({ name: 'Manny'.species: 'cat' }) // sends a JSON post body
.set('X-API-Key'.'foobar')
.set('accept'.'json')
.end((err, res) = > {
// Calling the end function will send the request
});
Copy the code
This is too similar! This gives us some inspiration: based on superagent, add the above expect to superagent, and then rewrite the end and restful HTTP functions. “Based on XX, rewrite methods and add your own methods”, what comes to mind? Inheritance! Superagent provides the Request class, so we just need to inherit it, rewrite methods and add Expect functions!
A simple Request subclass implements the following (regardless of the assertion callback distinction, just do a simple Equals callback) :
import {Request} from 'superagent'
import assert from 'assert'
function Test(url, method, path) {
// Send the request
Request.call(this, method.toUpperCase(), path)
this.url = url + path // Request path
this._asserts = [] / / an Assertion queue
}
/ / Request
Object.setPrototypeOf(Test.prototype, Request.prototype)
/** * .expect(1 + 1, 2) */
Test.prototype.expect = function(a, b) {
this._asserts.push(this.equals.bind(this, a, b))
return this
}
// Check whether the two values are equal
Test.prototype.equals = function(a, b) {
try {
assert.strictEqual(a, b)
} catch (err) {
return new Error(` I want${a}But you gave it to me${b}`)}}// Execute all assertions
Test.prototype.assert = function(err, res, fn) {
let errorObj = null
for (let i = 0; i < this._asserts.length; i++) {
errorObj = this._asserts[i](res)
}
fn(errorObj)
}
// Aggregate all Assertion results
Test.prototype.end = function (fn) {
const self = this
const end = Request.prototype.end
end.call(this.function(err, res) {
self.assert(err, res, fn)
})
return this
}
Copy the code
The above inherits the Request parent, provides expect, equals, and assert functions, and overwrites the end function. This is just our own Test class. It is best to provide a Request function externally:
import methods from 'methods'
import http from 'http'
import Test from './Test'
function request(path) {
const obj = {}
methods.forEach(function(method) {
obj[method] = function(url) {
return new Test(path, method, url)
}
})
obj.del = obj.delete
return obj
}
Copy the code
Methods the NPM package returns all restful function names, such as POST, GET, and so on. Add these restful functions to the newly created object, create the Test object by passing in the corresponding path, method, and URL, and then indirectly create an HTTP request to complete the “send request” step.
Then we can use our framework like this:
it('should be supported'.function (done) {
const app = express();
let s;
app.get('/'.function (req, res) {
res.send('hello');
});
s = app.listen(function () {
const url = 'http://localhost:' + s.address().port;
request(url)
.get('/')
.expect(1 + 1.1)
.end(done);
});
});
Copy the code
Creating a server
Listen (app.listen, app.listen, app.listen, app.listen, app.listen) Such as:
it('should fire up the app on an ephemeral port'.function (done) {
const app = express();
app.get('/'.function (req, res) {
res.send('hey');
});
request(app)
.get('/')
.end(function (err, res) {
expect(res.status).toEqual(200)
expect(res.text).toEqual('hey')
done();
});
});
Copy the code
First, we check in the request function to create a server if the app function is passed in.
function request(app) {
const obj = {}
if (typeof app === 'function') {
app = http.createServer(app) // Create an internal server
}
methods.forEach(function(method) {
obj[method] = function(url) {
return new Test(app, method, url)
}
})
obj.del = obj.delete
return obj
}
Copy the code
The constructor of the Test class can also get the corresponding path and listen for port 0:
function Test(app, method, path) {
// Send the request
Request.call(this, method.toUpperCase(), path)
this.redirects(0) // Disable redirection
this.app = app // app/string
this.url = typeof app === 'string' ? app + path : this.serverAddress(app, path) // Request path
this._asserts = [] / / an Assertion queue
}
// Get the request path through app
Test.prototype.serverAddress = function(app, path) {
if(! app.address()) {this._server = app.listen(0) / / internal server
}
const port = app.address().port
const protocol = app instanceof https.Server ? 'https' : 'http'
return `${protocol}: / / 127.0.0.1:${port}${path}`
}
Copy the code
Finally, close the newly created server in the end function:
// Aggregate all Assertion results
Test.prototype.end = function (fn) {
const self = this
const server = this._server
const end = Request.prototype.end
end.call(this.function(err, res) {
if (server && server._handle) return server.close(localAssert)
localAssert()
function localAssert() {
self.assert(err, res, fn)
}
})
return this
}
Copy the code
Encapsulate error information
Again, let’s see how we handle assertions: failed assertions go to the catch statement and return an Error, which is passed to the FN callback parameter of end(fn). There is a problem, however, when we look at the error stack:
The error messages are as expected, but the error stack is not so friendly: the first three lines locate in our own frame code! Imagine if someone made an error with our library Expect, clicked the error stack result, and found out that they had located our source code? Therefore, we need to modify the Error err.stack:
// Wrap the original function to provide a more elegant error stack
function wrapAssertFn(assertFn) {
// Keep the last 3 lines
const savedStack = new Error().stack.split('\n').slice(3)
return function(res) {
const err = assertFn(res)
if (err instanceof Error && err.stack) {
// Get rid of line 1
const badStack = err.stack.replace(err.message, ' ').split('\n').slice(1)
err.stack = [err.toString()]
.concat(savedStack)
.concat('-- -- -- -- -- -- -- --)
.concat(badStack)
.join('\n')}return err
}
}
Test.prototype.expect = function(a, b) {
this._asserts.push(wrapAssertFn(this.equals.bind(this, a, b)))
return this
}
Copy the code
Above first remove the first 3 lines of the current call stack, that is, the first 3 lines of the above screenshot, because this is the source code error, there will be interference to the developer, and the stack can help developers directly locate the cold expect. Of course, we also display the actual source code error as badStack, but with ‘——‘ as the partition, the final error result is as follows:
Distinguish between assertion callbacks
Now that we have just implemented the simplest assertions using equal, let’s add headers, status, and body assertions. A simple implementation of their assertion functions looks like this:
import util from "util";
import assert from 'assert'
// Determine whether the current status code is equal
Test.prototype._assertStatus = function(status, res) {
if(status ! == res.status) {const expectStatusContent = http.STATUS_CODES[status]
const actualStatusContent = http.STATUS_CODES[res.status]
return new Error('expected ' + status + '"' + expectStatusContent + '", got ' + res.status + '"' + actualStatusContent + '"')}}// Determine whether the current body is equal
// Determine whether the current body is equal
Test.prototype._assertBody = function(body, res) {
const isRegExp = body instanceof RegExp
if (typeof body === 'object' && !isRegExp) { // Compare the normal body
try {
assert.deepStrictEqual(body, res.body)
} catch (err) {
const expectBody = util.inspect(body)
const actualBody = util.inspect(res.body)
return error('expected ' + expectBody + ' response body, got '+ actualBody, body, res.body); }}else if(body ! == res.text) {// Compare normal text content
const expectBody = util.inspect(body)
const actualBody = util.inspect(res.text)
if (isRegExp) {
if(! body.test(res.text)) {// Body is a regular expression case
return error('expected body ' + actualBody + ' to match '+ body, body, res.body); }}else {
return error(`expected ${expectBody} response body, got ${actualBody}`, body, res.body)
}
}
}
// Check whether the current header is equal
Test.prototype._assertHeader = function(header, res) {
const field = header.name
const actualValue = res.header[field.toLowerCase()]
const expectValue = header.value
// Field does not exist
if (typeof actualValue === 'undefined') {
return new Error('expected "' + field + '" header field');
}
// It is the same case
if ((Array.isArray(actualValue) && actualValue.toString() === expectValue) || actualValue === expectValue) {
return
}
// Check the re case
if (expectValue instanceof RegExp) {
if(! expectValue.test(actualValue)) {return new Error('expected "' + field + '" matching ' + expectValue + ', got "' + actualValue + '"')}}else {
return new Error('expected "' + field + '" of "' + expectValue + '", got "' + actualValue + '"')}}// Optimize the error display
function error(msg, expected, actual) {
const err = new Error(msg)
err.expected = expected
err.actual = actual
err.showDiff = true
return err
}
Copy the code
Then select the corresponding _assertXXX function from the expect function by determining the parameter type:
/** * .expect(200) * .expect(200, fn) * .expect(200, body) * .expect('Some body') * .expect('Some body', fn) * .expect('Content-Type', 'application/json') * .expect('Content-Type', 'application/json', fn) * .expect(fn) */
Test.prototype.expect = function(a, b, c) {
/ / callback
if (typeof a === 'function') {
this._asserts.push(wrapAssertFn(a))
return this
}
if (typeof b === 'function') this.end(b)
if (typeof c === 'function') this.end(c)
/ / status code
if (typeof a === 'number') {
this._asserts.push(wrapAssertFn(this._assertStatus.bind(this, a)))
// body
if (typeofb ! = ='function' && arguments.length > 1) {
this._asserts.push(wrapAssertFn(this._assertBody.bind(this, b)))
}
return this
}
// header
if (typeof b === 'string' || typeof b === 'number' || b instanceof RegExp) {
this._asserts.push(wrapAssertFn(this._assertHeader.bind(this, { name: ' ' + a, value: b })))
return this
}
// body
this._asserts.push(wrapAssertFn(this._assertBody.bind(this, a)))
return this
}
Copy the code
At this point, we have completed the basic assertion functionality.
Handling network errors
Sometimes errors are thrown not because of business code errors, but because of exceptions such as network outages. We also need to handle these errors and show them to developers in a more user-friendly way, by modifying the Assert function:
// Execute all assertions
Test.prototype.assert = function(resError, res, fn) {
// Common network error
const sysErrors = {
ECONNREFUSED: 'Connection refused'.ECONNRESET: 'Connection reset by peer'.EPIPE: 'Broken pipe'.ETIMEDOUT: 'Operation timed out'
};
let errorObj = null
// Handle the returned error
if(! res && resError) {if (resError instanceof Error && resError.syscall === 'connect' && sysErrors[resError.code]) {
errorObj = new Error(resError.code + ':' + sysErrors[resError.code])
} else {
errorObj = resError
}
}
// Execute all assertions
for (let i = 0; i < this._asserts.length && ! errorObj; i++) { errorObj =this._assertFunction(this._asserts[i], res)
}
// Handle superagent errors
if(! errorObj && resErrorinstanceof Error&& (! res || resError.status ! == res.status)) { errorObj = resError } fn.call(this, errorObj || null, res)
}
Copy the code
So far, status, body, and headers assertions have been implemented, and expect uses all three assertion callbacks reasonably, while handling network exceptions.
The Agent Agent
Let’s review how we used frameworks to write test cases:
it('should handle redirects'.function (done) {
const app = express();
app.get('/login'.function (req, res) {
res.end('Login');
});
app.get('/'.function (req, res) {
res.redirect('/login');
});
request(app)
.get('/')
.redirects(1)
.end(function (err, res) {
expect(res).toBeTruthy()
expect(res.status).toEqual(200)
expect(res.text).toEqual('Login')
done();
});
});
Copy the code
As you can see, each call to the request function creates a server immediately inside, and then shuts it down immediately after the call to end. Continuous testing is very expensive and can easily share a server. Can we use A_Server for series A cases and B_Server for series B cases?
In addition to the Request class, SuperAgent also provides a powerful Agent class to address these requirements. TestAgent class TestAgent class TestAgent class TestAgent
import http from 'http'
import methods from 'methods'
import {agent as Agent} from 'superagent'
import Test from './Test'
function TestAgent(app, options) {
// Call TestAgent(app, options)
if(! (this instanceof TestAgent)) {
return new TestAgent(app, options)
}
// Create a server
if (typeof app === 'function') {
app = http.createServer(app)
}
// https
if (options) {
this._ca = options.ca
this._key = options.key
this._cert = options.cert
}
// The agent that uses superagent
Agent.call(this)
this.app = app
}
/ / Agent
Object.setPrototypeOf(TestAgent.prototype, Agent.prototype)
/ / host function
TestAgent.prototype.host = function(host) {
this._host = host
return this
}
// delete
TestAgent.prototype.del = TestAgent.prototype.delete
Copy the code
Don’t forget to overload restful methods as well:
// Override HTTP restful method
methods.forEach(function(method) {
TestAgent.prototype[method] = function(url, fn) {
// Initialize the request
const req = new Test(this.app, method.toLowerCase(), url)
// https
req.ca(this._ca)
req.key(this._key)
req.cert(this._cert)
// host
if (this._host) {
req.set('host'.this._host)
}
// Save cookies when HTTP returns
req.on('response'.this._saveCookies.bind(this))
// The redirection saves the Cookie along with the Cookie
req.on('redirect'.this._saveCookies.bind(this))
req.on('redirect'.this._attachCookies.bind(this))
// This request takes a Cookie
this._attachCookies(req)
this._setDefaults(req)
return req
}
})
Copy the code
In addition to returning the created Test object, HTTPS, host, and cookie are also handled. In fact, these processing is not my idea, is the superagent in its own Agent class processing, here just copy over 🙂
Using Class Inheritance
All of the above uses prototype to implement inheritance, very painful. TestAgent = TestAgent; TestAgent = TestAgent;
// Test.js
import http from 'http'
import https from 'https'
import assert from 'assert'
import {Request} from 'superagent'
import util from 'util'
// Wrap the original function to provide a more elegant error stack
function wrapAssertFn(assertFn) {
// Keep the last 3 lines
const savedStack = new Error().stack.split('\n').slice(3)
return function (res) {
const err = assertFn(res)
if (err instanceof Error && err.stack) {
// Get rid of line 1
const badStack = err.stack.replace(err.message, ' ').split('\n').slice(1)
err.stack = [err.toString()]
.concat(savedStack)
.concat('-- -- -- -- -- -- -- --)
.concat(badStack)
.join('\n')}return err
}
}
// Optimize the error display
function error(msg, expected, actual) {
const err = new Error(msg)
err.expected = expected
err.actual = actual
err.showDiff = true
return err
}
class Test extends Request {
/ / initialization
constructor(app, method, path) {
super(method.toUpperCase(), path)
this.redirects(0) // Disable redirection
this.app = app // app/string
this.url = typeof app === 'string' ? app + path : this.serverAddress(app, path) // Request path
this._asserts = [] / / an Assertion queue
}
// Get the request path through app
serverAddress(app, path) {
if(! app.address()) {this._server = app.listen(0) / / internal server
}
const port = app.address().port
const protocol = app instanceof https.Server ? 'https' : 'http'
return `${protocol}: / / 127.0.0.1:${port}${path}`
}
/** * .expect(200) * .expect(200, fn) * .expect(200, body) * .expect('Some body') * .expect('Some body', fn) * .expect('Content-Type', 'application/json') * .expect('Content-Type', 'application/json', fn) * .expect(fn) */
expect(a, b, c) {
/ / callback
if (typeof a === 'function') {
this._asserts.push(wrapAssertFn(a))
return this
}
if (typeof b === 'function') this.end(b)
if (typeof c === 'function') this.end(c)
/ / status code
if (typeof a === 'number') {
this._asserts.push(wrapAssertFn(this._assertStatus.bind(this, a)))
// body
if (typeofb ! = ='function' && arguments.length > 1) {
this._asserts.push(wrapAssertFn(this._assertBody.bind(this, b)))
}
return this
}
// header
if (typeof b === 'string' || typeof b === 'number' || b instanceof RegExp) {
this._asserts.push(wrapAssertFn(this._assertHeader.bind(this, {name: ' ' + a, value: b})))
return this
}
// body
this._asserts.push(wrapAssertFn(this._assertBody.bind(this, a)))
return this
}
// Aggregate all Assertion results
end(fn) {
const self = this
const server = this._server
const end = Request.prototype.end
end.call(this.function (err, res) {
if (server && server._handle) return server.close(localAssert)
localAssert()
function localAssert() {
self.assert(err, res, fn)
}
})
return this
}
// Execute all assertions
assert(resError, res, fn) {
// Common network error
const sysErrors = {
ECONNREFUSED: 'Connection refused'.ECONNRESET: 'Connection reset by peer'.EPIPE: 'Broken pipe'.ETIMEDOUT: 'Operation timed out'
}
let errorObj = null
// Handle the returned error
if(! res && resError) {if (resError instanceof Error && resError.syscall === 'connect' && sysErrors[resError.code]) {
errorObj = new Error(resError.code + ':' + sysErrors[resError.code])
} else {
errorObj = resError
}
}
// Execute all assertions
for (let i = 0; i < this._asserts.length && ! errorObj; i++) { errorObj =this._assertFunction(this._asserts[i], res)
}
// Handle superagent errors
if(! errorObj && resErrorinstanceof Error&& (! res || resError.status ! == res.status)) { errorObj = resError } fn.call(this, errorObj || null, res)
}
// Determine whether the current status code is equal
_assertStatus(status, res) {
if(status ! == res.status) {const expectStatusContent = http.STATUS_CODES[status]
const actualStatusContent = http.STATUS_CODES[res.status]
return new Error('expected ' + status + '"' + expectStatusContent + '", got ' + res.status + '"' + actualStatusContent + '"')}}// Determine whether the current body is equal
_assertBody(body, res) {
const isRegExp = body instanceof RegExp
if (typeof body === 'object' && !isRegExp) { // Compare the normal body
try {
assert.deepStrictEqual(body, res.body)
} catch (err) {
const expectBody = util.inspect(body)
const actualBody = util.inspect(res.body)
return error('expected ' + expectBody + ' response body, got ' + actualBody, body, res.body)
}
} else if(body ! == res.text) {// Compare normal text content
const expectBody = util.inspect(body)
const actualBody = util.inspect(res.text)
if (isRegExp) {
if(! body.test(res.text)) {// Body is a regular expression case
return error('expected body ' + actualBody + ' to match ' + body, body, res.body)
}
} else {
return error(`expected ${expectBody} response body, got ${actualBody}`, body, res.body)
}
}
}
// Check whether the current header is equal
_assertHeader(header, res) {
const field = header.name
const actualValue = res.header[field.toLowerCase()]
const expectValue = header.value
// Field does not exist
if (typeof actualValue === 'undefined') {
return new Error('expected "' + field + '" header field')}// It is the same case
if ((Array.isArray(actualValue) && actualValue.toString() === expectValue) || actualValue === expectValue) {
return
}
// Check the re case
if (expectValue instanceof RegExp) {
if(! expectValue.test(actualValue)) {return new Error('expected "' + field + '" matching ' + expectValue + ', got "' + actualValue + '"')}}else {
return new Error('expected "' + field + '" of "' + expectValue + '", got "' + actualValue + '"')}}// Execute a single Assertion
_assertFunction(fn, res) {
let err
try {
err = fn(res)
} catch (e) {
err = e
}
if (err instanceof Error) return err
}
}
export default Test
Copy the code
There are TestAgent
import http from 'http'
import methods from 'methods'
import {agent as Agent} from 'superagent'
import Test from './Test'
class TestAgent extends Agent {
/ / initialization
constructor(app, options) {
super(a)// Create a server
if (typeof app === 'function') {
app = http.createServer(app)
}
// https
if (options) {
this._ca = options.ca
this._key = options.key
this._cert = options.cert
}
// The agent that uses superagent
Agent.call(this)
this.app = app
}
/ / host function
host(host) {
this._host = host
return this
}
/ / reuse delete
del(. args) {
this.delete(args)
}
}
// Override HTTP restful method
methods.forEach(function (method) {
TestAgent.prototype[method] = function (url, fn) {
// Initialize the request
const req = new Test(this.app, method.toLowerCase(), url)
// https
req.ca(this._ca)
req.key(this._key)
req.cert(this._cert)
// host
if (this._host) {
req.set('host'.this._host)
}
// Save cookies when HTTP returns
req.on('response'.this._saveCookies.bind(this))
// The redirection saves the Cookie along with the Cookie
req.on('redirect'.this._saveCookies.bind(this))
req.on('redirect'.this._attachCookies.bind(this))
// This request takes a Cookie
this._attachCookies(req)
this._setDefaults(req)
return req
}
})
export default TestAgent
Copy the code
Finally, let’s look at the code for the request function:
import methods from 'methods'
import http from 'http'
import TestAgent from './TestAgent'
import Test from './Test'
function request(app) {
const obj = {}
if (typeof app === 'function') {
app = http.createServer(app)
}
methods.forEach(function(method) {
obj[method] = function(url) {
return new Test(app, method, url)
}
})
obj.del = obj.delete
return obj
}
request.agent = TestAgent
export default request
Copy the code
conclusion
Now that we’ve implemented the supertest library perfectly, let’s summarize what we’ve done:
- To determine the
request -> process -> expect
Expect is the core of the whole test library - To expose
expect
Function to collect assertion statements, andend
The assert callback function is used to perform assertion callbacks in batches - in
expect
Depending on the input parameter in the function_asssertStatus
或_assertBody
or_assertHeaders
push_asserts
In the array end
Function performsassert
Function to perform all_asserts
All assertions are called back, and network errors are handled accordingly- The stack has also been modified to display errors more friendly
- In addition to using
request
Functions to test a single use case are also providedTestAgent
Use case as agent test batch
The last
This is the last article in this issue of “Building the Wheel.” There are only 10 articles on “Building the Wheel.”
Although this series of articles begins with the title “Building the Wheel,” it essentially takes you through the source code step by step. Compared to the market “intensive reading source code” article, this series of articles will not come up to see the source code, but from a simple requirements to start, first to achieve a Low code to solve the problem, and then slowly optimize, and finally evolve into the source code. I hope you can take a look at the source code from shallow to deep, and there will not be too much psychological burden 🙂
Why only 10? One reason is to try out other fields and read books. Another reason is because every week to study the source code, and then start from scratch deduce the evolution of the source code is very energy consumption, really tired, afraid of the back will rot tail, with now the best state of the end.
(End loose flower 🎉🎉)