{"id":17683337,"url":"https://github.com/haixiangyan/my-supertest","last_synced_at":"2025-03-30T19:49:18.112Z","repository":{"id":106425543,"uuid":"361340549","full_name":"haixiangyan/my-supertest","owner":"haixiangyan","description":"手把手实现一个测试接口的框架 supertest","archived":false,"fork":false,"pushed_at":"2021-04-28T01:24:02.000Z","size":52,"stargazers_count":1,"open_issues_count":0,"forks_count":1,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-02-05T22:48:49.891Z","etag":null,"topics":["js","jstest","supertest","testing","unitest"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/haixiangyan.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-04-25T05:39:05.000Z","updated_at":"2021-07-23T04:15:43.000Z","dependencies_parsed_at":null,"dependency_job_id":"d356599c-b393-4e66-b38a-b01f734e500e","html_url":"https://github.com/haixiangyan/my-supertest","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-supertest","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-supertest/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-supertest/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/haixiangyan%2Fmy-supertest/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/haixiangyan","download_url":"https://codeload.github.com/haixiangyan/my-supertest/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246372501,"owners_count":20766627,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["js","jstest","supertest","testing","unitest"],"created_at":"2024-10-24T09:45:09.837Z","updated_at":"2025-03-30T19:49:18.091Z","avatar_url":"https://github.com/haixiangyan.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 造一个 supertest 轮子\n\n![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1e2e85c107764fc7a8052eef62a7fa5a~tplv-k3u1fbpfcp-zoom-1.image)\n\n[![Build Status](https://www.travis-ci.com/Haixiang6123/my-supertest.svg?branch=main)](https://www.travis-ci.com/Haixiang6123/my-supertest)\n\n\u003e 文章源码：https://github.com/Haixiang6123/my-supertest\n\u003e\n\u003e 参考轮子：https://www.npmjs.com/package/supertest\n\n\n[supertest](https://www.npmjs.com/package/supertest) 是一个短小精悍的接口测试工具，比如一个登录接口的测试用例如下：\n\n```js\nimport request from 'supertest'\n\nit('登录成功', () =\u003e {\n  request('https://127.0.0.1:8080')\n    .post('/login')\n    .send({ username: 'HaiGuai', password: '123456' })\n    .expect(200)\n})\n```\n\n整个用例感观上非常简洁易懂。这个库挺小的，设计也不错，还是 TJ Holowaychuk 写的！今天就带大家一起实现一个 supertest 的轮子吧，做一个测试框架！\n\n## 思路\n\n在写代码前，先根据上面的经典例子设计好整个框架。\n\n还是从上面的例子可以看出：发送请求，处理请求，对结果进行 expect 这三步组成了整个框架的链路，组成一个用例的生命周期。\n\n```\nrequest -\u003e process -\u003e expect(200)\n```\n\n**request** 这一步可以由第三方 http 库实现，比如 [axios](https://www.npmjs.com/package/axios)、[node-fetch](https://www.npmjs.com/package/node-fetch)、[superagent](https://www.npmjs.com/package/supertest) 都可以。\n\n**process** 这一步就是业务代码不需要理会，最后的 **expect** 则可以用到 Node.js 自己提供的 assert 库来执行断言语句。所以，我们要把精力放在如何执行这些断言身上。\n\n**expect** 最后一步是我们框架的整个核心，我们要做的是如何管理好所有的断言，因为开发者很有可能会像下面一样多次执行断言：\n\n```\nxxx\n  .expect(1 + 1, 2)\n  .expect(200)\n  .expect({ result: 'success'})\n  .expect((res) =\u003e console.log(res))\n```\n\n所以，我们需要一个数组 `this._asserts = []` 来存放这些断言，然后再提供一个 `end()` 函数，用来最后一次性执行完这些断言：\n\n```\nxxx\n  .expect(1 + 1, 2)\n  .expect(200)\n  .expect({ result: 'success'})\n  .expect((res) =\u003e console.log(res))\n  .end() // 把上面都执行了\n```\n\n有点像事件中心，只不过这里每 `expect` 一下就相当于给 \"expect\" 这个事件加一个监听器，最后 `end` 则类似触发 \"expect\" 事件，把所有监听器都执行。\n\n我们还注意到一点 `expect` 函数有可能是用来检查状态码 `status` 的，有的是检查返回的 `body`，还有些检查 `headers` 的，因此每次调用 `expect` 函数除了要往 `this._asserts` 推入断言回调，还要判断所推入的断言回调到底是给 `headers` 断言、还是给 `body` 断言或者给 `status` 断言的。\n\n将上面的思路整理出来，图示如下：\n\n![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/700c60d685f34e03ba1f6ff7dbbdf76f~tplv-k3u1fbpfcp-zoom-1.image)\n\n其中我们只需要关注黄色和红色部分即可。\n\n## 简单实现\n\n刚刚说到“发送请求”这一步是可以由第三方库完成的，这里选用 superagent 作为发送 npm 包，因为这个库的用法也是链式调用更符合我们的期望，举个例子：\n\n```js\nsuperagent\n  .post('/api/pet')\n  .send({ name: 'Manny', species: 'cat' }) // sends a JSON post body\n  .set('X-API-Key', 'foobar')\n  .set('accept', 'json')\n  .end((err, res) =\u003e {\n    // Calling the end function will send the request\n  });\n```\n\n这也太像了吧！这不禁给了我们一些灵感：基于 superagent，把上面的 `expect` 加到 superagent 里，然后改写一下 `end` 以及 restful 的 http 函数就 OK 了呀！**“基于 XX，重写方法和加自己的方法”**，想到了什么？继承呀！superagent 恰好提供了 Request 这个类，我们只要继承它再重写方法和加 `expect` 函数就好了！\n\n一个简单 Request 子类实现如下（先不管怎么区分断言回调，只做一个简单的 `equals` 作为断言回调）：\n\n```js\nimport {Request} from 'superagent'\nimport assert from 'assert'\n\nfunction Test(url, method, path) {\n  // 发送请求\n  Request.call(this, method.toUpperCase(), path)\n\n  this.url = url + path // 请求路径\n  this._asserts = [] // Assertion 队列\n}\n\n// 继承 Request\nObject.setPrototypeOf(Test.prototype, Request.prototype)\n\n/**\n *   .expect(1 + 1, 2)\n */\nTest.prototype.expect = function(a, b) {\n  this._asserts.push(this.equals.bind(this, a, b))\n\n  return this\n}\n\n// 判断两值是否相等\nTest.prototype.equals = function(a, b) {\n  try {\n    assert.strictEqual(a, b)\n  } catch (err) {\n    return new Error(`我想要${a}，但是你给了我${b}`)\n  }\n}\n\n// 执行所有 Assertion\nTest.prototype.assert = function(err, res, fn) {\n  let errorObj = null\n\n  for (let i = 0; i \u003c this._asserts.length; i++) {\n    errorObj = this._asserts[i](res)\n  }\n\n  fn(errorObj)\n}\n\n// 汇总所有 Assertion 结果\nTest.prototype.end = function (fn) {\n  const self = this\n  const end = Request.prototype.end\n\n  end.call(this, function(err, res) {\n    self.assert(err, res, fn)\n  })\n\n  return this\n}\n```\n\n上面继承 Request 父类，提供了 `expect`, `equals`, `assert` 函数，并重写了 `end` 函数，这仅仅是我们自己的 `Test` 类，最好向外提供一个 `request` 函数：\n\n```js\nimport methods from 'methods'\nimport http from 'http'\nimport Test from './Test'\n\nfunction request(path) {\n  const obj = {}\n\n  methods.forEach(function(method) {\n    obj[method] = function(url) {\n      return new Test(path, method, url)\n    }\n  })\n\n  obj.del = obj.delete\n\n  return obj\n}\n```\n\n**[methods](https://www.npmjs.com/package/methods) 这个 npm 包会返回所有 restful 的函数名，如 `post`, `get` 之类的。在新创建的对象里添加这些 restful 函数，并通过传入对应的 `path`, `method` 和 `url` 创建 `Test` 对象，然后间接创建一个 http 请求，以此完成 “发送请求” 这一步**。\n\n然后可以这样使用我们的框架了：\n\n```js\nit('should be supported', function (done) {\n  const app = express();\n  let s;\n\n  app.get('/', function (req, res) {\n    res.send('hello');\n  });\n\n  s = app.listen(function () {\n    const url = 'http://localhost:' + s.address().port;\n    request(url)\n      .get('/')\n      .expect(1 + 1, 1)\n      .end(done);\n  });\n});\n```\n\n## 创建一个服务器\n\n上面 `request` 函数调用的时候会有个问题：**我们每次都要在 `app.listen` 函数里测试，那能不能在 request 的时候就传入 app，然后直接发请求测试呢？比如：**\n\n```js\nit('should fire up the app on an ephemeral port', function (done) {\n  const app = express();\n\n  app.get('/', function (req, res) {\n    res.send('hey');\n  });\n\n  request(app)\n    .get('/')\n    .end(function (err, res) {\n      expect(res.status).toEqual(200)\n      expect(res.text).toEqual('hey')\n      done();\n    });\n});\n```\n\n首先，我们在 `request` 函数里检测如果传入的是 app 函数，那么创建服务器。\n\n```js\nfunction request(app) {\n  const obj = {}\n\n  if (typeof app === 'function') {\n    app = http.createServer(app) // 创建内部服务器\n  }\n\n  methods.forEach(function(method) {\n    obj[method] = function(url) {\n      return new Test(app, method, url)\n    }\n  })\n\n  obj.del = obj.delete\n\n  return obj\n}\n```\n\n然后在 `Test` 类的 constructor 里也可以获取对应的 path，并监听 0 号端口：\n\n```js\nfunction Test(app, method, path) {\n  // 发送请求\n  Request.call(this, method.toUpperCase(), path)\n\n  this.redirects(0) // 禁止重定向\n  this.app = app // app/string\n  this.url = typeof app === 'string' ? app + path : this.serverAddress(app, path) // 请求路径\n  this._asserts = [] // Assertion 队列\n}\n\n// 通过 app 获取请求路径\nTest.prototype.serverAddress = function(app, path) {\n  if (!app.address()) {\n    this._server = app.listen(0) // 内部 server\n  }\n\n  const port = app.address().port\n  const protocol = app instanceof https.Server ? 'https' : 'http'\n  return `${protocol}://127.0.0.1:${port}${path}`\n}\n```\n\n最后，在 `end` 函数里把刚刚创建的服务器关闭：\n\n```js\n// 汇总所有 Assertion 结果\nTest.prototype.end = function (fn) {\n  const self = this\n  const server = this._server\n  const end = Request.prototype.end\n\n  end.call(this, function(err, res) {\n    if (server \u0026\u0026 server._handle) return server.close(localAssert)\n\n    localAssert()\n\n    function localAssert() {\n      self.assert(err, res, fn)\n    }\n  })\n\n  return this\n}\n```\n\n## 封装报错信息\n\n再来看看我们是如何处理断言的：断言失败会走到 catch 语句并返回一个 Error，最后返回 Error 传入 `end(fn)` 的 `fn` 回调入参。但是这会有一个问题啊，我们看错误堆栈的时候就蒙逼了：\n\n![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/219088c02a5c4d7db23240c31b6db172~tplv-k3u1fbpfcp-zoom-1.image)\n\n错误信息是符合预期的，但是错误堆栈就不太友好了：前三行会定位到我们自己的框架代码里！试想一下，如果别人用我们的库 `expect` 出错了，点了错误堆栈结果后，发现定位到了我们的源码会不会觉得蒙逼？所以，我们要对 Error 的 `err.stack` 进行改造：\n\n```js\n// 包裹原函数，提供更优雅的报错堆栈\nfunction wrapAssertFn(assertFn) {\n  // 保留最后 3 行\n  const savedStack = new Error().stack.split('\\n').slice(3)\n\n  return function(res) {\n    const err = assertFn(res)\n    if (err instanceof Error \u0026\u0026 err.stack) {\n      // 去掉第 1 行\n      const badStack = err.stack.replace(err.message, '').split('\\n').slice(1)\n      err.stack = [err.toString()]\n        .concat(savedStack)\n        .concat('--------')\n        .concat(badStack)\n        .join('\\n')\n    }\n\n    return err\n  }\n}\n\nTest.prototype.expect = function(a, b) {\n  this._asserts.push(wrapAssertFn(this.equals.bind(this, a, b)))\n\n  return this\n}\n```\n\n上面首先去掉当前调用栈前 3 行，也就是上面截图的前 3 行，因为这都属于源码里的报错，对开发者会有干扰，而后面的堆栈可以帮助开发者直接定位到那个凉了的 `expect` 了。当然，我们还把真实的源码出错地方作为 `badStack` 也显示出来，只是用 '------' 作为分割了，最后的错误结果如下：\n\n![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/42f29125157b499692cd82cf5416e955~tplv-k3u1fbpfcp-zoom-1.image)\n\n## 区分断言回调\n\n现在把注意力都放在 `expect` 这个最最核心的函数上，刚刚已用 `equal` 实现最简单的断言了，现在我们要添加对 `headers`, `status` 和 `body` 的断言，对它们的断言函数的简单实现如下：\n\n```js\nimport util from \"util\";\nimport assert from 'assert'\n\n// 判断当前状态码是否相等\nTest.prototype._assertStatus = function(status, res) {\n  if (status !== res.status) {\n    const expectStatusContent = http.STATUS_CODES[status]\n    const actualStatusContent = http.STATUS_CODES[res.status]\n    return new Error('expected ' + status + ' \"' + expectStatusContent + '\", got ' + res.status + ' \"' + actualStatusContent + '\"')\n  }\n}\n\n// 判断当前 body 是否相等\n// 判断当前 body 是否相等\nTest.prototype._assertBody = function(body, res) {\n  const isRegExp = body instanceof RegExp\n\n  if (typeof body === 'object' \u0026\u0026 !isRegExp) { // 普通 body 的对比\n    try {\n      assert.deepStrictEqual(body, res.body)\n    } catch (err) {\n      const expectBody = util.inspect(body)\n      const actualBody = util.inspect(res.body)\n      return error('expected ' + expectBody + ' response body, got ' + actualBody, body, res.body);\n    }\n  } else if (body !== res.text) { // 普通文本内容的对比\n    const expectBody = util.inspect(body)\n    const actualBody = util.inspect(res.text)\n\n    if (isRegExp) {\n      if (!body.test(res.text)) { // body 是正则表达式的情况\n        return error('expected body ' + actualBody + ' to match ' + body, body, res.body);\n      }\n    } else {\n      return error(`expected ${expectBody} response body, got ${actualBody}`, body, res.body)\n    }\n  }\n}\n\n// 判断当前 header 是否相等\nTest.prototype._assertHeader = function(header, res) {\n  const field = header.name\n  const actualValue = res.header[field.toLowerCase()]\n  const expectValue = header.value\n\n  // field 不存在\n  if (typeof actualValue === 'undefined') {\n    return new Error('expected \"' + field + '\" header field');\n  }\n  // 相等的情况\n  if ((Array.isArray(actualValue) \u0026\u0026 actualValue.toString() === expectValue) || actualValue === expectValue) {\n    return\n  }\n  // 检查正则的情况\n  if (expectValue instanceof RegExp) {\n    if (!expectValue.test(actualValue)) {\n      return new Error('expected \"' + field + '\" matching ' + expectValue + ', got \"' + actualValue + '\"')\n    }\n  } else {\n    return new Error('expected \"' + field + '\" of \"' + expectValue + '\", got \"' + actualValue + '\"')\n  }\n}\n\n// 优化错误展示内容\nfunction error(msg, expected, actual) {\n  const err = new Error(msg)\n  err.expected = expected\n  err.actual = actual\n  err.showDiff = true\n  return err\n}\n```\n\n然后在 `expect` 函数里通过参数类型的判断选择对应的 `_assertXXX` 函数：\n\n```js\n/**\n *   .expect(200)\n *   .expect(200, fn)\n *   .expect(200, body)\n *   .expect('Some body')\n *   .expect('Some body', fn)\n *   .expect('Content-Type', 'application/json')\n *   .expect('Content-Type', 'application/json', fn)\n *   .expect(fn)\n */\nTest.prototype.expect = function(a, b, c) {\n  // 回调\n  if (typeof a === 'function') {\n    this._asserts.push(wrapAssertFn(a))\n    return this\n  }\n  if (typeof b === 'function') this.end(b)\n  if (typeof c === 'function') this.end(c)\n\n  // 状态码\n  if (typeof a === 'number') {\n    this._asserts.push(wrapAssertFn(this._assertStatus.bind(this, a)))\n    // body\n    if (typeof b !== 'function' \u0026\u0026 arguments.length \u003e 1) {\n      this._asserts.push(wrapAssertFn(this._assertBody.bind(this, b)))\n    }\n    return this\n  }\n\n  // header\n  if (typeof b === 'string' || typeof b === 'number' || b instanceof RegExp) {\n    this._asserts.push(wrapAssertFn(this._assertHeader.bind(this, { name: '' + a, value: b })))\n    return this\n  }\n\n  // body\n  this._asserts.push(wrapAssertFn(this._assertBody.bind(this, a)))\n\n  return this\n}\n```\n\n至此，我们完成基本的断言功能了。\n\n## 处理网络错误\n\n有时候会抛出的错误可能并不是因为业务代码出错了，而是像网络断网这种异常情况。我们也要对这类错误进行处理，以更友好的方式展示给开发者，可以对 `assert` 函数进行改造：\n\n```js\n// 执行所有 Assertion\nTest.prototype.assert = function(resError, res, fn) {\n  // 通用网络错误\n  const sysErrors = {\n    ECONNREFUSED: 'Connection refused',\n    ECONNRESET: 'Connection reset by peer',\n    EPIPE: 'Broken pipe',\n    ETIMEDOUT: 'Operation timed out'\n  };\n\n  let errorObj = null\n\n  // 处理返回的错误\n  if (!res \u0026\u0026 resError) {\n    if (resError instanceof Error \u0026\u0026 resError.syscall === 'connect' \u0026\u0026 sysErrors[resError.code]) {\n      errorObj = new Error(resError.code + ': ' + sysErrors[resError.code])\n    } else {\n      errorObj = resError\n    }\n  }\n\n  // 执行所有 Assertion\n  for (let i = 0; i \u003c this._asserts.length \u0026\u0026 !errorObj; i++) {\n    errorObj = this._assertFunction(this._asserts[i], res)\n  }\n\n  // 处理 superagent 的错误\n  if (!errorObj \u0026\u0026 resError instanceof Error \u0026\u0026 (!res || resError.status !== res.status)) {\n    errorObj = resError\n  }\n\n  fn.call(this, errorObj || null, res)\n}\n```\n\n至此，对于 `status`, `body`, `headers` 的断言都实现了，并在 `expect` 里合理使用这三者的断言回调，同时还处理了网络异常的情况。\n\n## Agent 代理\n\n再来回顾一下我们是怎么使用框架来写测试用例的：\n\n```js\nit('should handle redirects', function (done) {\n  const app = express();\n\n  app.get('/login', function (req, res) {\n    res.end('Login');\n  });\n\n  app.get('/', function (req, res) {\n    res.redirect('/login');\n  });\n\n  request(app)\n    .get('/')\n    .redirects(1)\n    .end(function (err, res) {\n      expect(res).toBeTruthy()\n      expect(res.status).toEqual(200)\n      expect(res.text).toEqual('Login')\n      done();\n    });\n});\n```\n\n可以观察到：**每次调用 `request` 函数内部都会马上创建一个服务器，调用 `end` 的时候又马上关闭，连续测试的时候消耗很大而且完全可以公用一个 server。能不能对 A 系列的用例用 A_Server，而对 B 系列的用例用 B_Server 呢？**\n\nsuperagent 除了 Request 类，还提供强大的 Agent 类来解决这类的需求。参考刚刚写的 `Test` 类，照猫画虎写一个自己的 `TestAgent` 类继承原 Agent 类：\n\n```js\nimport http from 'http'\nimport methods from 'methods'\nimport {agent as Agent} from 'superagent'\n\nimport Test from './Test'\n\nfunction TestAgent(app, options) {\n  // 普通函数调用 TestAgent(app, options)\n  if (!(this instanceof TestAgent)) {\n    return new TestAgent(app, options)\n  }\n\n  // 创建服务器\n  if (typeof app === 'function') {\n    app = http.createServer(app)\n  }\n\n  // https\n  if (options) {\n    this._ca = options.ca\n    this._key = options.key\n    this._cert = options.cert\n  }\n\n  // 使用 superagent 的代理\n  Agent.call(this)\n  this.app = app\n}\n\n// 继承 Agent\nObject.setPrototypeOf(TestAgent.prototype, Agent.prototype)\n\n// host 函数\nTestAgent.prototype.host = function(host) {\n  this._host = host\n  return this\n}\n\n// delete\nTestAgent.prototype.del = TestAgent.prototype.delete\n```\n\n当然不要忘了把 restful 的方法也重载了：\n\n```js\n// 重写 http 的 restful method\nmethods.forEach(function(method) {\n  TestAgent.prototype[method] = function(url, fn) {\n    // 初始化请求\n    const req = new Test(this.app, method.toLowerCase(), url)\n\n    // https\n    req.ca(this._ca)\n    req.key(this._key)\n    req.cert(this._cert)\n\n    // host\n    if (this._host) {\n      req.set('host', this._host)\n    }\n\n    // http 返回时保存 Cookie\n    req.on('response', this._saveCookies.bind(this))\n    // 重定向除了保存 Cookie，同时附带上 Cookie\n    req.on('redirect', this._saveCookies.bind(this))\n    req.on('redirect', this._attachCookies.bind(this))\n\n    // 本次请求就带上 Cookie\n    this._attachCookies(req)\n    this._setDefaults(req)\n\n    return req\n  }\n})\n```\n\n重写的时候除了返回创建的 `Test` 对象，还对 https, host, cookie 做了一些处理。其实这些处理也不是我想出来的，是 superagent 里的对它自己 Agent 类的处理，这里就照抄过来而已 :)\n\n## 使用 Class 继承\n\n上面都是用 prototype 来实现继承，非常的蛋疼。这里直接把代码都改写成 class 形式，同时整理 `Test` 和 `TestAgent` 两个类的代码：\n\n```js\n// Test.js\nimport http from 'http'\nimport https from 'https'\nimport assert from 'assert'\nimport {Request} from 'superagent'\nimport util from 'util'\n\n// 包裹原函数，提供更优雅的报错堆栈\nfunction wrapAssertFn(assertFn) {\n  // 保留最后 3 行\n  const savedStack = new Error().stack.split('\\n').slice(3)\n\n  return function (res) {\n    const err = assertFn(res)\n    if (err instanceof Error \u0026\u0026 err.stack) {\n      // 去掉第 1 行\n      const badStack = err.stack.replace(err.message, '').split('\\n').slice(1)\n      err.stack = [err.toString()]\n        .concat(savedStack)\n        .concat('--------')\n        .concat(badStack)\n        .join('\\n')\n    }\n\n    return err\n  }\n}\n\n// 优化错误展示内容\nfunction error(msg, expected, actual) {\n  const err = new Error(msg)\n  err.expected = expected\n  err.actual = actual\n  err.showDiff = true\n  return err\n}\n\nclass Test extends Request {\n  // 初始化\n  constructor(app, method, path) {\n    super(method.toUpperCase(), path)\n\n    this.redirects(0) // 禁止重定向\n    this.app = app // app/string\n    this.url = typeof app === 'string' ? app + path : this.serverAddress(app, path) // 请求路径\n    this._asserts = [] // Assertion 队列\n  }\n\n  // 通过 app 获取请求路径\n  serverAddress(app, path) {\n    if (!app.address()) {\n      this._server = app.listen(0) // 内部 server\n    }\n\n    const port = app.address().port\n    const protocol = app instanceof https.Server ? 'https' : 'http'\n    return `${protocol}://127.0.0.1:${port}${path}`\n  }\n\n  /**\n   *   .expect(200)\n   *   .expect(200, fn)\n   *   .expect(200, body)\n   *   .expect('Some body')\n   *   .expect('Some body', fn)\n   *   .expect('Content-Type', 'application/json')\n   *   .expect('Content-Type', 'application/json', fn)\n   *   .expect(fn)\n   */\n  expect(a, b, c) {\n    // 回调\n    if (typeof a === 'function') {\n      this._asserts.push(wrapAssertFn(a))\n      return this\n    }\n    if (typeof b === 'function') this.end(b)\n    if (typeof c === 'function') this.end(c)\n\n    // 状态码\n    if (typeof a === 'number') {\n      this._asserts.push(wrapAssertFn(this._assertStatus.bind(this, a)))\n      // body\n      if (typeof b !== 'function' \u0026\u0026 arguments.length \u003e 1) {\n        this._asserts.push(wrapAssertFn(this._assertBody.bind(this, b)))\n      }\n      return this\n    }\n\n    // header\n    if (typeof b === 'string' || typeof b === 'number' || b instanceof RegExp) {\n      this._asserts.push(wrapAssertFn(this._assertHeader.bind(this, {name: '' + a, value: b})))\n      return this\n    }\n\n    // body\n    this._asserts.push(wrapAssertFn(this._assertBody.bind(this, a)))\n\n    return this\n  }\n\n  // 汇总所有 Assertion 结果\n  end(fn) {\n    const self = this\n    const server = this._server\n    const end = Request.prototype.end\n\n    end.call(this, function (err, res) {\n      if (server \u0026\u0026 server._handle) return server.close(localAssert)\n\n      localAssert()\n\n      function localAssert() {\n        self.assert(err, res, fn)\n      }\n    })\n\n    return this\n  }\n\n  // 执行所有 Assertion\n  assert(resError, res, fn) {\n    // 通用网络错误\n    const sysErrors = {\n      ECONNREFUSED: 'Connection refused',\n      ECONNRESET: 'Connection reset by peer',\n      EPIPE: 'Broken pipe',\n      ETIMEDOUT: 'Operation timed out'\n    }\n\n    let errorObj = null\n\n    // 处理返回的错误\n    if (!res \u0026\u0026 resError) {\n      if (resError instanceof Error \u0026\u0026 resError.syscall === 'connect' \u0026\u0026 sysErrors[resError.code]) {\n        errorObj = new Error(resError.code + ': ' + sysErrors[resError.code])\n      } else {\n        errorObj = resError\n      }\n    }\n\n    // 执行所有 Assertion\n    for (let i = 0; i \u003c this._asserts.length \u0026\u0026 !errorObj; i++) {\n      errorObj = this._assertFunction(this._asserts[i], res)\n    }\n\n    // 处理 superagent 的错误\n    if (!errorObj \u0026\u0026 resError instanceof Error \u0026\u0026 (!res || resError.status !== res.status)) {\n      errorObj = resError\n    }\n\n    fn.call(this, errorObj || null, res)\n  }\n\n  // 判断当前状态码是否相等\n  _assertStatus(status, res) {\n    if (status !== res.status) {\n      const expectStatusContent = http.STATUS_CODES[status]\n      const actualStatusContent = http.STATUS_CODES[res.status]\n      return new Error('expected ' + status + ' \"' + expectStatusContent + '\", got ' + res.status + ' \"' + actualStatusContent + '\"')\n    }\n  }\n\n  // 判断当前 body 是否相等\n  _assertBody(body, res) {\n    const isRegExp = body instanceof RegExp\n\n    if (typeof body === 'object' \u0026\u0026 !isRegExp) { // 普通 body 的对比\n      try {\n        assert.deepStrictEqual(body, res.body)\n      } catch (err) {\n        const expectBody = util.inspect(body)\n        const actualBody = util.inspect(res.body)\n        return error('expected ' + expectBody + ' response body, got ' + actualBody, body, res.body)\n      }\n    } else if (body !== res.text) { // 普通文本内容的对比\n      const expectBody = util.inspect(body)\n      const actualBody = util.inspect(res.text)\n\n      if (isRegExp) {\n        if (!body.test(res.text)) { // body 是正则表达式的情况\n          return error('expected body ' + actualBody + ' to match ' + body, body, res.body)\n        }\n      } else {\n        return error(`expected ${expectBody} response body, got ${actualBody}`, body, res.body)\n      }\n    }\n  }\n\n  // 判断当前 header 是否相等\n  _assertHeader(header, res) {\n    const field = header.name\n    const actualValue = res.header[field.toLowerCase()]\n    const expectValue = header.value\n\n    // field 不存在\n    if (typeof actualValue === 'undefined') {\n      return new Error('expected \"' + field + '\" header field')\n    }\n    // 相等的情况\n    if ((Array.isArray(actualValue) \u0026\u0026 actualValue.toString() === expectValue) || actualValue === expectValue) {\n      return\n    }\n    // 检查正则的情况\n    if (expectValue instanceof RegExp) {\n      if (!expectValue.test(actualValue)) {\n        return new Error('expected \"' + field + '\" matching ' + expectValue + ', got \"' + actualValue + '\"')\n      }\n    } else {\n      return new Error('expected \"' + field + '\" of \"' + expectValue + '\", got \"' + actualValue + '\"')\n    }\n  }\n\n  // 执行单个 Assertion\n  _assertFunction(fn, res) {\n    let err\n    try {\n      err = fn(res)\n    } catch (e) {\n      err = e\n    }\n    if (err instanceof Error) return err\n  }\n}\n\nexport default Test\n```\n\n还有 `TestAgent`\n\n```js\nimport http from 'http'\nimport methods from 'methods'\nimport {agent as Agent} from 'superagent'\n\nimport Test from './Test'\n\nclass TestAgent extends Agent {\n  // 初始化\n  constructor(app, options) {\n    super()\n\n    // 创建服务器\n    if (typeof app === 'function') {\n      app = http.createServer(app)\n    }\n\n    // https\n    if (options) {\n      this._ca = options.ca\n      this._key = options.key\n      this._cert = options.cert\n    }\n\n    // 使用 superagent 的代理\n    Agent.call(this)\n    this.app = app\n  }\n\n  // host 函数\n  host(host) {\n    this._host = host\n    return this\n  }\n\n  // 重用 delete\n  del(...args) {\n    this.delete(args)\n  }\n}\n\n// 重写 http 的 restful method\nmethods.forEach(function (method) {\n  TestAgent.prototype[method] = function (url, fn) {\n    // 初始化请求\n    const req = new Test(this.app, method.toLowerCase(), url)\n\n    // https\n    req.ca(this._ca)\n    req.key(this._key)\n    req.cert(this._cert)\n\n    // host\n    if (this._host) {\n      req.set('host', this._host)\n    }\n\n    // http 返回时保存 Cookie\n    req.on('response', this._saveCookies.bind(this))\n    // 重定向除了保存 Cookie，同时附带上 Cookie\n    req.on('redirect', this._saveCookies.bind(this))\n    req.on('redirect', this._attachCookies.bind(this))\n\n    // 本次请求就带上 Cookie\n    this._attachCookies(req)\n    this._setDefaults(req)\n\n    return req\n  }\n})\n\nexport default TestAgent\n```\n\n最后再给大家看一下 `request` 函数的代码：\n\n```js\nimport methods from 'methods'\nimport http from 'http'\nimport TestAgent from './TestAgent'\nimport Test from './Test'\n\nfunction request(app) {\n  const obj = {}\n\n  if (typeof app === 'function') {\n    app = http.createServer(app)\n  }\n\n  methods.forEach(function(method) {\n    obj[method] = function(url) {\n      return new Test(app, method, url)\n    }\n  })\n\n  obj.del = obj.delete\n\n  return obj\n}\n\nrequest.agent = TestAgent\n\nexport default request\n```\n\n## 总结\n\n至此，已经完美地实现了 [supertest](https://www.npmjs.com/package/supertest) 这个库啦，来总结一下我们都干了什么：\n\n1. 确定了 `request -\u003e process -\u003e expect` 的整体链路，expect 这一环是整个测试库的核心\n2. 向外暴露 `expect` 函数用于收集断言语句，以及 `end` 函数用于批量执行断言回调\n3. 在 `expect` 函数里根据入参要将 `_asssertStatus` 或 `_assertBody` 还是 `_assertHeaders` 推入 `_asserts` 数组里\n4. `end` 函数执行 `assert` 函数来执行所有 `_asserts` 里所有的断言回调，并对网络错误也做了相应的处理\n5. 对抛出的错误 stack 也做了修改，更友好地展示错误\n6. 除了用 `request` 函数测试单个用例，也提供 `TestAgent` 作为 agent 测试一批的用例\n\n## 最后\n\n这是这期 “造轮子” 的最后一篇文章了，目前只出了 10 篇关于 “造轮子” 的文章。\n\n虽然这系列的文章标题都是以 “造轮子” 为开头，但本质上是带大家一步一步地阅读源码。相比于市面上 “精读源码” 的文章，这一系列的文章不会一上来就看源码，而是从一个简单需求开始，先实现一个最 Low 的代码来解决问题，然后再慢慢地优化，最后进化成源码的样子。希望这样可以由浅入深地带大家看一遍源码，同时又不会有太大的心理负担 :)\n\n为什么只写 10 篇呢？一个原因是想尝试一下别的领域了和看看书了。另一个原因是因为每周都研究源码，再从头开始推演源码的进化路程是十分消耗精力的，真的会累，怕后面会烂尾，就以现在最好的状态收尾吧。\n\n（完结散花🎉🎉）\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhaixiangyan%2Fmy-supertest","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhaixiangyan%2Fmy-supertest","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhaixiangyan%2Fmy-supertest/lists"}