在本章中,我们将介绍以下配方:
- 使用 Winston 添加日志记录
- 使用 Morgan 添加 HTTP 日志记录
- 为不同环境配置服务器
- 单元测试代码
- 测量您的测试覆盖率
- 调试代码
- 从命令行测试简单服务
- 用邮递员测试更复杂的通话序列
- 使用 Swagger 记录和测试您的 restapi
在前面的章节中,我们安装了Node
并创建了一个 RESTful 服务器。一切都准备好了吗?通常情况下,事情不会很好地解决,bug 会悄悄进入,您必须找到如何修复代码。在本章中,我们将深入实际细节,例如测试和调试服务器。
因此,在本章之后,您将准备好Node
RESTful 服务器,以供部署和正式生产工作。让我们进入必要的任务。
让我们从一个简单的基本需求开始:日志记录。建立可靠、正确的日志记录可以帮助您快速发现问题,而不完整或缺乏日志记录会让您花数小时寻找可能是一个简单、琐碎的问题。任何应用的一个基本规则是确保设置了正确的登录,这样您就可以确信出现的任何情况至少会被识别并记录下来,以便将来进行分析。
您可能有的第一个想法是只使用控制台系列的功能,例如console.log()
、console.warn()
、console.info()
等等。(有关完整的参考资料,请查阅https://developer.mozilla.org/en-US/docs/Web/API/console )虽然这些有助于快速调试,但它们不能用于应用级日志记录。您应该能够选择所需的日志类型(所有内容?仅限错误?),以根据您的环境确定您看到的日志类型(例如,您希望在开发中看到某些类型的日志,但不希望在生产中看到),甚至可以启用或禁用日志记录。最后,我们希望对提供的信息进行一些控制:
- 时间戳、以了解每个日志的写入时间
** 文本格式、因此日志记录可以被人类理解,也可以被应用解析*** 级别设置,通常范围从错误(最严重)到警告、信息性、详细,最后以调试和愚蠢(是的,真的!)* 目的地拣选,如
stdout
或stderr
文件系统等***
***如果您查看npm
清单,您会发现许多可以进行日志记录的模块:一些是通用工具,另一些是更具体的工具。在我们的例子中,我们将使用Winston
进行通用的应用级日志记录,我们将使用另一种工具Morgan
,它是专门为 HTTP 流量日志记录定制的,我们将在下一节中看到。
You can learn more about Winston
at https://github.com/winstonjs/winston.
我们想安装Winston
,所以第一步将采用历史悠久的方法:
npm install winston --save
Currently, version 3.0 is in beta, but by the time you get this book, it will almost certainly be out of beta and ready for production. (By the way, I installed the beta version by using a slightly changed command: npm install winston@next --save
; otherwise, I'd have gotten a 2.x.x version.)
For (thorough!) documentation on Winston
, check its own GitHub page at https://github.com/winstonjs/winston. Be careful with articles on the web, though, because there are some important changes in version 3, so most code won't work without some updating.
我们想看一个关于Winston
用法的简单示例。这个软件包有很多配置参数,所以让我们尝试进行一个基本的、正常的配置,您可以自己扩展它:
// Source file: winston_server.js
/* @flow */
"use strict";
const express = require("express");
const winston = require("winston");
const app = express();
const logger = winston.createLogger({
transports: [
new winston.transports.Console({
level: "info",
format: winston.format.combine(
winston.format.colorize({ all: true }),
winston.format.label({ label: "serv" }),
winston.format.timestamp(),
winston.format.printf(
msg =>
`${msg.timestamp} [${msg.label}] ${msg.level} ${
msg.message
}`
)
)
}),
new winston.transports.File({
filename: "serv_error.txt.log",
level: "warn",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(
msg =>
`${msg.timestamp} [serv] ${msg.level} ${
msg.message
}`
)
)
}),
new winston.transports.File({
filename: "serv_error.json.log",
level: "warn"
})
]
});
// *continues...*
Winston
可以同时处理多个传输,通过传输表示您记录的任何内容的存储设备。单个记录器可能有多个传输,但配置不同:例如,您可能希望在控制台上显示所有日志,但只写入带有警告和错误的文件,还有更多的可能包括写入数据库或将数据发送到某个 URL。格式也可能不同(控制台的文本行,文件可能是 JSON?),因此您可以灵活地配置消息的去向。
在我们的案例中,我们创建了三种传输:
- 控制台输出,对于标记为
"info"
及以上的所有消息,使用彩色输出(我们稍后会看到),发出带有时间戳、标签("serv"
,以帮助区分服务器消息与控制台中可能显示的其他消息(来自其他应用)、错误级别和消息的输出 - 一个文件输出,用于所有标记为
"warn"
及以上的消息,文本格式 - 另一个文件输出,用于相同的消息,但为 JSON 格式
We'll look at how to adapt logging (and other features) later in this chapter, in the Configuring your server for different environments section, so you'll be able to be even more flexible in logging and other features.
在创建了日志并定义了传输之后,我们所要做的就是在任何地方使用它。我将从一个非常基本的服务器开始,这样我们就可以集中精力让日志正常工作:我们将只处理两条路由-/
,它将发送回一条消息;/xyzzy
,它将模拟一些程序失败,而不是发回一条"Nothing happens"
信息。
开始时,我们可以手动记录每个请求,尽管使用Morgan
可以获得更好的输出,稍后我们将看到。下面的代码就是这样做的:
// ...*continued*
app.use((req, res, next) => {
logger.info(`${req.method} request for ${req.originalUrl}`);
next();
});
// *continues...*
然后,对于每个路由,我们可以根据需要添加一些info
或debug
消息:
// ...*continued*
app.get("/", (req, res) => {
logger.info("Doing some processing...");
logger.debug("Some fake step 1; starting");
logger.debug("Some fake step 2; working");
logger.debug("Some fake step 3; finished!");
res.send("Winston server!");
});
app.get("/xyzzy", (req, res) => {
logger.info("Adventurer says 'XYZZY'");
res.say_xyzzy(); // this will fail
res.send("Nothing happens.");
});
// *continues...*
处理错误的路由可能会产生warn
消息,而在其他计划外的情况下,会产生直接error
。对于前者,我只列出了所需的路由,对于后者,列出了错误消息和回溯堆栈,以帮助将来调试:
// ...*continued*
app.use((req, res) => {
logger.warn(`UNKNOWN ROUTE ${req.originalUrl}`);
res.status(404).send("NOT FOUND");
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
logger.error(`GENERAL ERROR ${err.message}\n${err.stack}`);
res.status(500).send("INTERNAL SERVER ERROR");
});
app.listen(8080, () => {
logger.info("Ready at http://localhost:8080");
});
我们准备好了!让我们试试看。
在构建项目之后,我运行Winston
日志代码来捕获所有生成的日志。我尝试了一系列的呼叫,用curl
模拟;我们将在本章后面的章节中了解如何在更复杂的任务中做到这一点:
> curl localhost:8080/
Winston server!
> curl localhost:8080/
Winston server!
> curl localhost:8080/invented
NOT FOUND
> curl localhost:8080/
Winston server!
> curl localhost:8080/xyzzy
INTERNAL SERVER ERROR
> curl localhost:8080/
Winston server!
> curl localhost:8080/
Winston server!
控制台上的输出可以在下面的屏幕截图中看到。正常的行是绿色的(是的,在黑白书中很难看到),警告是黄色的,错误是红色的。对不存在的/invented
路径的请求以警告结束,/xyzzy
路径的请求产生错误,因为我们试图调用一个不存在的函数:
Winston's console output for a few dummy requests
在不同的日志文件中记录了什么?根据我们的规范,只存储警告和错误消息。文本文件基本上与控制台输出相同,这是有意义的,因为我们为这两个传输选择的格式规范完全相同:
2018-05-28T00:29:06.651Z [serv] warn UNKNOWN ROUTE /invented
2018-05-28T00:29:11.214Z [serv] error GENERAL ERROR res.say_xyzzy is not a function
TypeError: res.say_xyzzy is not a function
at app.get (/home/fkereki/MODERNJS/chapter05/out/winston_server.js:60:9)
at Layer.handle [as handle_request] (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/layer.js:95:5)
at next (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/route.js:137:13)
at Route.dispatch (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/route.js:112:3)
at Layer.handle [as handle_request] (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/layer.js:95:5)
at /home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/index.js:281:22
at Function.process_params (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/index.js:335:12)
at next (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/index.js:275:10)
at app.use (/home/fkereki/MODERNJS/chapter05/out/winston_server.js:47:5)
at Layer.handle [as handle_request] (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/layer.js:95:5)
另一方面,JSON 文件有点简化:每行包含一个具有message
和level
属性的对象,因为我们没有指定要添加任何特定的内容。然而,你可以改变这一点:阅读温斯顿在上的文档 https://github.com/winstonjs/winston/blob/master/README.md ,您将有很多可用的可能性:
{"message":"UNKNOWN ROUTE /invented","level":"warn"}
{"message":"GENERAL ERROR res.say_xyzzy is not a function\nTypeError: res.say_xyzzy is not a function\n at app.get (/home/fkereki/MODERNJS/chapter05/out/winston_server.js:60:9)\n at Layer.handle [as handle_request] *...part of the text snipped out...*
(/home/fkereki/MODERNJS/chapter05/out/winston_server.js:47:5)\n at Layer.handle [as handle_request] (/home/fkereki/MODERNJS/chapter05/node_modules/express/lib/router/layer.js:95:5)","level":"error"}
所以,我们有一种灵活的方式来记录我们想记录的任何内容,但我们的 HTTP 日志记录有点过于简略,这是一个很好的理由包括Morgan
,我们将看到。
您可能还对其他软件包感兴趣,例如Bunyan
(https://github.com/trentm/node-bunyan 或Pino
https://github.com/pinojs/pino ;后者据说是性能最好的日志软件包,但不要相信我的话,试试吧!最后,您是否应该开发npm
包,然后开发debug
(https://github.com/visionmedia/debug ),它基本上是console
方法的包装,可以是您选择的包,而且非常简单,它也适用于 web 应用和Node
在上一节中,当我们包含一些进行Winston
日志记录的中间件时,我们设法提供了一个非常基本的 HTTP 日志记录功能:
app.use((req, res, next) => {
logger.info(`${req.method} request for ${req.originalUrl}`);
next();
});
虽然这样做有效,但我们可能需要更多的信息,例如响应的 HTTP 状态代码、所需的处理时间等等,因此让我们将Morgan
添加到混合中,因为该包专门用于请求日志记录。
You can learn more about Morgan
at https://github.com/expressjs/morgan.
在这个配方中,我们将把Morgan
添加到我们的软件堆栈中,这样我们就可以为所有已处理的请求获得更好的日志。
我们先用通常的方法安装Morgan
:
npm install morgan --save
现在我们必须将其包含在服务器中,并且我们还需要fs
包才能将Morgan
的日志写入文件。请注意,我将添加到以前的服务器中,因此,Winston
部分将就位,与我们在上一节中看到的内容相同:
// Source file: src/morgan_server.js
/* @flow */
"use strict";
const express = require("express");
const winston = require("winston");
const morgan = require("morgan");
const fs = require("fs");
const app = express();
// *continues...*
我们希望对一个文件进行一些常规日志记录,并将所有错误(HTTP 状态代码 400 及更高)记录到控制台,因此我们必须向中间件堆栈中添加两次morgan
。morgan
的第一个参数定义了日志消息的形成方式:您必须提供一个函数来生成将被记录的消息,或者提供一个字符串,其中包含morgan
将在运行时替换的令牌。在以下代码段中,我使用了两种样式,只是为了多样性:用于文件输出的函数和用于控制台的字符串:
// ...*continued*
const morganStream = fs.createWriteStream("serv_http_errors.log", {
flags: "a"
});
app.use(
morgan(
(tokens, req, res) =>
`${new Date().toISOString()} [http] ` +
`${tokens.method(req, res)} ${tokens.url(req, res)}`,
{
immediate: true,
stream: morganStream
}
)
);
app.use(
morgan(
`:date[iso] [http] ` +
`:method :url (:status) :res[content-length] - :response-time ms`,
{
skip: (req, res) => res.statusCode < 400
}
)
);
// *continues...*
morgan
的第二个选项允许您添加一些选项,例如:
immediate
,这意味着请求一进入(immediate:true
)或处理完毕(immediate:false
)就会被记录下来。前者的优点是,即使在严重崩溃的情况下,您也可以确定所有请求都将被记录,但后者提供了更多信息。skip()
,一个让您决定是否记录给定请求的函数。在我们的例子中,我们将使用它来记录状态为 400 或更高的请求。stream
,输出应写入其中。
在指定输出格式时,您可以访问多个数据段,按照 Morgan 的说法称为标记,例如以下内容,但请查看文档以了解完整列表:
| :date[format]
| 当前日期和时间(UTC),多种格式 |
| :http-version
| 请求的 HTTP 版本 |
| :method
| 请求的 HTTP 方法 |
| :remote-addr
| 请求的远程地址 |
| :req[header]
| 请求的给定标头,如果标头不存在,则为“-” |
| :res[header]
| 响应的给定标头,如果标头不存在,则为“-” |
| :response-time
| 处理时间,以毫秒为单位 |
| :status
| 响应的 HTTP 状态 |
| :url
| 请求的 URL |
您可以看到,我在设置Morgan
的输出时使用了其中的几个令牌。现在,让我们看看这是怎么回事。
让我们用我们在winston
中使用的相同示例来试一试。由于我们将控制台输出设置为仅显示警告和错误,因此我们将只看到添加的一对行。显示[http]
而不是[serv]
有助于在其他控制台输出中找到它们:
.
.
.
2018-05-28T19:27:19.232Z [http] GET /invented (404) 9 - 0.886 ms
.
.
.
2018-05-28T19:27:23.771Z [http] GET /xyzzy (500) 21 - 0.925 ms
.
.
.
(完整的)HTTP 日志进入一个文件,只是所有请求的列表:
2018-05-28T19:27:16.871Z [http] GET /
2018-05-28T19:27:17.827Z [http] GET /
2018-05-28T19:27:19.231Z [http] GET /invented
2018-05-28T19:27:20.677Z [http] GET /
2018-05-28T19:27:23.770Z [http] GET /xyzzy
2018-05-28T19:27:25.296Z [http] GET /
请注意,我们选择了立即记录,这意味着所有请求(即使是那些可能导致一切崩溃的请求)都会被记录下来,但请求的结果本身并不可用。如果您还希望获得该信息,但仅针对导致错误的请求,您可以添加第三个morgan
目的地,共享相同的文件流,但仅针对错误,如以下代码段所示:
app.use(
morgan(
`:date[iso] [http] ` +
`:method :url (:status) :res[content-length] - :response-time ms`,
{
skip: (req, res) => res.statusCode < 400,
stream: morganStream
}
)
);
使用此选项,日志将包含更多数据,但仅限于您选择的请求:
2018-05-28T19:36:54.968Z [http] GET /
2018-05-28T19:36:55.453Z [http] GET /
2018-05-28T19:36:56.011Z [http] GET /
2018-05-28T19:36:58.149Z [http] GET /invented
2018-05-28T19:36:58.151Z [http] GET /invented (404) 9 - 1.230 ms
2018-05-28T19:36:59.528Z [http] GET /
2018-05-28T19:37:00.033Z [http] GET /
2018-05-28T19:37:01.886Z [http] GET /xyzzy
2018-05-28T19:37:01.888Z [http] GET /xyzzy (500) 21 - 1.115 ms
2018-05-28T19:37:03.060Z [http] GET /
2018-05-28T19:37:03.445Z [http] GET /
2018-05-28T19:37:03.903Z [http] GET /
如果您愿意,您可以将Morgan
的输出放入Winston
中,以获得单个公共日志流,如下所示:
// Source file: src/morgan_in_winston_server.js
app.use(
morgan(
`:method :url (:status) :res[content-length] - :response-time ms`,
{
stream: {
write: message => logger.info(message.trim())
}
}
)
);
一些输出可以如下所示;我突出显示了morgan
行:
2018-05-28T20:03:59.931Z [serv] info Ready at http://localhost:8080
2018-05-28T20:04:02.140Z [serv] info Doing some processing...
2018-05-28T20:04:02.146Z [serv] info GET / (200) 15 - 3.642 ms
2018-05-28T20:04:02.727Z [serv] info Doing some processing...
2018-05-28T20:04:02.728Z [serv] info GET / (200) 15 - 0.581 ms
2018-05-28T20:04:04.479Z [serv] warn UNKNOWN ROUTE /invented
2018-05-28T20:04:04.480Z [serv] info GET /invented (404) 9 - 1.170 ms
2018-05-28T20:04:05.842Z [serv] info Doing some processing...
2018-05-28T20:04:05.843Z [serv] info GET / (200) 15 - 0.490 ms
2018-05-28T20:04:07.640Z [serv] info Adventurer says 'XYZZY'
以下是我所做更改的一些细节:
- 添加
.trim()
可以去掉一个额外的换行符 - 由于所有消息都是通过
winston
发送的,因此在输出中无法获得[http]
区分文本 - 如果您想发送 400 或更高状态的警告,您必须编写一个更复杂的函数来扫描消息文本,并决定是否使用
logger.info()
或其他方法
无论您开发什么,可以肯定的是,您将至少使用两种环境,开发和生产,并且您的代码设置将不同。例如,在安全性、访问数据库、记录错误或连接到分析服务等方面,您不会使用相同的配置:在开发环境中运行时,您将需要特定的设置,而对于生产环境,可能会有许多更改
您可以在代码中设置所有内容,但将用户、密码、IP 和其他敏感数据以明文形式保存在可能被黑客攻击的源代码存储库中并不是一个很好的安全方法。您应该专门处理您的开发配置,并将实际部署留给另一个团队进行生产,该团队将安全地处理该配置。
Node 允许您访问环境变量并将其用于配置,以便可以将该设置移到代码之外。在本章中,让我们看看处理所有这些问题的一些方法,这也将间接帮助我们在本章后面的测试。
当您开发软件时,显然您将在与生产环境不同的环境中工作;事实上,你可以有几个环境,比如开发、测试、试生产、生产等等,我们也会这样做;让我们首先回顾一下我们在本书中已经看到的一些配置
在第 3 章的获取连接部分与 Node一起开发时,我们在创建服务时定义了四个常数来访问数据库,如下所示:
const DB_HOST = "127.0.0.1";
const DB_USER = "fkereki";
const DB_PASS = "modernJS!!";
const DB_SCHEMA = "world";
在上一章中,在添加 JWT认证部分,我们有一个用于签名的秘密:
const SECRET_JWT_KEY = "modernJSbook";
最后,在这一章中,我们决定了应该执行哪些级别的日志记录。但是,我们对这些级别进行了硬编码,不可能使生产中的情况有所不同:
const logger = winston.createLogger({
transports: [
new winston.transports.Console({
level: "info",
format: winston.format.combine(
winston.format.colorize({ all: true }),
.
.
.
我们还编写了以下内容,并进行了一些硬编码:
const morganStream = fs.createWriteStream("serv_http_errors.log", {
flags: "a"
});
app.use(
morgan(
`:date[iso] [http] ` +
`:method :url (:status) :res[content-length] - :response-time ms`,
{
skip: (req, res) => res.statusCode < 400
值得指出的是,开发和生产之间的变化不必局限于上市或不上市;您还可以更改日志格式、应该写入日志的文件等。
动态更改配置的关键是使用通过process.env
对象提供的环境变量。环境中的每个配置变量都将显示为该对象的属性。如果我们编写并运行一个只包含一个console.log(process.env)
行的程序(或者如果我们在命令行中编写并运行node -e "console.log(process.env)"
),您将得到类似于以下内容的输出:
> node show_env.js
{ GS_LIB: '/home/fkereki/.fonts',
KDE_FULL_SESSION: 'true',
PILOTPORT: 'usb:',
HOSTTYPE: 'x86_64',
VSCODE_NLS_CONFIG: '{"locale":"en-us","availableLanguages":{}}',
XAUTHLOCALHOSTNAME: 'linux',
XKEYSYMDB: '/usr/X11R6/lib/X11/XKeysymDB',
LANG: 'en_US.UTF-8',
WINDOWMANAGER: '/usr/bin/startkde',
LESS: '-M -I -R',
DISPLAY: ':0',
JAVA_ROOT: '/usr/lib64/jvm/jre',
HOSTNAME: 'linux',
.
.
.
. *many, many lines snipped out*
.
.
.
PATH: '/home/fkereki/bin:/usr/local/bin:/usr/bin:/bin:/usr/lib/mit/sbin',
JAVA_BINDIR: '/usr/lib64/jvm/jre/bin',
KDE_SESSION_UID: '1000',
KDE_SESSION_VERSION: '5',
SDL_AUDIODRIVER: 'pulse',
HISTSIZE: '1000',
SESSION_MANAGER: 'local/linux:@/tmp/.ICE-unix/2202,unix/linux:/tmp/.ICE-unix/2202',
CPU: 'x86_64',
CVS_RSH: 'ssh',
LESSOPEN: 'lessopen.sh %s',
GTK_IM_MODULE: 'ibus',
NODE_VERSION: '9' }
Read more about process.env
and its contents at https://nodejs.org/api/process.html#process_process_env.
有两种方法可以利用这一点。我们可以使用一个environment
变量来检查我们是否处于开发、生产或任何其他情况下,并根据这一情况设置一些属性,或者我们可以直接从环境本身获取这些属性的值。这两种解决方案中的任何一种都将帮助您将代码与环境断开链接;让我们看看这在实践中是如何工作的。
让我们从确定环境开始。标准是在运行Node
服务器之前,使用环境名称设置一个名为NODE_ENV
的环境变量。如何做到这一点取决于您的实际机器,但在 Linux 中,它类似于以下内容,而在 Windows 中,则需要使用SET
命令:
> export NODE_ENV=production
> echo $NODE_ENV
Production
在您的代码中,如果您在仅使用两行代码的开发环境中运行isDev
变量,则可以将其设置为 true(否则为 false)。如果未指定环境,则第一行将其默认为"development"
,这很可能是最安全的选择:
// Source file: show_env.js
const dev = process.env.NODE_ENV || "development";
const isDev = dev === "development";
然后,例如,您可以轻松设置不同的日志记录级别:请参阅以下代码片段,了解level
属性如何获取其值,具体取决于环境:
const logger = winston.createLogger({
transports: [
new winston.transports.Console({
level: isDev ? "info" : "warn",
format: winston.format.combine(
winston.format.colorize({ all: true }),
.
.
.
更改日志文件也很简单,与前面的代码相同:
let loggingFile;
if (isDev) {
loggingFile = "serv_http_errors.log";
} else {
loggingFile = "/var/log/http_server.txt";
}
const morganStream = fs.createWriteStream(loggingFile, {
flags: "a"
});
这种风格可行,但仍存在一些问题:
- 环境中的任何更改都需要更改(硬编码)服务器
- 路径、令牌、密码等都以非常可视的状态驻留在源代码中
因此,通过直接从环境中获取内部变量的值,我们可以做得更好:
const DB_HOST = process.env.DB_HOST;
const DB_USER = process.env.DB_USER;
const DB_PASS = process.env.DB_PASS;
const DB_SCHEMA = process.env.DB_SCHEMA;
const SECRET_JWT_KEY = process.env.SECRET_JWT_KEY;
或者,对于日志记录,我们可以使用以下内容:
const logger = winston.createLogger({
transports: [
new winston.transports.Console({
level: process.env.WINSTON_LEVEL,
format: winston.format.combine(
winston.format.colorize({ all: true }),
.
.
.
如果您想简化开发过程,同时也让其他人在将代码推送到生产环境或其他环境时更容易,那么您可能需要研究dotenv
,一个npm
包,它允许您在文本文件中使用环境变量。安装带有npm install dotenv --save
的软件包,然后在项目根目录下创建一个扩展名为.env
的文件,其中包含所需的变量值:
DB_HOST=127.0.0.1
DB_USER=fkereki
DB_PASS=modernJS!!
DB_SCHEMA=world
SECRET_JWT_KEY=modernJSbook
然后,在您的代码中,您只需要添加一行,这将加载并合并.env
文件中的所有定义到process.env
。当然,如果您只想在开发中使用此功能(正如dotenv
的创建者最初打算的那样),您可以事先检查isDev
变量,正如我们前面看到的:
if (isDev) {
dotenv.load();
}
环境文件永远不应该上传到源代码管理,所以在您的.gitignore
文件中添加一行带有**/*.env
的内容是有意义的。但是,您可以上传一个示例文件(比如,config.env.example
),但没有环境变量的实际值;这将有助于新开发人员获得必要的文件,但可以保护安全性。
You can learn more about dotenv
at https://github.com/motdotla/dotenv.
确保质量和保护自己免受回归 bug(当您修改某些内容并重新引入先前已更正的 bug 时发生的 bug)影响的最佳实践之一是确保您的代码经过单元测试。有三种类型的测试:
- 单元测试,适用于每个组件,独立进行
- 集成测试,适用于协同工作的组件
- 端到端E2E测试,适用于整个系统
单元测试之所以好,不仅是因为它有助于测试您的代码,还因为如果做得好,就像在测试驱动设计(TDD)中,您基本上首先设置测试,然后才编写代码,因为这将有助于生成质量更好的代码,这肯定会对减少整个系统的 bug 产生影响。(即使在任何测试工作开始之前就发现 bug 也是一种省钱的方法;越早发现并修复 bug,成本就越低。)因此,让我们关注一下如何在 Node 工作中使用单元测试。
Of course, it's well-known that testing can prove the existence of bugs, but not their absence, so no matter how much testing you do, some bugs will fall through! And, when that happens, TDD will make you first create some new unit tests that pinpoint the bug, and only then work at actually fixing it; at least, that specific bug won't reappear, because it will be detected.
单元测试有很多工具和框架,在本书中,我们将使用Jest
,这是一个现代的工具,用于令人愉快的 JavaScript 测试,正如它的引理所说,它是由 Facebook 开发的。我们还可以将它与 React 或 React Native 一起使用。安装非常简单,只需要npm install jest --save-dev
。这样做之后,我们就可以编写测试了;让我们看看如何。
You can read more about Jest
at the official web page, at https://facebook.github.io/jest/.
在本食谱中,我们将了解如何为Node
编写单元测试,并为以后的章节获得有效的经验。
编写单元测试可能更简单,也可能更难,这取决于您如何设计代码。如果您以清晰、无副作用的方式工作,那么编写功能测试将非常简单。如果你开始增加复杂度,比如回调或承诺、数据库或文件系统,那么你需要做更多的工作,因为你必须模拟其中的一些元素;毕竟,您不想在生产数据库上运行测试,是吗
在下面的部分中,我们将了解如何编写单元测试,并学习如何使用一些特定的概念,例如模拟或间谍。
首先,让我们看一组简单、基本的功能测试,为此,让我们回到我们在第 3 章的使用模块部分中编写的舍入库,使用 Node开发。当您测试一个模块时,您仅测试导出的函数,以查看它们是否按照其规格执行。那么,需要测试的有趣部分是:
const addR = (x: number, y: number): number => roundToCents(x + y);
const subR = (x: number, y: number): number => addR(x, changeSign(y));
const multR = (x: number, y: number): number => roundToCents(x * y);
const divR = (x: number, y: number): number => {
if (y === 0) {
throw new Error("Divisor must be nonzero");
} else {
return roundToCents(x / y);
}
};
这四个函数完全是函数式的,因为它们的计算结果只取决于它们的输入参数,并且它们绝对没有副作用。编写测试需要(1)定义测试组,(2)在每组中包含一个或多个测试。在这里,为每个函数编写一个组是有意义的,所以让我们看看代码是如何运行的;我们可以从addR()
函数开始,编写如下内容:
// Source file: src/roundmath.test.js
/* @flow */
"use strict";
const rm = require("./roundmath");
describe("addR", () => {
it("should add first and round later", () => {
expect(rm.addR(1.505, 2.505)).toBe(4.01);
});
it("should handle negatives", () => {
expect(rm.addR(3.15, -2.149)).toBe(1.0);
});
});
// *continues...*
The most usual style is naming the unit test file in the same way as the tested file, but adding "test"
or "spec"
before the file extension. In our case, for roundmath.js
, we named the unit test file as roundmath.test.js
. As for placement, Jest
is able to find your tests no matter where you place them, so the usual practice is to place this new file alongside the original one so that it will be easy to find.
每个describe()
调用定义一个组,其中的每个it()
调用定义一个特定的测试。如果测试失败,Jest 将报告,给出小组和测试的描述,如"addR should add first and round later"
所示。测试包括(1)设置东西(如果需要);(2) 通过调用函数实际运行测试;以及(3)检查功能是否如我们预期的那样工作。
我们编写的第一个测试验证了,当添加数字时,应该先进行加法,然后才进行舍入;先四舍五入,然后再相加是不对的。我们通过调用addR(1.505, 2.505)
进行测试,我们希望结果是4.01
;如果先对函数进行四舍五入,则结果将为4.02
。每个测试都应该能够验证函数的至少一个属性;我们的第二个测试检查addR()
是否可以处理负数。
The style in which you write your assumptions about the code is meant to be easy to read: expect so-and-so to be such-value. Methods such as toBe()
or toThrow()
(see our next example) are called matchers; see the quite long list at https://facebook.github.io/jest/docs/en/expect.html for more information.
当然,对于复杂的代码来说,仅仅几个测试可能是不够的,您通常会有更多的测试,但是作为一个例子,这些测试就可以了。注意,我们应该为所有函数编写测试;例如,divR()
可以使用这样的东西。虽然第一个测试非常简单(类似于addR()
的测试),但在第二个测试中,我们验证了使用零除数调用divR()
会引发异常:
// ...*continued*
describe("divR", () => {
it("should divide first, then round", () => {
expect(rm.divR(22.96, 0.001)).toBe(22960);
});
it("should not divide by zero", () =>
expect(() => rm.divR(22, 0)).toThrow());
});
如果您遗漏了某些功能或其中的一部分,在本章后面,我们将研究检测这些功能的方法;现在别担心。此时,我们将继续编写测试,然后运行完整的套件。
我们编写的功能测试非常好,但在某些情况下,例如在处理回调时,这些测试不起作用。让我们看一下我们编写的另一段代码:用于 JWT 的用户验证例程。基本上,这个函数接收一个用户名、一个密码和一个错误优先回调,用于指示用户名是否真的拥有该密码;我们想看看如何处理回调。我们现在关心的重要部分在以下代码摘录中突出显示:
const validateUser = (
userName: string,
password: string,
callback: (?string, ?string) => void) => {
if (!userName || !password) {
callback("Missing user/password", null);
} else if (userName === "fkereki" && password === "modernjsbook") {
callback(null, "fkereki"); // OK, send userName back
} else {
callback("Not valid user", null);
}
};
测试这一点需要实际传递回调,然后尝试查看它是如何被调用的;这是可以做到的,但细节会很混乱。或者,我们可以有一个 spy-a 伪函数,稍后我们可以查询它是否被调用,使用哪些参数调用,等等:
// Source file: validate_user.test.js
/* @flow */
"use strict";
const validateUser = require("./validate_user");
describe("validateUser", () => {
let cb;
beforeEach(() => {
cb = jest.fn();
});
it("should reject a call with empty user", () => {
validateUser("", "somepass", cb);
expect(cb).toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith("Missing user/password", null);
});
it("should reject a wrong password", () => {
validateUser("fkereki", "wrongpassword", cb);
expect(cb).toHaveBeenCalledWith("Not valid user", null);
});
it("should accept a correct password", () => {
validateUser("fkereki", "modernjsbook", cb);
expect(cb).toHaveBeenCalledWith(null, "fkereki");
});
});
我们可以通过调用jest.fn()
来创建这样一个间谍。因为我们将要编写的每个测试都需要一个新的 spy,所以我们可以利用一个beforeEach()
函数,Jest 将在运行每个单独的测试之前自动调用该函数;这将节省一些额外的写作。实际上,您可以使用四个函数,如下所示:
- 在开始测试之前,只调用一次
beforeAll()
;例如,您可以在这里设置一个测试数据库,并用某些数据填充它 beforeEach()
将在每次测试之前调用,就像我们在创建间谍的示例中所做的那样- 每次测试后都会调用
afterEach()
进行清理 - 运行所有测试后调用
afterAll()
;例如,您可以销毁仅为测试目的而创建的测试数据库
所有三个测试都是相似的;我们将选择第一个。我们调用验证例程,但传递一个空参数。就验证规范而言,这应该会产生一个错误。通过这样做,我们可以测试回调是否实际被调用,并且它是通过将错误作为第一个参数传递而不是第二个参数来调用的。
(当然,第一个测试,使用.toHaveBeenCalled()
匹配器,不需要,因为第二个测试测试是否使用特定值调用,但我们只想显示一对新的匹配器。)
如果我们只关心是否调用了给定的函数,那么使用 spies 是非常实用的,但是如果测试中的函数实际上需要 Spiet 返回一些值,会发生什么呢?我们也可以解决这个问题;让我们进入一个更复杂的例子。
让我们以一个更复杂的例子来结束,这个例子是 REST 代码中处理区域的一部分,它需要一个数据库并使用承诺,还有一些复杂之处。我们以DELETE
方法处理程序为例:
const deleteRegion = async (
res: any,
dbConn: any,
country: string,
region: string
) => {
try {
const sqlCities = `
SELECT 1 FROM cities
WHERE countryCode="${country}"
AND regionCode="${region}"
LIMIT 1
`;
const cities = await dbConn.query(sqlCities);
if (cities.length > 0) {
res.status(405).send("Cannot delete a region with cities");
} else {
const deleteRegion = `
DELETE FROM regions
WHERE countryCode="${country}"
AND regionCode="${region}"
`;
const result = await dbConn.query(deleteRegion);
if (result.info.affectedRows > 0) {
res.status(204).send();
} else {
res.status(404).send("Region not found");
}
}
} catch (e) {
res.status(500).send("Server error");
}
};
通过将数据库连接(dbConn
作为参数传递给函数,我们做了一些正确的事情。这意味着我们可以模拟它,也就是说,提供一个可供选择的版本,该版本可以按照我们的需要进行操作,但不需要实际使用任何数据库。类似地,处理我们的请求需要模拟一个响应对象(res
),我们要检查其状态代码;我们可以手工编写代码,但使用node-mocks-http
包更简单,所以只需使用npm install node-mocks-http --save
安装即可。在查看其文档 https://github.com/howardabrams/node-mocks-http ,更多信息,它可以做更多!
我们知道DELETE
方法应该(1)确认要删除的区域必须没有城市,(2)如果为真,则实际删除该区域。我们如何测试第一次检查是否有效?让我们为deleteRegion()
提供一个模拟,说明我们想要删除的区域实际上有一些城市:
// Source file: src/restful_regions.test.js
/* @flow */
"use strict";
const { deleteRegion } = require("./restful_regions");
const mockRes = require("node-mocks-http");
describe("deleteRegion", () => {
let mDb;
let mRes;
beforeEach(() => {
mDb = { query: jest.fn() };
mRes = new mockRes.createResponse();
});
it("should not delete a region with cities", async () => {
mDb.query.mockReturnValueOnce(Promise.resolve([1]));
await deleteRegion(mRes, mDb, "FK", "22");
expect(mRes.statusCode).toBe(405);
});
// *continues*...
我们可以编写一个完整的模拟数据库来分析传入的查询,然后提供一些预期的答案,但在这种情况下,了解代码如何检查城市是很好的。我们可以创建一个带有查询属性(mDb.query
的模拟数据库对象,并对其进行设置,以便在第一次调用mDb.query()
时,它将返回一个承诺,该承诺解析为一个带有单个 1 的数组,因为这是实际 SQL 语句在检查实际包含一些城市的区域时所产生的结果。我们还将创建一个模拟响应对象(mRes
),该对象将获得例程的答案。
还有什么事要做?您只需调用带有所有参数的deleteRegion()
函数,await
及其结果,并验证响应状态代码是否为 405,如预期的那样;那你就完了!
其他测试类似,但我们必须模拟两个 SQL 访问,而不是一个:
// ...*continued*
it("should delete a region without cities", async () => {
mDb.query
.mockReturnValueOnce(Promise.resolve([]))
.mockReturnValueOnce(
Promise.resolve({
info: { affectedRows: 1 }
})
);
await deleteRegion(mRes, mDb, "ST", "12");
expect(mRes.statusCode).toBe(204);
});
it("should produce a 404 for non-existing region", async () => {
mDb.query
.mockReturnValueOnce(Promise.resolve([]))
.mockReturnValueOnce(
Promise.resolve({
info: { affectedRows: 0 }
})
);
await deleteRegion(mRes, mDb, "IP", "24");
expect(mRes.statusCode).toBe(404);
});
});
有趣的是,我们可以设置一个模拟函数,根据需要,每次调用它时生成不同的答案。因此,为了测试deleteRegion()
是否会正确删除没有城市的区域,我们的 mock DB 对象必须执行以下操作:
- 首先,返回一个空数组,表示要删除的区域没有城市
- 第二,返回一个带有
affectedRows:1
的对象,表示(假定的)DELETE
SQL 命令成功
在以这种方式设置之后,代码的其余部分就像我们的第一个案例;等待功能并检查状态代码。
要运行测试,我们必须在package.json
中编辑一个脚本。更改"test"
脚本,该脚本到目前为止只有一条错误消息,因此其内容如下:
"test": "jest out/"
只需输入npm
测试即可运行"test"
脚本。在我们的例子中,由于我们的输出代码进入了out/
目录,所以我们告诉 Jest 检查该目录,并运行它可以找到的所有测试(*.test.js
文件,默认情况下)。您可以针对更具体的情况修改 Jest 的配置,但通常情况下,它可以与零配置配合使用。输出简短实用,如以下屏幕截图所示:
The result of the npm test command is short and to the point
在我们的例子中,匹配我们所做的,它显示我们运行了三组测试,包括总共 10 个测试,并且它们都通过了。如果一个或多个测试产生了错误的结果,我们会得到另一种结果,带有大量红色。我故意修改了一个测试,使其失败,结果如下:
Modifying a test to make it fail, and running Jest, produces a listing including the missed expectations, the failed test, and more
在前面的屏幕截图中,我们可以在restful_regions.test.js
文件中看到一个测试失败,显示预期结果为 204,但收到的却是 404 错误。该文件标有红色FAIL
信息;另外两个文件以绿色标记为PASS
。在我们的例子中,这是因为我们故意编写了一个失败的测试,但在现实生活中,如果测试以前运行良好,现在失败了,那就意味着有人弄乱了代码,不小心引入了一个 bug。(公平地说,也存在这样的可能性,即当时的测试并不完全正确,而被测试的函数实际上是正确的!)在任何情况下,得到红色结果意味着代码不能被认为已经准备好,需要做更多的工作
如果您需要模拟某些无法(或不会)作为参数注入函数的包,您可以为 Jest 提供完整的模拟版本。假设您想要模拟"fs"
包:首先在node_modules
包的同一级别创建__mocks__
目录,然后编写并放置手动模拟代码,最后在测试文件的开头指定jest.mock("fs")
,以便Jest
使用您的模块而不是标准模块。
所有这些都可能成为一件琐事,因此您最好尝试将所有模块作为参数提供给您的函数(就像我们在删除区域时对dbConn
所做的那样),以便可以使用标准模拟。但是,如果您不能做到这一点,请查看https://facebook.github.io/jest/docs/en/manual-mocks.html 了解更多信息。
好的,您已经编写了很多测试,但是您实际测试了多少代码库?这种测试质量(广度)的度量称为覆盖率,很容易确定;在本食谱中,让我们了解如何做到这一点。幸运的是,考虑到我们所做的所有工作,这将是一个非常简单的配方。
要让Jest
生成覆盖率报告,显示测试覆盖了(或未覆盖)代码的哪些部分,您只需在package.json
文件中的相应脚本中添加一对参数:
"test": "jest out/ --coverage --no-cache"
在前一行代码中,第一个参数--coverage
告诉Jest
收集所有必要的信息,第二个参数--no-cache
确保所有信息都是新鲜的;在某些情况下,如果忽略此参数,则不会产生完全正确的结果。这对测试有何影响?让我看看!
使用 coverage 运行Jest
时的关键区别在于控制台添加了不同的报告,并且构建了一个 HTML 页面。首先,让我们看看前者:看看下面的屏幕截图——再一次,我承认看到黑白颜色真的很难!
Including coverage options when running Jest produces a more detailed analysis of your tests
对于每个文件,您将获得以下信息:
%Stmts
:由于您的测试而至少执行一次的语句的百分比。理想情况下,每条语句都应该至少执行一次;否则,任何未执行的语句都可能是任何内容,您不会意识到这一点。%Branch
:已执行的分支的百分比。其原因类似于%Stmts
——如果有一些分支(例如,一个 else)从未执行过,这意味着代码中有一些路径可以执行任何操作。%Funcs
:文件中调用的函数的百分比。%Lines
:覆盖行的百分比。请注意,一行可能有多个语句,因此%Lines
将始终大于或等于%Stmts
。Uncovered Line #s
:这不是行数(几十亿!?),而是从未执行的特定行数。
在我们的例子中,我们发现所有的函数都在validate_user.js
中进行了测试,但是roundmath.js
中有一半的函数被遗漏了(我们测试了addR()
和divR()
,但是忘记了subR()
和multR()
,所以这是正确的),只有一个函数(DELETE
处理程序)在restful_regions.js
中进行了测试。获得更好的覆盖率意味着更多的工作,从经济角度讲,目标定为 100%可能并不总是明智的(80%-90%是常见的),但 25%或 50%肯定太低了,因此需要更多的工作。
更有趣的是,通过查看项目的coverage/lcov_report/
目录并在浏览器中打开index.html
,您可以深入分析测试是如何运行的,如以下屏幕截图所示:
The main page of the web coverage report shows essentially the same data as the console run
首先,您可以看到不同颜色的文件:与往常一样,红色表示效果不太好,而绿色表示效果最好。有趣的是,如果你点击一个文件,你会得到一个详细的分析,包括每一行,它是否被执行,以及更多:
You can see which lines were executed, and which were missed, and why 100% wasn't achieved
在我们的案例中,即使我们认为我们已经在deleteRegion()
中涵盖了所有案例,但屏幕显示我们错过了一个可能的情况:SQL server 无法响应。当然,我们是否为此包含一个特定的测试是您必须做出的决定:至少我们可以看到所有最重要的代码都包含在内,但不要忘记同一文件中的其他函数,它们根本没有经过测试!
在某个时刻,您必须调试代码。只需要一点日志记录(使用 console 对象,正如我们在前面的添加 Winston日志部分的开头所看到的那样),您就可以做得很好,但是使用更强大的调试器是一个很大的帮助。在本教程中,让我们看看如何使用断点、变量检查等进行实时调试,这样您就不会局限于通过查看控制台日志来推断出问题所在。
调试有两种方法;让我们看看这两种方法。
如果您只想留在 IDE 中,VisualStudio 代码允许您直接启动调试会话。只需点击要运行的代码(提示:在 out/目录中选择代码,不要忘记使用npm run build
),然后在菜单中选择 Debug | Start Debug。窗口的外观如下所示:
You can start a debugging session right in Visual Studio Code
或者,如果你想继续使用你最喜欢的 Chrome 开发工具,你可以使用另一种方法。首先,在 Chrome 中查找N.I.M.
,即Node。js V8 Inspector Manager,可在找到 https://chrome.google.com/webstore/detail/nodejs-v8-inspector-manag/gnhhdgbaldcilmgcpfddgdbkhjohddkj ,并将其添加到浏览器中。
完成后,进入about:inspect
打开N.I.M.
控制台,您将看到如下屏幕截图所示:
The N.I.M. extension lets you debug Node sessions using Chrome's developer tools
您现在所要做的就是转到 VSC 或 shell 会话,并运行代码。在执行此操作之前,请添加--inspect
选项,如node --inspect out/restful_server.js
中所示。您将收到以下输出:
To connect Node to Chrome's developer tools, you must run your code with an extra --inspect
option
之后,将打开一个窗口,您可以完全访问 Chrome 的调试器控制台,如以下屏幕截图所示:
If you examine the URL in Chrome's debugger, you'll see something like chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=...
, followed by a URL and a (long) hexadecimal number. These values are listed after running Node
with --inspect
, in the line starting with "Debugger listening on ws...".
If N.I.M. is enabled, your Node session will connect to it, and you'll be able to debug your code from within Chrome
最后,在任何情况下,您都已准备好开始一次认真的调试会话;让我们看看你能做些什么。
If you want to learn about how code inspection works, read the following article: https://nodejs.org/en/docs/guides/debugging-getting-started/. This also gives you tips for debugging with other IDEs.
在前面的截图中,使用 VSC 和 Chrome,我打开了out/restful_regions.js
文件,并在SELECT
完成的地方设置了一个断点,以获得一些区域。对/regions/uy
执行请求导致运行在此点暂停。然后,您可以执行以下操作:
- 检查所有变量,包括块变量、局部变量和全局变量。如果需要,可以修改它们的值
- 添加一些变量或表达式进行观察;每当执行暂停时,您都会看到它们的值
- 请参阅调用堆栈
- 设置一些特定的断点
对于程序执行,您可以执行以下操作:
- 在任何断点停止执行
- 重新启动执行
- 逐步浏览代码,选择钻取**向下分析函数调用
如果您使用 Chrome,您将能够获得一些额外的选项,如内存使用情况分析或代码执行评测,但很明显,特定于 web 的选项不会有任何好处。然而,通过使用检查选项调试代码对 bug 追踪是一个很好的帮助,所以要习惯它;你会非常感激的!
无论何时创建服务,都需要某种方法来测试它们。到目前为止,我们已经看到了一些使用curl
实现这一点的示例。所以,在这个食谱中,让我们深入一点,看看一些你可能会发现有用的选项。或者,您可以选择其他工具,例如wget
。就我们的目的而言,这两个选项大致相当,允许我们做 RESTful 服务测试所需的一切:它们可以编写脚本,可以下载东西,还可以发送发布数据的请求,因此您使用什么主要取决于个人偏好。
If you want to read more about curl
, check out its site at https://curl.haxx.se/, or the source code at https://github.com/curl/curl. You may also be interested in Everything Curl, a book that details all there is to know about this tool, and which is freely available at https://www.gitbook.com/download/pdf/book/bagder/everything-curl—however, do take into account that it's over 300 pages long!
如何安装curl
将取决于您的操作系统,但它几乎适用于您可能使用的所有平台;请在查看所有下载内容 https://curl.haxx.se/download.html 。该命令有几十个可能的选项,但出于我们的目的,我们将查看下表。请注意,大多数选项有两个版本:一个是短的单字符版本,另一个是长的版本,旨在更清楚地理解:
| -K filename``--config filename
| 用于指定包含选项的文件的名称,以便命令更短。在给定的文件中,每个选项将位于不同的行中。 |
| -d key=value``--data key=value
| 允许您在请求正文中发送数据。如果您多次使用此选项,curl
将使用&
作为分隔符,作为标准。 |
| --data-binary someData
| 类似于--data
,但用于发送二进制数据。最常见的情况是后面跟着@filename
,这意味着将发送指定文件的内容。 |
| -D filename``--dump-header filename
| 将接收数据的标题转储到文件中。 |
| -H "header:value"``--header "header:value"
| 允许您设置并发送带有请求的某些标头。您可以多次使用此选项来设置多个标题。 |
| -i``--include
| 在输出中包括接收数据的标题。 |
| -o filename``--output filename
| 将接收到的数据存储在给定文件中。 |
| -s``--silent
| 最小化控制台的输出。 |
| -v``--verbose
| 最大化控制台的输出。 |
| -X method``--request method
| 指定将使用哪个 HTTP 方法,例如GET
、POST
、PUT
等。 |
最后,如果您需要帮助,请使用curl --help
或curl --manual
,您将获得该实用程序及其选项的完整描述。现在让我们看看如何使用curl
来测试我们的服务。
让我们对上一章中创建的 RESTful 服务器进行一整套测试,并启用所有选项,包括 JWT,您会记得,为了简化代码,我们删除了 JWT!让我们按照以下步骤进行操作:
首先,我们可以验证服务器是否已启动并正在运行;/
路由没有令牌要求。请记住,我们使用的是8443
,实际的 HTTPS:请求将发送到该端口:
> curl localhost:8443/
Ready
现在,如果我们试图访问某个地区,我们将被拒绝,因为缺少授权 JWT:
> curl localhost:8443/regions/uy/10
No token specified
- 如果该行以
*
开头,则它是来自curl
本身的一些信息 - 如果该行以
>
开头,则它是随请求一起发送的标头 - 如果该行以
<
开头,则它是一个已接收的标头
在下面的列表中,我突出显示了传入的数据:
> curl localhost:8443/regions/uy/10 --verbose
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8443 (#0)
> GET /regions/uy/10 HTTP/1.1
> Host: localhost:8443
> User-Agent: curl/7.59.0
> Accept: */*
>
< HTTP/1.1 401 Unauthorized
< X-Powered-By: Express
< Access-Control-Allow-Origin: *
< Connection: close
< Content-Type: text/html; charset=utf-8
< Content-Length: 18
< ETag: W/"12-s2+Ia/H9PDrgc59/6Z0mcWLfxuw"
< Date: Sun, 03 Jun 2018 21:00:40 GMT
<
* Closing connection 0
No token specified
我们可以使用/gettoken
路由并提供user
和password
值来获取令牌。让我们将收到的令牌存储在一个文件中,以简化未来的测试:
> curl localhost:8443/gettoken -d "user=fkereki" -d "password=modernjsbook" -o token.txt
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 187 100 153 100 34 149k 34000 --:--:-- --:--:-- --:--:-- 182k
> cat token.txt
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI4MDU5Nzc0LCJleHAiOjE1MjgwNjMzNzR9.6tioV798HHqriOFkhUpf8xJc8wq5TY5g-jN-XhgwaTs
现在我们可以尝试一个简单的GET
。至少在基于 Linux 的系统中,我们可以剪切并粘贴头中的令牌,或者使用一些 shell 功能,并利用 back tick 选项将令牌文件的内容包括在请求中:
> curl localhost:8443/regions/uy/10 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI4MDU5Nzc0LCJleHAiOjE1MjgwNjMzNzR9.6tioV798HHqriOFkhUpf8xJc8wq5TY5g-jN-XhgwaTs"
[{"countryCode":"UY","regionCode":"10","regionName":"Montevideo"}]
> curl localhost:8443/regions/uy/10 -H "Authorization: Bearer `cat token.txt`"
[{"countryCode":"UY","regionCode":"10","regionName":"Montevideo"}]
我们所能做的就是尝试其他的路线和方法。让我们把蒙得维的亚的名字改为 MVD,实际上是国际航空运输协会国际机场的代码;我们将首先执行一个PUT
(它将生成一个 204 状态代码),然后执行一个GET
来验证更新:
> curl localhost:8443/regions/uy/10 -H "Authorization: Bearer `cat token.txt`" -X PUT -d "name=MVD" --verbose
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8443 (#0)
> PUT /regions/uy/10 HTTP/1.1
> Host: localhost:8443
> User-Agent: curl/7.59.0
> Accept: */*
> Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI4MDU5Nzc0LCJleHAiOjE1MjgwNjMzNzR9.6tioV798HHqriOFkhUpf8xJc8wq5TY5g-jN-XhgwaTs
> Content-Length: 8
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 8 out of 8 bytes
< HTTP/1.1 204 No Content
< X-Powered-By: Express
< Access-Control-Allow-Origin: *
< Connection: close
< Date: Sun, 03 Jun 2018 21:09:01 GMT
<
* Closing connection 0
> curl localhost:8443/regions/uy/10 -H "Authorization: Bearer `cat token.txt`"
[{"countryCode":"UY","regionCode":"10","regionName":"MVD"}]
在一个实验中,我创建了一个编号为 20 的新区域。让我们删除它并验证它是否与另一个GET
一起消失。第一个请求应获得 204 状态,第二个请求应获得 404 状态,因为该区域将不再存在:
> curl localhost:8443/regions/uy/20 -H "Authorization: Bearer `cat token.txt`" -X DELETE --verbose
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8443 (#0)
> DELETE /regions/uy/20 HTTP/1.1
> Host: localhost:8443
> User-Agent: curl/7.59.0
> Accept: */*
> Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJma2VyZWtpIiwiaWF0IjoxNTI4MDU5Nzc0LCJleHAiOjE1MjgwNjMzNzR9.6tioV798HHqriOFkhUpf8xJc8wq5TY5g-jN-XhgwaTs
>
< HTTP/1.1 204 No Content
< X-Powered-By: Express
< Access-Control-Allow-Origin: *
< Connection: close
< Date: Sun, 03 Jun 2018 21:12:06 GMT
<
* Closing connection 0
> curl localhost:8443/regions/uy/20 -H "Authorization: Bearer `cat token.txt`" -X DELETE --verbose
.
. *several lines snipped out*
.
< HTTP/1.1 404 Not Found
.
. *more snipped lines*
.
Region not found
最后,让我们发明一个新区域来验证POST
是否也有效;应返回 201 状态,以及新 ID(在我们删除之前发明的第 20 个乌拉圭地区后为 20):
> curl localhost:8443/regions/uy -H "Authorization: Bearer `cat token.txt`" -X POST -d "name=Fictitious" --verbose
.
. *lines snipped out*
.
< HTTP/1.1 201 Created
< X-Powered-By: Express
< Access-Control-Allow-Origin: *
< Connection: close
< Location: /regions/uy/20
.
. *snipped lines*
.
Region created
> curl localhost:8443/regions/uy -H "Authorization: Bearer `cat token.txt`"
[{"countryCode":"UY","regionCode":"1","regionName":"Artigas"},{"countryCode":"UY","regionCode":"10","regionName":"MVD"},
.
. *snipped out lines*
.
{"countryCode":"uy","regionCode":"20","regionName":"Fictitious"},
.
. *more snipped out lines*
.
{"countryCode":"UY","regionCode":"9","regionName":"Maldonado"}]
因此,通过使用curl
和一些控制台工作,我们可以开始测试任何类型的服务。然而,在某些情况下,您可能需要处理更复杂的服务调用序列,而手工完成所有这些工作可能会成为一件琐事。事实上,通过仔细的脚本编写,你可以简化你的工作,但让我们考虑另一个工具,邮递员,更适合这种工作。
手动测试服务,甚至使用精心编制的 shell 脚本测试服务,其实并不容易。此外,如果您需要某种复杂的测试,那么使用脚本可能会证明太难了。Postman
可用于为服务编写测试,将它们组织成完整的测试套件,并记录 RESTful API 的工作方式。您也可以使用它来模拟服务或作为开发中的帮助,但我们这里不讨论这一点。在本食谱中,我们将重点介绍测试方面。
从下载Postman
https://www.getpostman.com/ ,并根据每个平台的说明进行安装。请记住查看它的文档,了解更多我们在这里不会看到的功能。
Postman 允许您创建可以存储在集合中的请求。在每个请求之前和之后,您可以执行 JavaScript 代码,以设置即将到来的请求,处理结果响应,或者在测试序列中存储未来请求的一些信息。让我们来看看下面的部分。
首先,我们将从一个简单的测试开始,以获取 JWT,然后存储 JWT,以便在接下来的测试中使用它。打开Postman
应用,点击新建创建请求。给它一个名称和描述,然后选择或创建一个集合或文件夹来保存它。不要太担心实际的位置;您将能够四处移动请求、编辑请求,等等。
然后,为了获得令牌,我们需要一个POST
,所以适当地设置方法。选择 BODY 选项卡,选择x-www-form-urlencoded
选项,并添加两个值user
和password
,这两个值将随请求一起发送。(对于其他情况,您可以发送原始数据(如 XML 或 JSON)或二进制数据(如文件)
Creating a POST request to get a JWT
现在,如果您通过单击“发送”进行测试,请求将转到您的服务器,答案将显示在屏幕底部:
A test run of our request shows that everything is working fine
然而,这还不够。我们不只是想检查/gettoken
端点是否工作,我们想测试令牌是否正确,如果正确,存储它以便以后的请求可以使用它。我们将创建一个环境(点击右上角的齿轮)并添加一个token
条目,以便我们可以存储和检索从服务器获得的值:
Creating an environment is one way you can share data between requests
最后,让我们为令牌编写一些测试,并将其值存储在环境中。编写测试本身与我们已经做的有点类似,但是您必须查看文档以了解哪些对象和方法可用。至于测试本身,他们使用Chai
(见http://www.chaijs.com/ ),这与Jest
在写你的期望时类似,但并不完全相同:
pm.test("Response is long enough", () =>
pm.expect(pm.response.text()).to.have.lengthOf.above(40));
pm.test("Response has three parts", () =>
pm.expect(pm.response.text().split(".")).to.have.lengthOf(3));
pm.environment.set("token", pm.response.text()); // for later scripts
首先,我们将测试答案应该至少有 40 字节长;令牌没有特殊的大小限制,但 40 个字符的大小较低。然后,第二个测试将检查令牌是否由三个部分组成,以句点分隔。最后,我们将响应本身存储在环境中,以备将来使用。如果选中测试选项卡,您将看到我们的两个测试都通过了,如以下屏幕截图所示:
Both tests we created were successful
如果您检查环境,您将看到令牌已存储。现在,让我们编写第二个测试,GET
,它将使用令牌。我通过请求/regions/uy
完成了一个类似的序列,但我在标题中添加了一行,带有Authorization
键和Bearer {{token}}
值,以便在标题中替换先前存储的令牌值。我还添加了一些测试,以确保(1)我得到了一个成功的 JSON 答案,(2)答案是一个至少包含 19 个区域的数组。(是的,我知道我的国家乌拉圭正好有 19 个地区,但有时出于测试目的,我可能会添加一些新的地区!)测试显示了一些我们以前从未见过的功能:
pm.test("Answer should be JSON", () => {
pm.response.to.be.success;
pm.response.to.have.jsonBody();
});
pm.test("Answer should have at least 19 regions", () => {
const regions = JSON.parse(pm.response.text());
pm.expect(regions).to.have.lengthOf.at.least(19);
});
通过这种方式,您可以创建完整的请求序列;确保获取 JWT 已放在列表的前面。在一个集合中,还可以有许多文件夹,每个文件夹都有一系列不同的步骤。(您也可以通过编程方式更改序列,但我们不会在这里进行讨论;请查看https://www.getpostman.com/docs/v6/postman/scripts/branching_and_looping 了解更多信息。)
我创建了两个文件夹来测试一些GET
和一个DELETE
——但是,当然,您应该编写更多的测试来验证每个方法,以及尽可能多的不同序列。让我们看看如何让他们跑。
将请求组织到文件夹中后,可以通过单击左侧侧栏上的任意给定序列来运行该序列。如果一切正常,您将获得所有测试的绿色分数;红色标记突出显示一个问题:
Running a collection runs every test in it. Green blocks show successes; red ones mark errors.
有了这一点,您已经有了一个很好的工具来记录您的 API(确保每个测试和字段都有一个解释),并确保它继续工作,超越单元测试进入完整的端到端(E2E测试。
Depending on your Postman
account, you can also set things up to get periodical monitoring of your API; check out https://www.getpostman.com/docs/v6/postman/monitors/intro_monitors for more information.
通过使用newman
包(与npm install newman --save-dev
一起安装),您可以从命令行运行Postman
测试,这也允许您将它们包括在持续集成工作流中。首先,将您的收藏从Postman
导出(令人惊讶的是,我将我的收藏命名为postman_collection.json
,然后向您的package.json
文件中添加一个名为"newman":"newman run postman_collection.json"
的新脚本。然后使用npm run newman
将产生如下代码片段所示的输出。您还可以测试所有测试运行是否令人满意或是否存在问题:
> npm run newman
> simpleproject@1.0.0 newman /home/fkereki/MODERNJS/chapter05
> newman run postman_collection.json
newman
Restful server testing for regions
❏ Test Delete
↳ Get JWT
POST localhost:8443/gettoken [200 OK, 386B, 14ms]
✓ Response is long enough
✓ Response has three parts
↳ Delete non-existing region
DELETE localhost:8443/regions/zz/99 [404 Not Found, 255B, 4ms]
✓ Status code is 404 baby!!
❏ Test Get
↳ Get JWT
POST localhost:8443/gettoken [200 OK, 386B, 2ms]
✓ Response is long enough
✓ Response has three parts
↳ Get /regions/uy
GET localhost:8443/regions/uy [200 OK, 1.46KB, 2ms]
✓ Answer should be JSON
✓ Answer should have at least 19 regions
↳ Get /regions/uy/10
GET localhost:8443/regions/uy/11 [200 OK, 303B, 2ms]
✓ Answer has a single region
✓ Country code is UY
✓ Region code is 11
✓ Region name is Paysandu
✓ Answer is valid, JSON
现在,让我们更多地关注文档和使用一个著名工具进行测试:Swagger
。这是一个旨在帮助您设计、建模和测试 API 的工具。关键的想法是,您最终将拥有一个在线的交互式文档,详细描述您的所有 API 调用、参数类型和限制、必需的和可选的值,等等,甚至可以让您动态地尝试调用*,以更好地理解 API 的用途。*
*# 怎么做。。。
第一个也是最难的-Swagger
设置的一部分是为您的完整 API 准备规范。这意味着要用YAML 不是标记语言(YAML)编写,可能很难正确理解。但是,您可以使用 web 编辑器,您可以在自己的服务器上运行该编辑器(转到https://swagger.io/tools/swagger-editor/ 下载)或在线https://editor.swagger.io 。然而,在写了这篇文章之后,设置一切都将非常简单,只需要三行代码!
YAML is a recursive acronym that stands for YAML Ain't Markup Language. If you want to learn more about it, visit http://yaml.org/.
我们不能在这里介绍编写 API 规范的完整规则,也不能在我们的示例中包含它的所有特性。此外,任何 API 的完整描述都可能长达数百行,这是另一个问题。所以,让我们看一下一些基本的定义,以及一些服务,来了解需要做些什么。首先,我们需要一些关于服务器的基本数据:
swagger: "2.0"
info:
description: "This is a RESTful API to access countries, regions, and cities."
version: "1.0.0"
title: "World Data API"
host: "127.0.0.1:8443"
schemes:
- "http"
然后我们必须描述我们的文档将分成的标签(想想部分。我们使用代币(用于安全),再加上国家、地区和城市,因此这些似乎是必要的定义:
tags:
- name: "token"
description: "Get a JWT for authorization"
- name: "countries"
description: "Access the world countries"
- name: "regions"
description: "Access the regions of countries"
- name: "cities"
description: "Access the world cities"
让我们看一下路线。我们定义了一个 POST 请求,它获取主体编码的参数,并返回纯文本。需要两个字符串参数user
和password
。如果一切正常,API 可能返回 200 状态,否则返回 401 状态:
paths:
/gettoken:
post:
tags:
- "token"
summary: "Get a token to authorize future requests"
consumes:
- "application/x-www-form-urlencoded"
produces:
- text/plain
parameters:
- in: formData
name: user
required: true
type: string
- in: formData
name: password
required: true
type: string
responses:
200:
description: A valid token to use for other requests
401:
description: "Wrong user/password"
为一个国家获取地区将获得类似的规范:
/regions:
get:
tags:
- "regions"
summary: "Get all regions of all countries"
produces:
- application/json
parameters:
- in: header
name: "Authorization"
required: true
type: string
description: Authorization Token
responses:
200:
description: "OK"
401:
description: "No token provided"
要启用Swagger
文档,我们需要swagger-ui-express
包,还需要加载 YAML 规范的 JSON 版本,因此需要几行代码。首先,使用通常的npm install swagger-ui-express --save
安装软件包,然后将以下行添加到服务器:
const swaggerUi = require("swagger-ui-express");
const swaggerDocument = require("../swagger.json");
在服务器中,我们还必须在开始时,在其他app.use()
语句之后添加一行以启用新路由。我们正在将Swagger
添加到 RESTful API 中,并且没有令牌:您可能更喜欢设置不同的服务器,只提供对 API 的访问,还可能启用授权,但这两个更改都很容易完成。那么,让我们在这里使用更简单的版本:
app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
你都准备好了!重建项目并启动服务器后,新路由将可用,为服务器提供在线文档。
如果启动服务器,访问/api-docs
路由将提供对Swagger
主屏幕的访问,如下所示:
Swagger produces a main page, with access to every route you defined
交互很简单:选择一个区域,点击一个给定的请求,你会得到所有路线和操作的列表。例如,让我们看看如何获得乌拉圭的地区。首先,我们必须获得一个令牌,因此我们希望打开令牌区域并输入必要的用户和密码,如以下屏幕截图所示:
Doing a request is just a matter of filling the fields and executing the query
当流程运行时,您将得到答案,如以下屏幕截图所示:
A successful request returned the security token
您可以在顶部看到等效的curl
请求,它与我们在本章前面的测试来自命令行部分的简单服务相匹配。现在,复制该令牌并将其粘贴到/regions/uy
端点意味着我们已经准备好进行该查询:
After getting a token, we can set up the query to get all of the regions of a country
剩下要做的就是执行该查询,我们将得到所需的结果,如以下屏幕截图所示:
Doing sequences of calls is possible, and Swagger lets you experiment easily with different endpoints
我们能指出什么?首先,显然,Swagger
在文档方面是一个非常好的工具。您可以向方法、参数、结果添加描述,甚至包括示例值和结果。这意味着需要使用您的 API 的开发人员将有一个非常好的学习如何使用它的方法。就实际使用您的 API 而言,Swagger
比curl
或Postman
简单,但它不能链接操作,这需要您自己完成。您应该真正考虑使用这个工具开始您的开发,并且只有在您将所有内容都记录在案之后才能继续实际的编码;试试看!*****