深入浅出nodeJs-测试

测试

测试的意义在于,在用户消费产出的代码之前,开发者首先消费它,给予其重要的质量保证。

单元测试

开发者应该吃自己的狗粮。如果开发者不自己测试代码,那必然要面对如下问题

  1. 测试工程师是否可依赖? 第一个层面是测试工程师是否熟悉 Node 领域
  2. 第三方代码是否可信赖?
  3. 在产品迭代过程中,如何继续保证质量?

另一个对单元测试持疑的观点是,如果要在项目中进行单元测试,那么势必会影响开发者的项目进度。这个答案是肯定的,因为产出品质可以久经考验的产品,必然要花费较多的经历

单元测试只是在早期会多花费一定的成本,但这个成本要远远低于后期深陷维护泥潭的投入。这里是朝三暮四还是朝四暮三的选择

  • 单一职责。如果一段代码承担的职责越多,为其编写单元测试的时候就要构造更多的输入数据,然后推测它的输出。
  • 接口抽象。通过对程序代码进行接口抽象后,我们可以针对接口进行测试,而具体代码实现的变化不影响为接口编写的单元测试。
  • 层次分离。层次分离实际上是单一职责的一种实现。而 MVC 结构就是典型的层次奋力模型,如果不分离,无法想象这个代码该如何切入测试

单元测试介绍 单元测试主要包含断言、测试框架、测试用例、测试覆盖率、mock、持续集成等几个方面,它还会加入衣不带吗测试和私有方法的测试这两个部分 01. 断言。开发者通常仅仅在 test.js 或 demo.js 里看到实例代码,这对想进一步使用模块的用户存在心里负担。比如

var readOF = require("readof");
readOF.read(pic, target_path, function(error, data) {
    // do something 
});

此类代码对质量没有任何保证,主要是

  • 没有对输出结果进行任何的检测
  • 输入条件覆盖率并不完备

如果有对 NOde 的源码进行过研究,会发现 Node 中存在 assert 这个模块,以及很多模块都调用了这个模块。断言的解释是

在程序设计中,断言是一种放在程序中的一阶逻辑,目的是为了标示程序开发者预期的结果--当程序运行到断言的位置时,对应的断言应该为真。若断言不为真,程序会终止允许,并出现错误信息

以下是 assert 模块的工作方式:

var assert = require('assert');
// 一旦 assert.equal 不满足期望,将会抛出 AssertionError 异常,整个程序将会停止运行
assert.equal(Math.max(1, 100), 100);

断言有以下几种方法

  • ok(): 判断结果是否为真
  • equal(): 判断实际值与期望值是否相等
  • notEqual(): 判断实际值与期望值是否不相等
  • deepEqual(): 判断实际值与期望值是否深度相等
  • notDeepEqual(): 判断实际值与期望值是否不深度相等
  • strictEqual(): 判断实际值与期望值是否不严格相等
  • notStrictEqual(): 判断实际值与期望值是否不严格相等
  • throws(): 判断代码块是否抛出异常
  • doesNotThrow(): 判断代码块是否没抛出异常
  • ifError(): 判断实际值是否是一个假值(null、undefined、0、''、false),如果实际值为真值,抛出异常
  1. 测试框架。这些断言一旦检查失败,将会抛出异常停止整个应用,这对于大规模断言检查时并不友好。更好地方法是,记录下抛下的异常并继续执行,最后生成测试报告。这些任务的承担者是测试框架
  • 测试风格。流行的但与测试风格有 TDD 和 BDD 两种,差别如下
    • 关注点不同。TDD 关注所有功能是否被正确实现,每一个功能都具备对应的测试用例;BDD 关注整体行为是否符合预期,适合自顶向下的设计方式
    • 表达方式不同。TDD 的表述方式偏向于功能说明书的风格;BDD 的表述方式更接近于自然语言的习惯

BDD 风格的示例如下

describe('Array', function() {
    before(function() {
        // ... 
    });
    describe('#indexOf()', function() {
        it('should return -1 when not present', function() {
            [1, 2, 3].indexOf(4).should.equal(-1);
        });
    });
});

BDD 风格的组织示意图如下

BDD风格的组织示意图

TDD 风格的示例如下

suite('Array', function() {
    setup(function() {
        // ... 
    });
    suite('#indexOf()', function() {
        test('should return -1 when not present', function() {
            assert.equal(-1, [1, 2, 3].indexOf(4));
        });
    });
});

TDD 对测试用例的组织主要采用 suite 和 test 完成。suite 也可以实现多层级描述,测试用例用 test。它提供的钩子函数仅包含 setup 和 teardown,对应 BDD 中的 before 和 after

TDD 风格的组织示意图如下

TDD 风格的组织示意图

  • 测试报告。调用mocha --reporters即可查看所有报告格式。使用mocha -help命令可以看到更多的帮助信息来了解如何使用它们
  1. 测试代码的文件组织。想让单元测试顺利运行起来,请记得在宝描述文件中添加相应模块的依赖关系。
  2. 测试用例。一个行为或功能需要有完善的、多方面的测试用例,一个测试用例中包含至少一个断言。
  3. 测试覆盖率。测试覆盖率能够概括性地给出整体的覆盖度,也能明确地给出统计到行的覆盖情况
  4. mock。其实就是模拟异常
  5. 私有方法的测试。

性能测试

完成代码的行为检测后,还需要对已有代码的性能做出评估,检测已有功能是否满足生产环境的性能要求,性能也是功能

基准测试 基准测试要统计的就是在多少时间内执行了多少次某个方法,可以使用 benchmark 模块

var Benchmark = require('benchmark');
var suite = new Benchmark.Suite();
var arr = [0, 1, 2, 3, 5, 6];
suite.add('nativeMap', function() {
    return arr.map(callback);
}).add('customMap', function() {
    var ret = [];
    for (var i = 0; i < arr.length; i++) {
        ret.push(callback(arr[i]));
    }
    return ret;
}).on('cycle', function(event) {
    console.log(String(event.target));
}).on('complete', function() {
    console.log('Fastest is ' + this.filter('fastest').pluck('name'));
}).run();

压力测试 对网络接口做压力测试需要考察的几个指标有吞吐率、响应时间和并发数,这些指标反映了服务器的并发处理能力

基准测试驱动开发 BDD(Benchmark Driven Development) 即基准测试驱动开发,分为以下几步 01. 写基准测试 02. 写/改代码 03. 收集数据 04. 找出问题 05. 回到第(2)步

产品化

在实际的产品中,需要很多非编码相关的工作以保证项目的进展和产品的正常运行等,这些细节包括工程化、架构、容灾备份、部署和运维等。

项目工程化

工程化可以理解为项目的组织能力,体现在文件上,就是文件的组织能力。

目录结构 普通的模块应用遵循 CommonJS 的模块和包规范即可,下面是某个项目文件结构

$ tree -L 2 
. 
 History.md // 项目该动历史
 INSTALL.md // 安装
 Makefile // Makefile文件
 benchmark // 基准测试
 controllers // 控制器
 lib // 有模块化的文件目录
 middlewares // 中间件
 package.json // 包描述文件目配置 proxy // 数据代理目录类MVC中的M
 test // 测试目录
 tools // 工具目录
 views // 视图目录
 routes.js // 路注表
 dispatch.js // 多进程理
 README.md // 目文件
 assets // 静态文件目录
 assets.json // 静态文件与CDN路径的文件 bin // 执行本
 config // 配置目录
 logs // 日志目录
 app.js // 工作进程

这个项目结构将各种功能的文件分门别类地归类到目录中,其中包含普通的 MVC 约定和 CommonJS 模块约定以及一些自有约定

编码规范 建议在项目一开始就指定基本的编码规范,让团队形成统一的风格

代码审查 代码审查在请求合并的过程中完成,需要审查的点有功能是否正确完成、编码风格是否符合规范、单元测试是否有同步添加等等。如果不符合规范,就需要重新更改代码,然后提交审查

部署流程

代码在完成开发、审查、合并之后,才会进入部署流程。

部署环境 之所以要准备专有的测试环境,是为了排除掉无关因素的影响。我们将普通测试环境称为 pre-release 环境,实际生产环境称为 product 环境

部署操作 启动进程很容易,我们还有两个需求需要考虑-停止进程和重启进程,我们可以通过脚本来实现应用的启动、停止和重启等操作,代码如下

#!/bin/sh

DIR = `pwd`
NODE = `which node`
# get action
ACTION = $1
# help
usage() {
    echo "Usage: ./appctl.sh {start|stop|restart}"
    exit 1;
}
get_pid() {
    if [-f. / run / app.pid];
    then
    echo `cat ./run/app.pid`
    fi
}
# start app
start() {
    pid = `get_pid`
    if [!-z $pid];
    then
    echo 'server is already running'
    else
        $NODE $DIR / app.js 2 > & 1 &
        echo 'server is running'
    fi
}
# stop app
stop() {
    pid = `get_pid`
    if [-z $pid];
    then
    echo 'server not running'
    else
        echo "server is stopping ..."
    kill - 15 $pid
    echo "server stopped !"
    fi
}

restart() {
stop
sleep 0.5
echo === ==
    start
}
case "$ACTION" in
start)
start
;;
stop)
stop
;;
restart)
restart
;;
*)
usage
;;
esac

部署过程中,只需要执行 bash 脚本即可

./appctl.sh start 
./appctl.sh stop 
./appctl.sh restart

性能

有一些常见的提升性能的方法

动静分离 将图片、脚本、样式表和多媒体等静态文件都引导到专业的静态文件服务器上,让 Node 只处理动态请求即可。这个过程可以使用 Nginx 或专业的 CDN 来处理

静态文件和动态请求分离只是最简单的分离,比如在 node 中将北荣发送至客户端时需要进行字符串到 Buffer 的转换,但对于静态内容可以直接进行 Buffer 传输

启用缓存 提升性能其实差不多只有两个途径,一是提升服务的速度,二是避免不必要的计算,而避免不必要的计算,应用场景最多的地方就是缓存,比如使用 Redis 或 Memcached

多进程架构 多进程架构不仅可以充分利用多核 CPU,更是可以建立机制让 Node 进程更加健壮,以保障 Web 应用的持续服务

读写分离 这里主要针对数据库而言,读取的速度远高于写入的速度。某些数据库在写入时为了保证一致性,会进行锁表操作,这同时会影响到读取的速度

日志

完善的日志记录能够还原问题现场。通过记录日志来定位问题是一种成本较小的方式

访问日志 访问日志用来记录每个客户端对应用的范文。在 Web 应用中,主要记录 HTTP 请求中的关键数据

中间件框架 Connect 提供了一个日志中间件,可以将关键数据按一定格式输出到日志文件中

var app = connect();
// 录访问日志
connect.logger.format('home', ':remote-addr :response-time - [:date] ":method :url HTTP /: http - version " :status :res[content-length] ": referrer " ": user - agent " :res[content-length]');
app.use(connect.logger({
    format: 'home',
    stream: fs.createWriteStream(__dirname + '/logs/access.log')
}));

异常日志 通过日志的记录,开发者可以更具异常信息去定位 bug 出现的具体位置,以快速修复问题

  • console.log: 普通日志
  • console.info: 普通信息
  • console.warn: 警告信息
  • console.error: 错误信息

console 模块在具体实现时,log 与 info 方法都将信息输出给标准输出 process.stdout ,warn 与 error 方法则将信息输出到标准错误 process.stderr ,而 info 和 error 分别是 log 和 warn 的别名

console 对象有一个 Console 属性,它是 console 对象的构造函数,借助这个构造函数,我们可以实现自己的日志对象

var info = fs.createWriteStream(logdir + '/info.log', {
    flags: 'a',
    mode: '0666'
});
var error = fs.createWriteStream(logdir + '/error.log', {
    flags: 'a',
    mode: '0666'
});
var logger = new console.Console(info, error);
logger.log('Hello world!');
logger.error('segment fault');

有了记录信息的日志 API 后,开发者需要关心的是小心捕获每一个异常。对于回调函数中产生的异常可以不用过问,交给全局的 uncaughtException 时间捕获

在逐层次的异步 API 调用中,异常是该传递给调用方还是该立即通过日志记录,是一个需要注意的问题,尽量不要隐藏错误。对于底层 API 的设计而言,尤为重要。事实上,日志通常是服务于业务的

对于最上层的业务,不能无视下层传递过来的任何异常,需要记录异常,以便将来排查错误,同时给用户友好的提示

日志与数据库 将日志分析和日志记录这两个步骤分开来是比较好的选择,日志记录可以在线写,日志分析则可以借助一些工具同步到数据库中,通过离线分析的方式反馈出来

分割日志 将产生的日志按日期分割是一个不错的注意。我们可以按日期传递对应的日志文件可写流对象

监控报警

应用的监控主要有两类,一种是业务逻辑型的监控,一种是硬件型的监控。监控主要通过定时采样来进行记录。除此之外,还要对监控的信息设置上限,一旦出现大的波动,就需要发出警报提醒开发者

监控 监控的点可以很细致,也可以只选主要的指标 01. 日志监控。 通过监控异常日志文件的变动,将新增的异常按异常类型和数量反映出来。

除了异常日志的监控,对于访问日志的监控也能体现出实际业务的 QPS 值

此外,从访问日志中也能实现 PV 和 UV 的监控。可以很好地直到应用使用者们的习惯、预知访问高峰等

  1. 响应时间。响应时间可以在 Nginx 一类的反向代理商监控,也可以通过应用自行产生的访问日志来监控。健康的系统响应时间应该是波动较小的、持续均衡的

  2. 进程监控。监控日志和响应时间都能较好地监控到系统的状态,但它们的前提是系统是运行状态的,所以监控进程是比前两者更为精要的任务。对于多进程应用需要检查工作进程的数量

  3. 磁盘监控。磁盘监控主要是监控磁盘的用量,一旦磁盘用量超过警戒值,就该整理日志或清理磁盘了

  4. 内存监控。一旦出现内存泄漏,不是那么容易排查的,在访问量大的时候上升,在访问量回落的时候,占用量随之回落。如果突然出现内存异常,也能够追踪到是近期那些代码改动导致的问题

  5. CPU占用监控。CPU 的使用分为用户态、内核态、IOWait 等。如果用户态 CPU 使用了率较高,说明服务器上的应用需要大量的 CPU 开销;如果内核态 CPU 使用率较高,说明服务器花费大量时间进行进程调度或系统调用;IOWait 使用率则反映的是 CPU 等待磁盘 I/O 操作

CPU 使用率中,用户态小于 70%、内核态小于 35%且整体小于 70%时,处于将康状态。

  1. CPU load 监控。CPU load 又称 CPU 平均负载,它用来描述操作系统当前的繁忙程度

  2. I/O 负载。主要是磁盘 I/O,大多数的 I/O 压力来自于数据库

  3. 网络监控。需要对流量进行监控并设置上限值。即便应用突然受到用户的青睐,流量爆涨时也能通过数值感知到网站的宣传是否有效。

网络流量监控的两个指标是流入流量和流出流量

  1. 应用状态监控。应用应当提供一种机制来反馈其自身的状态信息,外部监控将会持续性地调用应用的反馈接口来检查它的将康状态

  2. DNS 监控。DNS 服务一旦出现故障,就可能是史无前例的故障。对于产品的稳定性,域名 DNS 状态也需要加入监控

报警的实现 最普通的邮件报警、IM 报警适合在线工作状态,短信或电话报警适合非在线状态

  • 邮件报警。可以调用 nodemailer 模块来实现邮件的发送
var nodemailer = require("nodemailer");
// 建立一个 SMTP 传输连接
var smtpTransport = nodemailer.createTransport("SMTP", {
    service: "Gmail",
    auth: {
        user: "gmail.user@gmail.com",
        pass: "userpass"
    }
});
// 邮件选项
var mailOptions = {
    from: "Fred Foo ✔ <foo@bar.com>", // 发件ටᆰ件ں኷
    to: "bar@bar.com, baz@bar.com", // 收件ටᆰ件ں኷列表
    subject: "Hello ✔", // Ք题
    text: "Hello world ✔", // 纯文本内容
    html: "<b>Hello world ✔</b>" // HTML内容
}
// 发送邮件
smtpTransport.sendMail(mailOptions, function(err, response) {
    if (err) {
        console.log(err);
    } else {
        console.log("Message sent: " + response.message);
    }
});
  • 短信或电话报警。一些断性服务平台提供短信接入服务。

稳定性

关于应用的稳定性,单独一台服务器满足不了业务无限增长的需求,这就需要 Node 按多进程的方式部署到多台机器中。为了更好地稳定性,电线的水平扩展方式就是多进程、多机器、多机房

异构共存

在应用 Node 的过程中,不存在为了用它而推翻已有设计的情况。Node 能够通过协议与已有的系统很好地异构共存。将 Node 用于系统改良的开发者需要考虑的是已有的系统是否具备良好的服务化、是否支持多种终端,是否支持多种语言调用