2016年2月28日星期日

阮一峰的网络日志

阮一峰的网络日志


Linux 守护进程的启动方法

Posted: 27 Feb 2016 08:30 PM PST

"守护进程"(daemon)就是一直在后台运行的进程(daemon)。

本文介绍如何将一个 Web 应用,启动为守护进程。

一、问题的由来

Web应用写好后,下一件事就是启动,让它一直在后台运行。

这并不容易。举例来说,下面是一个最简单的Node应用server.js,只有6行。

 var http = require('http');  http.createServer(function(req, res) {   res.writeHead(200, {'Content-Type': 'text/plain'});   res.end('Hello World'); }).listen(5000); 

你在命令行下启动它。

 $ node server.js 

看上去一切正常,所有人都能快乐地访问 5000 端口了。但是,一旦你退出命令行窗口,这个应用就一起退出了,无法访问了。

怎么才能让它变成系统的守护进程(daemon),成为一种服务(service),一直在那里运行呢?

二、前台任务与后台任务

上面这样启动的脚本,称为"前台任务"(foreground job)。它会独占命令行窗口,只有运行完了或者手动中止,才能执行其他命令。

变成守护进程的第一步,就是把它改成"后台任务"(background job)。

 $ node server.js & 

只要在命令的尾部加上符号&,启动的进程就会成为"后台任务"。如果要让正在运行的"前台任务"变为"后台任务",可以先按ctrl + z,然后执行bg命令(让最近一个暂停的"后台任务"继续执行)。

"后台任务"有两个特点。

  1. 继承当前 session (对话)的标准输出(stdout)和标准错误(stderr)。因此,后台任务的所有输出依然会同步地在命令行下显示。
  2. 不再继承当前 session 的标准输入(stdin)。你无法向这个任务输入指令了。如果它试图读取标准输入,就会暂停执行(halt)。

可以看到,"后台任务"与"前台任务"的本质区别只有一个:是否继承标准输入。所以,执行后台任务的同时,用户还可以输入其他命令。

三、SIGHUP信号

变为"后台任务"后,一个进程是否就成为了守护进程呢?或者说,用户退出 session 以后,"后台任务"是否还会继续执行?

Linux系统是这样设计的。

  1. 用户准备退出 session
  2. 系统向该 session 发出SIGHUP信号
  3. session 将SIGHUP信号发给所有子进程
  4. 子进程收到SIGHUP信号后,自动退出

上面的流程解释了,为什么"前台任务"会随着 session 的退出而退出:因为它收到了SIGHUP信号。

那么,"后台任务"是否也会收到SIGHUP信号?

这由 Shell 的huponexit参数决定的。

 $ shopt | grep huponexit 

执行上面的命令,就会看到huponexit参数的值。

大多数Linux系统,这个参数默认关闭(off)。因此,session 退出的时候,不会把SIGHUP信号发给"后台任务"。所以,一般来说,"后台任务"不会随着 session 一起退出。

四、disown 命令

通过"后台任务"启动"守护进程"并不保险,因为有的系统的huponexit参数可能是打开的(on)。

更保险的方法是使用disown命令。它可以将指定任务从"后台任务"列表(jobs命令的返回结果)之中移除。一个"后台任务"只要不在这个列表之中,session 就肯定不会向它发出SIGHUP信号。

 $ node server.js & $ disown 

执行上面的命令以后,server.js进程就被移出了"后台任务"列表。你可以执行jobs命令验证,输出结果里面,不会有这个进程。

disown的用法如下。

 # 移出最近一个正在执行的后台任务 $ disown  # 移出所有正在执行的后台任务 $ disown -r  # 移出所有后台任务 $ disown -a  # 不移出后台任务,但是让它们不会收到SIGHUP信号 $ disown -h  # 根据jobId,移出指定的后台任务 $ disown %2 $ disown -h %2 

五、标准 I/O

使用disown命令之后,还有一个问题。那就是,退出 session 以后,如果后台进程与标准I/O有交互,它还是会挂掉。

还是以上面的脚本为例,现在加入一行。

 var http = require('http');  http.createServer(function(req, res) {   console.log('server starts...'); // 加入此行   res.writeHead(200, {'Content-Type': 'text/plain'});   res.end('Hello World'); }).listen(5000); 

启动上面的脚本,然后再执行disown命令。

 $ node server.js & $ disown 

接着,你退出 session,访问5000端口,就会发现连不上。

这是因为"后台任务"的标准 I/O 继承自当前 session,disown命令并没有改变这一点。一旦"后台任务"读写标准 I/O,就会发现它已经不存在了,所以就报错终止执行。

为了解决这个问题,需要对"后台任务"的标准 I/O 进行重定向。

 $ node server.js > stdout.txt 2> stderr.txt < /dev/null & $ disown 

上面这样执行,基本上就没有问题了。

六、nohup 命令

还有比disown更方便的命令,就是nohub

 $ nohup node server.js & 

nohup命令对server.js进程做了三件事。

  • 阻止SIGHUP信号发到这个进程。
  • 关闭标准输入。该进程不再能够接收任何输入,即使运行在前台。
  • 重定向标准输出和标准错误到文件nohup.out

也就是说,nohup命令实际上将子进程与它所在的 session 分离了。

注意,nohup命令不会自动把进程变为"后台任务",所以必须加上&符号。

七、Screen 命令与 Tmux 命令

另一种思路是使用 terminal multiplexer (终端复用器:在同一个终端里面,管理多个session),典型的就是 Screen 命令和 Tmux 命令。

它们可以在当前 session 里面,新建另一个 session。这样的话,当前 session 一旦结束,不影响其他 session。而且,以后重新登录,还可以再连上早先新建的 session。

Screen 的用法如下。

 # 新建一个 session $ screen $ node server.js 

然后,按下ctrl + Actrl + D,回到原来的 session,从那里退出登录。下次登录时,再切回去。

 $ screen -r 

如果新建多个后台 session,就需要为它们指定名字。

 $ screen -S name  # 切回指定 session $ screen -r name $ screen -r pid_number  # 列出所有 session $ screen -ls 

如果要停掉某个 session,可以先切回它,然后按下ctrl + cctrl + d

Tmux 比 Screen 功能更多、更强大,它的基本用法如下。

 $ tmux $ node server.js  # 返回原来的session $ tmux detach 

除了tmux detach,另一种方法是按下Ctrl + Bd ,也可以回到原来的 session。

 # 下次登录时,返回后台正在运行服务session $ tmux attach 

如果新建多个 session,就需要为每个 session 指定名字。

 # 新建 session $ tmux new -s session_name  # 切换到指定 session $ tmux attach -t session_name  # 列出所有 session $ tmux list-sessions  # 退出当前 session,返回前一个 session  $ tmux detach  # 杀死指定 session $ tmux kill-session -t session-name 

八、Node 工具

对于 Node 应用来说,可以不用上面的方法,有一些专门用来启动的工具:forevernodemonpm2

forever 的功能很简单,就是保证进程退出时,应用会自动重启。

 # 作为前台任务启动 $ forever server.js  # 作为服务进程启动  $ forever start app.js  # 停止服务进程 $ forever stop Id  # 重启服务进程 $ forever restart Id  # 监视当前目录的文件变动,一有变动就重启 $ forever -w server.js  # -m 参数指定最多重启次数 $ forever -m 5 server.js   # 列出所有进程 $ forever list 

nodemon一般只在开发时使用,它最大的长处在于 watch 功能,一旦文件发生变化,就自动重启进程。

 # 默认监视当前目录的文件变化 $ nodemon server.js  # 监视指定文件的变化    $ nodemon --watch app --watch libs server.js   

pm2 的功能最强大,除了重启进程以外,还能实时收集日志和监控。

 # 启动应用 $ pm2 start app.js  # 指定同时起多少个进程(由CPU核心数决定),组成一个集群 $ pm2 start app.js -i max  # 列出所有任务 $ pm2 list  # 停止指定任务 $ pm2 stop 0  # 重启指定任务 $ pm2 restart 0  # 删除指定任务 $ pm2 delete 0  # 保存当前的所有任务,以后可以恢复 $ pm2 save  # 列出每个进程的统计数据 $ pm2 monit  # 查看所有日志 $ pm2 logs  # 导出数据 $ pm2 dump  # 重启所有进程 $ pm2 kill $ pm2 resurect  # 启动web界面 http://localhost:9615 $ pm2 web 

十、Systemd

除了专用工具以外,Linux系统有自己的守护进程管理工具 Systemd 。它是操作系统的一部分,直接与内核交互,性能出色,功能极其强大。我们完全可以将程序交给 Systemd ,让系统统一管理,成为真正意义上的系统服务。

下一篇文章,我就来介绍 Systemd。

(完)

文档信息

2016年2月22日星期一

阮一峰的网络日志

阮一峰的网络日志


库切的《青春》

Posted: 21 Feb 2016 07:59 AM PST

上周,我整理了过去几年读过的书,做了一份书单

然后,发现自己好久没写读后感了,上一篇还是两年多前的《做学问的八个境界》。过去几年,这个博客已经偏向纯技术了。虽然今后也会如此,但我觉得,读后感还是应该坚持写下去。

今天就介绍,我最近读完的一本非常好看的小说《青春》

这本书是2003年诺贝尔文学奖得主、南非作家库切的"自传体"小说。

它讲述了一个名叫约翰的年轻人,大学毕业后,为了逃避南非的种族对立,独自一人来到伦敦追求理想的故事。小说内容跟库切的个人经历完全吻合,但又有艺术加工和虚构的部分。读来让人觉得很真实,但又像在听故事。

整本书都是约翰的内心独白,没有贯穿始终的情节。他讲述生活中的各种遭遇,然后倾述自己的内心感觉。自己提问,自己回答。如果你喜欢曲折的情节,大概不会喜欢这本书。但是,如果你对探索精神世界有兴趣,尤其是有过精神苦闷,那么你会爱不释手的。

约翰爱好文学,希望成为一个诗人或者艺术家。但是,他来到伦敦后,只找到一份IBM公司程序员的工作。

面试官想知道的第一件事,是他是否永远离开南非了。

是的,他答道。

为什么?面试官问。

"因为那个国家要发生革命了。"他回答说。

约翰很快发现,IBM公司的这份工作,根本就在扼杀自己的生命力。

"随着时间一周一周地过去,他发现自己越来越痛苦。惊恐会向他袭来,他费力地将其击退。在办公室里,他感到自己的灵魂在受到袭击。办公楼是一个毫无特色的玻璃水泥大厦,似乎散发出一种气体,无色、无味,一直钻进他的血液,使他麻木。他敢发誓,IBM在杀死他,把他变成一具僵尸。"

他在伦敦的生活也很糟糕,因为没钱。

"他在伦敦北部牌楼路附近的一所房子里,独自租一个房间住。房间在三楼,能够看见水库,有个煤气取暖器和小凹室,里面有煤气炉灶和放食物及碗碟等用品的架子。在一个角落里是煤气表,你放进去一个先令,得到价值一先令的煤气供应。"

"他一早就离家,回来得很晚,很少看见其他的房客。他在书店、美术馆、博物馆、电影院里度过星期六。星期日他在房间里看《观察家报》,然后出去看个电影,或到荒野去散步。星期六和星期天的晚上是最难熬的。那时,寂寞感会传遍全身,和伦敦的阴沉多雨的灰色天气、冰冷铁硬的人行道合在一起。"

在冰冷的现实面前,他原来的人生计划很快就破灭了。

"原本,他来英国时,心底里计划就是找个工作,攒点钱。当他有了足够的钱就放弃工作,献身于写作。积蓄的钱花完了就去找个新工作,如此等等。"

"很快他就发现,这个计划是多么幼稚。他在IBM的税前工资是每月六十英镑,他最多能够存下十英镑。一年的劳动能够为他挣得两个月的自由,而这其中的许多时间还得花费在寻找下一个工作上。南非给他的奖学金只够勉强交学费。"

"而且他还得知,他不能够随意自由地更换雇主。管理居住在英国的外国人的新条例规定,每一次改变就业都需得到内政部的批准。禁止闲散无业,如果他在IBM辞了职,必须很快找到別的工作,要不就必须离开英国。"

他陷入了深深的苦闷。

"他觉得自己像个狄更斯小说里厌倦无聊的小职员,成天坐在凳子上抄写发霉了的文件。惟一打破一天的单调沉闷的是十一点和三点半。这时,送茶的女士推着小车,在每个人面前啪地放下一杯英国浓茶("给你,亲爱的")。"

"他为什么会在这个巨大而冷漠的城市里,在这里仅仅为了能活下去,就意味着需要永远死命拼搏、力求不要倒下?"

"他暗自想到,我们要为了精神生活而献身吗?我以及在大英博物馆深处的这些孤独的流浪者,有一天我们会得到报答吗?我们的孤独感会消失吗,还是说精神生活就是它本身的报答?"

当时正是越南战争时期,他憎恨西方资本主义国家。

"他给中国驻伦敦的大使馆写了一封信。既然他猜想中国不需要计算机,就没有提计算机编程的事情。他说自己准备到中国去教英语,作为对世界斗争的一个贡献。工资多少对他并不重要。"

"他把信寄了出去,等待答复。与此同时,他买了《自学汉语》,开始学习汉语那陌生的咬紧牙齿的发音。"

"一天又一天过去了,中国人没有答复。英国特工截下了他的信销毁了吗?他们截下并销毁所有寄往中国大使馆的信件吗?如果这样,允许中国人在伦敦设立大使馆有什么意义呢?或者是,在截下了他的件以后,英国特工有没有把他的信转到内政部,并附上一张条子,说在XX计算机公司服务的那个南非人暴露出了他具有的共产党倾向?他会不会因为政治丢掉工作,被驱逐出英国?如果出现了这种情况,他不打算对此提出质疑。这将是命运的声音;他准备接受命运的决定。"

他对自己产生了巨大的怀疑,自问追求的东西是不是错了,要不要放弃理想。

"这是一个他可以逃避的世界----现在逃还不晚,或者与之和解,和他看到的周围的一个个年轻人那样,满足于婚姻、住宅和汽车,满足于生活能够实际提供的,把精力放进工作之中。他懊恼地看到,讲求实际的原则多么奏效。"

他与不同的女孩交往,频繁地发生性关系,为了不让自己被苦闷淹没。但是,还是无法摆脱深入骨髓的孤独感,以及对未来的无力和迷惘。

"在泰特画廊,他和一个他以为是来旅游的女孩聊了起来。她相貌平平,戴副眼镜,身体结实,是他不感兴趣的那种女孩,但很可能他自己就属于那种人。她告诉他她叫阿斯特丽德,来自奥地利----是克拉根福,不是维也纳。"

"原来阿斯特丽德不是旅游者,而是个以干家务换取在主人家吃住的女孩。第二天,他请她出去看电影。他们的趣味很不相同,这点他立刻就看出来了。然而当她邀请他一起回到她工作的人家去的时候,他没有拒绝。他看了一眼她的房间:一间阁楼,蓝色方块布窗帘和颜色相配的床罩,枕头上靠着一只玩具熊。"

"后来,他再一次邀请阿斯特丽德出来。没有什么特别的原闵,他说服她和他一起回到他的住处。她还不到十八岁,还有点胖乎乎的娃娃样。他从来没有和这么年轻的人在一起过----其实她还是个孩子。他给她脱衣服的时候,她的皮肤摸上去冷而黏湿。他已经知道自己犯了个错误。他没有性欲。至于阿斯特丽德,虽然通常女人和她们的性需求对他是个谜,他确知她也没有感到有性欲。但是他们两个已经走得太近,欲罢不能,因此就干到底了。"

"在此后的几个星期中,他们又一起过了几个晚上,但是时间永远是个问题。阿斯特丽德只有在主人家的小孩上床睡觉后才能出来,在返回肯辛顿的末班火车之前,他们最多能有匆忙的一个小时,有次,她大起胆子和他过了一整夜。他假装喜欢有她在,但事实上他不喜欢。他单独睡觉睡得好些,有人和他同床。他整夜紧张地直挺挺地躺在那里,醒来时筋疲力尽。"

"有好几个星期,他没有和阿斯特丽德联系了,她来电话了。她在英国的时间巳经结束,要回奥地利的家里去了。"我猜我不会再见到你了,"她说,"所以打电话和你告別。"

"她尽力就事论事地说话,但是他能够听出她含泪的声音。他愧疚地建议见一面。他们一起喝咖啡;她和他一起回到他的房间里过了一夜(她称之为"我们最后的一夜"),紧紧依偎着他,柔声哭泣。第二天一早(是个星期日),他听见她悄悄下床,蹑手蹑脚地走进楼梯平台处的卫生间去穿衣服。她回来的时候他假装睡着了。他知道,他只要稍作暗示,她就会留下来。如果在对她表示出关心之前他想先做别的事情,比方看报纸,她就会安静地坐在角落里等着。在克拉根福,女孩子在行为举止上似乎受到的就是这样的教育:不提出要求,等待着男人准备好的时候,然后为他服务。"

"他很想对阿斯特丽德好一些,她是这徉年轻,在这个大城市里是这样孤单。他很想给她擦干眼泪,逗她笑;他很想对她证明,他的心肠不像看上去那么冷酷,他能够用自己的乐意回应她的乐意,乐意像她希望被搂抱的那样搂抱她,倾听她讲述的关于她在老家的母亲和兄弟们的故事。但是他必须小心谨慎。过多的热情她就可能把票退掉,留在伦敦,搬来和他同住。两个失败者在彼此的怀抱中躲避,彼此安慰:这个情景太令人羞辱了。要是这样,他和阿斯特丽徳还不如结婚, 然后像病人般互相照顾,度过一生。因此他没有作出暗示,而是躺在那儿紧闭着眼睛,直到听见楼梯的吱咯声和前门咔哒一声关上。"

这样日复一日,他过着这种毫无希望、似乎看不到尽头的生活。他知道,自己必须做出改变了。

"他所做的一切都是在等待他的命运之神到来。命运之神不会在南非来到他的身边,他对自己说,它只会在欧洲的大城市之中。他在伦敦等待了几乎两年,受了两年罪,命运之神没有来。"

"他心里明白。除非他促使她来,否则命运之神是不会来找他的。他必须坐下来创作,这是唯一的办法。"

小说就到这里结束了。

现实生活中,库切从IBM公司辞职,离开了英国,到美国攻读文学博士,从此走上了作家的道路。

(完)

文档信息

2016年2月17日星期三

阮一峰的网络日志

阮一峰的网络日志


"人工智能之父"马文・明斯基

Posted: 17 Feb 2016 04:01 AM PST

(说明:本文原载2016年第六期《财新周刊》

1.

1月底,"人工智能"领域发生了两件大事。

第一件是1月24日,学科创始人之一的马文・明斯基(Marvin Minsky)教授因为脑溢血去世。

第二件是四天后的1月28日,Google宣布人工智能项目DeepMind战胜了欧洲围棋冠军。

《金融时报》说:

"要是明斯基教授多活几天,就能看到自己60年前的预言变成了现实:机器像人类一样思考。"

2.

围棋可能是人类发明的最复杂棋类游戏,变化无穷无尽,远超国际象棋,曾经被认为是专属人类的智力活动。

突然之间,软件能够战胜职业棋手了。这事的意义不在于围棋,而在于机器模拟人类思维的能力,达到了一个前所未有的新高峰。

连围棋那么需要直觉的项目,机器都能胜过人类,还有多少事情是机器做不到的呢?这让大家觉得,人类的未来都不一样了。

"人工智能"这门学科正受到空前重视。而明斯基教授正是它的创始人。

3.

1927年,马文・明斯基生于纽约,先后获得哈佛大学的数学学士和普林斯顿大学的数学博士。

1951年,攻读博士期间,他提出了"神经网络"的概念。这被认为是"人工智能"的起源。Google的DeepMind项目,用的正是这个算法。

明斯基深受罗素的逻辑学思想影响,认为人的智能可以用数学逻辑表达。他受到神经科学的启发,相信"智能"不过是无数"非智能"的神经细胞互相作用的结果。人与机器之间没有本质的差别。如果能模拟神经细胞的行为,进而组成一张人工的"神经网络",那么理论上就能模拟出人的大脑,也就是所谓"智能"。

他根据自己的数学模型,创造了世界上第一台具有学习功能的机器,即由真空管组成的人工神经网络SNARC,意为"随机神经网络模拟加固计算器"。在这个基础上,他与一批志同道合的青年学者,共同创立了"人工智能"这个学科。

4.

"人工智能"是一个矛盾的词,刚刚出现的时候,引起过争论。

哲学意义上,"人工"和"智能"这两个词是互相排斥的,怎么能放在一起呢?"人工"比较好理解,争议性不大。"智能"就不一样了,包含意识、思维、认知、本能等许多内容,都是生命体才有的特征。如果"智能"可以"人工"制造,岂不是生命体也可以制造?

明斯基把"人工"和"智能"这两个词合在一起,让它们变成了一个词。在哲学上,这个词意味着要让机器做到像人一样。

这实际上等于提出了一个终极问题:机器最终会变成人吗,或者说,人是一种机器吗?

5.

18世纪的法国学者拉美特里,写过一本名著《人是机器》,主张生理决定论,即人的意识依赖于生理组织,一旦死亡,灵魂就不存在了。

他写道:"心灵的一切作用,依赖着脑子和整个身体的组织,......这是一架多么聪明的机器!"这本书的目的是提倡唯物主义,反对唯心主义。可是现在来看,它其实提出了人工智能的终极含义。

明斯基就是这样的想法。他深信尽管人脑极其复杂,有判断、有推理、有记忆、有情绪,但是本质上依然是一台机器。这台机器是可以用计算机模拟的。在他眼里,人与机器的边界最终将不存在:人不过是肉做的机器,而钢铁做的机器有一天也能思考

他在这个领域之中,不断取得成绩,一手创立了麻省理工学院的人工智能实验室。该实验室直到今天依然是本学科的最前沿和最权威的学术机构。

他一直留在学术界担任教授,直到去世,培养了无数学生。许多硅谷大公司的首席人工智能科学家,都是他的弟子。

6.

随着技术的进步,机器已经证明可以模拟某些人类的思考过程。接下来的一个问题就是,人工智能有可能超过人的智能吗?如果可以的话,那么机器能学会想象吗?机器又会不会像人一样顿悟呢?

这些问题,人们曾经确信不可能发生,至少也要等到遥远的未来。但是,现在我们不再那么有信心了,也许一切都是可能的。

明斯基对这些问题有自己的回答。他公开预测过未来,说电脑的智能未必能胜过所有人,但肯定会超过大多数人,只是不知道这一天何时来临。

他还描述过一个科幻电影般的场景,告诫大家小心,也许某天一台超级电脑突然做出决定,要用地球上所有资源造出更多的超级电脑,达到它自己的目的。这时,人类就会有危险。不过,他又补充说,这种场景发生的可能性不大,因为相信人类部署"人工智能"之前,会进行充分的测试。

(完)

文档信息

2016年2月13日星期六

阮一峰的网络日志

阮一峰的网络日志


React 测试入门教程

Posted: 13 Feb 2016 01:30 AM PST

越来越多的人,使用React开发Web应用。它的测试就成了一个大问题。

React的组件结构和JSX语法,不适用传统的测试工具,必须有新的测试方法和工具。

本文总结目前React测试的基本做法和最佳实践,手把手教你如何写React测试。

一、Demo应用

请先安装Demo

 $ git clone https://github.com/ruanyf/react-testing-demo.git $ cd react-testing-demo && npm install $ npm start 

然后,打开 http://127.0.0.1:8080/,你会看到一个 Todo 应用。

接下来,我们就要测试这个应用,一共有5个测试点。

  1. 应用标题应为"Todos"
  2. Todo项的初始状态("未完成"或"已完成")应该正确
  3. 点击一个Todo项,它就反转状态("未完成"变为"已完成",反之亦然)
  4. 点击删除按钮,该Todo项就被删除
  5. 点击添加按钮,会新增一个Todo项

这5个测试用例都已经写好了,执行一下就可以看到结果。

 $ npm test 

下面就来看,测试用例应该怎么写。测试框架我用的是Mocha,如果你不熟悉,可以先读我写的《Mocha教程》

二、测试工具库

React测试必须使用官方的测试工具库,但是它用起来不够方便,所以有人做了封装,推出了一些第三方库,其中Airbnb公司的Enzyme最容易上手。

这就是说,同样的测试用例至少有两种写法,本文都将介绍。

  • 官方测试工具库的写法
  • Enzyme的写法

三、官方测试工具库

我们知道,一个React组件有两种存在形式:虚拟DOM对象(即React.Component的实例)和真实DOM节点。官方测试工具库对这两种形式,都提供测试解决方案。

  • Shallow Rendering:测试虚拟DOM的方法
  • DOM Rendering: 测试真实DOM的方法

3.1 Shallow Rendering

Shallow Rendering (浅渲染)指的是,将一个组件渲染成虚拟DOM对象,但是只渲染第一层,不渲染所有子组件,所以处理速度非常快。它不需要DOM环境,因为根本加载进DOM。

首先,在测试脚本之中,引入官方测试工具库。

 import TestUtils from 'react-addons-test-utils'; 

然后,写一个 Shallow Rendering 函数,该函数返回的就是一个浅渲染的虚拟DOM对象。

 import TestUtils from 'react-addons-test-utils';  function shallowRender(Component) {   const renderer = TestUtils.createRenderer();   renderer.render(<Component/>);   return renderer.getRenderOutput(); } 

第一个测试用例,是测试标题是否正确。这个用例不需要与DOM互动,不涉及子组件,所以使用浅渲染非常合适。

 describe('Shallow Rendering', function () {   it('App\'s title should be Todos', function () {     const app = shallowRender(App);     expect(app.props.children[0].type).to.equal('h1');     expect(app.props.children[0].props.children).to.equal('Todos');   }); }); 

上面代码中,const app = shallowRender(App)表示对App组件进行"浅渲染",然后app.props.children[0].props.children就是组件的标题。

你大概会觉得,这个属性的写法太古怪了,但实际上是有规律的。每一个虚拟DOM对象都有props.children属性,它包含一个数组,里面是所有的子组件。app.props.children[0]就是第一个子组件,在我们的例子中就是h1元素,它的props.children属性就是h1的文本。

第二个测试用例,是测试Todo项的初始状态。

首先,需要修改shallowRender函数,让它接受第二个参数。

 import TestUtils from 'react-addons-test-utils';  function shallowRender(Component, props) {   const renderer = TestUtils.createRenderer();   renderer.render(<Component {...props}/>);   return renderer.getRenderOutput(); } 

下面就是测试用例。

 import TodoItem from '../app/components/TodoItem';  describe('Shallow Rendering', function () {   it('Todo item should not have todo-done class', function () {     const todoItemData = { id: 0, name: 'Todo one', done: false };     const todoItem = shallowRender(TodoItem, {todo: todoItemData});     expect(todoItem.props.children[0].props.className.indexOf('todo-done')).to.equal(-1);   }); }); 

上面代码中,由于TodoItemApp的子组件,所以浅渲染必须在TodoItem上调用,否则渲染不出来。在我们的例子中,初始状态反映在组件的Class属性(props.className)是否包含todo-done

3.2 renderIntoDocument

官方测试工具库的第二种测试方法,是将组件渲染成真实的DOM节点,再进行测试。这时就需要调用renderIntoDocument 方法。

 import TestUtils from 'react-addons-test-utils'; import App from '../app/components/App';  const app = TestUtils.renderIntoDocument(<App/>); 

renderIntoDocument 方法要求存在一个真实的DOM环境,否则会报错。因此,测试用例之中,DOM环境(即window, documentnavigator 对象)必须是存在的。jsdom 库提供这项功能。

 import jsdom from 'jsdom';  if (typeof document === 'undefined') {   global.document = jsdom.jsdom('<!doctype html><html><body></body></html>');   global.window = document.defaultView;   global.navigator = global.window.navigator; } 

将上面这段代码,保存在test子目录下,取名为 setup.js。然后,修改package.json

 {   "scripts": {     "test": "mocha --compilers js:babel-core/register --require ./test/setup.js",   }, } 

现在,每次运行npm testsetup.js 就会包含在测试脚本之中一起执行。

第三个测试用例,是测试删除按钮。

 describe('DOM Rendering', function () {   it('Click the delete button, the Todo item should be deleted', function () {     const app = TestUtils.renderIntoDocument(<App/>);     let todoItems = TestUtils.scryRenderedDOMComponentsWithTag(app, 'li');     let todoLength = todoItems.length;     let deleteButton = todoItems[0].querySelector('button');     TestUtils.Simulate.click(deleteButton);     let todoItemsAfterClick = TestUtils.scryRenderedDOMComponentsWithTag(app, 'li');     expect(todoItemsAfterClick.length).to.equal(todoLength - 1);   }); }); 

上面代码中,第一步是将App渲染成真实的DOM节点,然后使用scryRenderedDOMComponentsWithTag方法找出app里面所有的li元素。然后,取出第一个li元素里面的button元素,使用TestUtils.Simulate.click方法在该元素上模拟用户点击。最后,判断剩下的li元素应该少了一个。

这种测试方法的基本思路,就是找到目标节点,然后触发某种动作。官方测试工具库提供多种方法,帮助用户找到所需的DOM节点。

可以看到,上面这些方法很难拼写,好在还有另一种找到DOM节点的替代方法。

3.3 findDOMNode

如果一个组件已经加载进入DOM,react-dom模块的findDOMNode方法会返回该组件所对应的DOM节点。

我们使用这种方法来写第四个测试用例,用户点击Todo项时的行为。

 import {findDOMNode} from 'react-dom';  describe('DOM Rendering', function (done) {   it('When click the Todo item,it should become done', function () {     const app = TestUtils.renderIntoDocument(<App/>);     const appDOM = findDOMNode(app);     const todoItem = appDOM.querySelector('li:first-child span');     let isDone = todoItem.classList.contains('todo-done');     TestUtils.Simulate.click(todoItem);     expect(todoItem.classList.contains('todo-done')).to.be.equal(!isDone);   }); }); 

上面代码中,findDOMNode方法返回App所在的DOM节点,然后找出第一个li节点,在它上面模拟用户点击。最后,判断classList属性里面的todo-done,是否出现或消失。

第五个测试用例,是添加新的Todo项。

 describe('DOM Rendering', function (done) {   it('Add an new Todo item, when click the new todo button', function () {     const app = TestUtils.renderIntoDocument(<App/>);     const appDOM = findDOMNode(app);     let todoItemsLength = appDOM.querySelectorAll('.todo-text').length;     let addInput = appDOM.querySelector('input');     addInput.value = 'Todo four';     let addButton = appDOM.querySelector('.add-todo button');     TestUtils.Simulate.click(addButton);     expect(appDOM.querySelectorAll('.todo-text').length).to.be.equal(todoItemsLength + 1);   }); }); 

上面代码中,先找到input输入框,添加一个值。然后,找到Add Todo按钮,在它上面模拟用户点击。最后,判断新的Todo项是否出现在Todo列表之中。

findDOMNode方法的最大优点,就是支持复杂的CSS选择器。这是TestUtils本身不提供的。

四、Enzyme库

Enzyme是官方测试工具库的封装,它模拟了jQuery的API,非常直观,易于使用和学习。

它提供三种测试方法。

  • shallow
  • render
  • mount

4.1 shallow

shallow方法就是官方的shallow rendering的封装。

下面是第一个测试用例,测试App的标题。

 import {shallow} from 'enzyme';  describe('Enzyme Shallow', function () {   it('App\'s title should be Todos', function () {     let app = shallow(<App/>);     expect(app.find('h1').text()).to.equal('Todos');   }); }; 

上面代码中,shallow方法返回App的浅渲染,然后app.find方法找出h1元素,text方法取出该元素的文本。

关于find方法,有一个注意点,就是它只支持简单选择器,稍微复杂的一点的CSS选择器都不支持。

 component.find('.my-class'); // by class name component.find('#my-id'); // by id component.find('td'); // by tag component.find('div.custom-class'); // by compound selector component.find(TableRow); // by constructor component.find('TableRow'); // by display name 

4.2 render

render方法将React组件渲染成静态的HTML字符串,然后分析这段HTML代码的结构,返回一个对象。它跟shallow方法非常像,主要的不同是采用了第三方HTML解析库Cheerio,它返回的是一个Cheerio实例对象。

下面是第二个测试用例,测试所有Todo项的初始状态。

 import {render} from 'enzyme';  describe('Enzyme Render', function () {   it('Todo item should not have todo-done class', function () {     let app = render(<App/>);     expect(app.find('.todo-done').length).to.equal(0);   }); }); 

在上面代码中,你可以看到,render方法与shallow方法的API基本是一致的。 Enzyme的设计就是,让不同的底层处理引擎,都具有同样的API(比如find方法)。

4.3 mount

mount方法用于将React组件加载为真实DOM节点。

下面是第三个测试用例,测试删除按钮。

 import {mount} from 'enzyme';  describe('Enzyme Mount', function () {   it('Delete Todo', function () {     let app = mount(<App/>);     let todoLength = app.find('li').length;     app.find('button.delete').at(0).simulate('click');     expect(app.find('li').length).to.equal(todoLength - 1);   }); }); 

上面代码中,find方法返回一个对象,包含了所有符合条件的子组件。在它的基础上,at方法返回指定位置的子组件,simulate方法就在这个组件上触发某种行为。

下面是第四个测试用例,测试Todo项的点击行为。

 import {mount} from 'enzyme';  describe('Enzyme Mount', function () {   it('Turning a Todo item into Done', function () {     let app = mount(<App/>);     let todoItem = app.find('.todo-text').at(0);     todoItem.simulate('click');     expect(todoItem.hasClass('todo-done')).to.equal(true);   }); }); 

下面是第五个测试用例,测试添加新的Todo项。

 import {mount} from 'enzyme';  describe('Enzyme Mount', function () {   it('Add a new Todo', function () {     let app = mount(<App/>);     let todoLength = app.find('li').length;     let addInput = app.find('input').get(0);     addInput.value = 'Todo Four';     app.find('.add-button').simulate('click');     expect(app.find('li').length).to.equal(todoLength + 1);   }); }); 

4.4 API

下面是Enzyme的一部分API,你可以从中了解它的大概用法。

  • .get(index):返回指定位置的子组件的DOM节点
  • .at(index):返回指定位置的子组件
  • .first():返回第一个子组件
  • .last():返回最后一个子组件
  • .type():返回当前组件的类型
  • .text():返回当前组件的文本内容
  • .html():返回当前组件的HTML代码形式
  • .props():返回根组件的所有属性
  • .prop(key):返回根组件的指定属性
  • .state([key]):返回根组件的状态
  • .setState(nextState):设置根组件的状态
  • .setProps(nextProps):设置根组件的属性

(完)

文档信息