Mocha 和 Chai 对 JavaScript 代码进行单元测试

你是否曾经尝试过谢盖代码后,导致其它地方出现问题吗?

我相信很多人都遇到过。因为这是几乎不可避免的,特别在庞大的代码面前。由于代码间可能是环环相扣的,改变一处会影响另一处。

但如果这种情况不会发生呢?如果有一种方法能让你知道改变后会出现的结果呢?这无疑是极好的。因为修改代码后无需担心会破坏什么东西,从而程序出现 bug 的概率更低,在 debug 上话费时间更少。

这就是单元测试的魅力。它能自动检测代码中的任何问题。在修改代码后进行相应测试,若有问题,能立刻知道问题是什么,问题在哪和正确的做法是什么。这完全可以消除任何猜测!

在本文,我会让你了解如何对 JavaScript 代码进行单元测试。而且,在本文出现的案例和技术可同时应用到基于浏览器的代码和 Node.js 的代码。

[阮一峰 测试框架 Mocha 实例教程](测试框架 Mocha 实例教程)

什么是单元测试

当你对代码库进行测试时,可先取一段代码(通常是一个函数),然后在特定情况下,验证其行为是否正确。而单元测试就是这方面的一种结构化和自动化的方法。当然,写的测试越多,获得的益处也更大。这也会让你在开发时更加自信。

单元测试的核心思想是给函数特定的输入值,测试其行为。也就是说,以特定的参数调用函数,然后检查是否得到正确的结果。

// 输入 1 和 10...
var result = Math.max(1, 10);
 
// ...应该输出 10
if(result !== 10) {
  throw new Error('Failed');
}

在实际中,测试有时会更复杂。例如,如果你的函数含有一个 Ajax 请求,那么测试就需要设定更多的东西。当然,“根据特定的输入值得到特定的输出值”原理仍然适用。

设置工具

在本文,我们选择 Mocha。它入门简单,能同时适用于基于浏览器的测试和 Node.js 的测试,而且与其它测试工具配合同样运行良好。

安装好 Node.js 后,在你的项目目录下打开 terminal 或 command line。

  • 如果你想在浏览器上测试代码,执行 npm install mocha chai --save-dev。
  • 如果你想测试 Node.js 代码,除了执行上面那行命令,也要执行 npm install -g mocha。

此时已经安装了 mocha 和 chai 包(package)。Mocha 是一个运行测试的库,而 Chai 包含一些有用的功能,我们能利用这些功能对我们的测试结果进行验证。

Node.js vs Browser 测试对比

下面的案例是在浏览器上运行测试的。如果想为你的 Node.js 应用进行单元测试,要遵循以下步骤。

  • 对于 Node,无需测试运行文件(test runner file)。
  • 为了引入 Chari,需在测试文件顶部添加语句 var chai = require('chai');。
  • 用 mocha 命令执行单元测试,而不是打开浏览器。
设置目录结构

为了让文件结构更清晰,应将测试文件放在主代码文件的一个独立目录下。这是为了方便以后添加其它类型的测试(如集成测试(integration tests)功能测试(functional tests))。

对于 JavaScript,最流行的实践方案是在项目根目录下创建一个 test/ 文件夹。然后,将每个测试文件放置在该文件夹下,如 test/someModuleTest.js。另一种方案是,在 test/ 目录下,再创建文件夹。但我建议尽量保持简单——这样能保证在后面必要时进行(快速)修改。

设置测试运行器(Test Runner)

为了能在浏览器上进行测试,我们需要创建一个简单的 HTML 页面作为测试运行页(test runner page)。该页面会加载 Mocha、测试库文件和实际测试文件。为了运行这些测试,我们只需在浏览器打开运行器(runner)。

如果你使用 Node.js,你可跳过这一步。Node.js 的单元测试能通过命令 mocha 运行,前提是按照我推荐的目录结构。

下面是我们用于测试运行器(test runner)的代码。我将其存为 testrunner.html。

<!DOCTYPE html>
<html>
  <head>
    <title>Mocha Tests</title>
    <link rel="stylesheet" href="node_modules/mocha/mocha.css">
  </head>
  <body>
    <div id="mocha"></div>
    <script src="node_modules/mocha/mocha.js"></script>
    <script src="node_modules/chai/chai.js"></script>
    <script>mocha.setup('bdd')</script>
 
    <!-- load code you want to test here -->
 
    <!-- load your test files here -->
 
    <script>
      mocha.run();
    </script>
  </body>
</html>

该测试运行器的几个重要点:

  • 为了让测试结果拥有漂亮的样式,我们加载了 Mocha 的 CSS 文件。
  • 创建了一个 ID 为 mochat 的 div 标签。测试结果将放在该标签内。
  • 加载 Mocha 和 Chai 脚本文件。由于这两个文件是通过 npm 安装的,它们被放在 node_modules 目录的子文件夹下。
  • 通过调用 mocha.setup,开启 Mocha 的测试功能(testing helpers)。
  • 然后,加载需要的测试项和相应测试的文件。尽管我们还没在这放置任何代码。
  • 最后,调用了 mocha.run 执行相应测试。当然,要确保在资源和测试文件加载完成后再调用该函数。
基本的测试骨架

现在我们可以运行测试了,下面就开始写点测试相关的东西吧。

首先,我们创建一个 test/arrayTest.js, 每个文件名都有其具体含义,显然它是个测试文件,并会测试 array 的基本功能。

每个测试案例文件都会遵循以下基本模式,首先,有个 describe 块:

describe('this is a Array', function(){
    // Further code dor tests goes here
})

describe 用于把单独的测试聚合在一起。其第一个参数用于指示测试什么,在本例中,由于我们打算测试 array 功能,我传入一个 ‘Array’ 字符串。

然后,在 describe 内需有 it 块:

describe('Array', function() {
  it('should start empty', function() {
    // Test implementation goes here
  });
 
  // We can have more its here
});

it 用于创建实际的测试。其第一个参数是对该测试的描述,且该描述的语言应该是人类可读的(而非编程语言)。如在本例中,“it should empty” 能很好地描述了 array 的行为。实现该测试的具体代码则写在 it 的第二个参数 function 内。

所有 Mocha 测试都以同样的骨架编写,而且它们遵循相同的基本模式。

  • 首先,使用 describe 表明我们测试什么,如 “描述 array 该如何运行”。
  • 然后,使用多个 it 函数创建独立的测试,每个 it 应该描述一个特定的行为,如上述的案例 “it should start empty(array 运行前应为空)”
编写测试代码

现在我们已经知道如何构造测试案例了,下面就开始更有趣的部分--实现测试。

由于我们的测试是 array 初始值应为空,即我们需要创建一个数组,并确保它为空。实现该测试非常简单:

var assert = chai.assert;

describe("测试 array 是怎么工作", function(){
    it("应该是一个 empty", function(){
        var arr = [];

        assert.equal(arr.length, 0);
    })
});

请注意首行代码,我们设置了 assert 变量。这样就不用每次都输入 chai.assert 了。

在 it 函数里,我们创建了一个数组并检查其长度。尽管简单,但很好地展示了测试是如何工作的。

首先,你有东西需要被测试——这叫 被测系统(System Under Test,SUT)。若有需要,则对被测系统进行相应操作。对于上述案例,由于检查数组初始值是否为空,我们没做任何操作。

测试的最后步骤应该是验证——对结果进行断言(assertion)检查。对于上述案例,我们对此使用 assert.equal。大多数断言函数的参数顺序是一致的:首先是“实际”值,然后是“期待”值。

实际值是测试代码的结果,因此,在该案例中是 arr.length。

期待值是预想的结果。由于数组的初始值应为空,因此,在该案例中的期待值是 0。

虽然 Chai 提供了两种不同的断言(assertion)编写方式,但现在为了保持简单,我们使用了 assert。当你能熟练编写测试时,你可能更想用 expect assertions ,因为它提供了更灵活的操作。

运行测试

为了运行该测试,我们需要将其添加到先前创建的测试运行器文件内。

对于 Node.js,我们可以跳过此步骤,然后使用命令 mocha 执行测试。你会在 terminal 里看到测试结果。

向运行器添加该测试(针对浏览器端):

<!-- load your test files here -->
<script src="test/arrayTest.js"></script>

你一旦添加了脚本,就可以加载测试运行器页面了(若选择在浏览器进行测试)。

测试结果

当你运行这些测试,其测试结果看起来和下图类似:

image

注意:在 describe 和 it 函数的描述语句都在页面展示出来了——测试项(如:should start empty)都分组放在描述(如:Array)下。当然,也可以对 describe 块再嵌套,以创建更深的子分组。

下面看看测试失败会显示什么。

将测试的该行代码进行修改:

assert.equal(arr.length, 0);

将 0 改为 1。这无疑会导致测试失败,因为数组长度不再匹配期待值。

如果你再次运行测试,那么在测试结果中,运行错误的描述将以红色显示。

image

测试的一项好处是能帮助你更快地找到 bug,尽管错误信息在这并不是非常详细。但是我们可以解决这个问题。

大多数断言函数都带有一个可选的 message 参数。该信息参数会在断言失败时显示。因此我们可以利用该参数,让错误信息更容易理解。

我们能像下面那样向断言添加 message 参数:

assert.equal(arr.length, 1, 'Array length was not 0');

如果你再次运行测试,那么自定义的信息会取代默认的信息而显示出来。

OK,让我们将 1 改回 0,确保测试通过。

综合案例

到目前为止,案例都是相当简单的。那么下面就让我们将学到的知识付诸实践,看看如何测试将一段实际当中所用到的代码。

下面是一个将 CSS 类名添加到元素的函数。我们将该函数放进新文件 js/className.js。

function addClass(el, newClass) {
  if(el.className.indexOf(newClass) === -1) {
    el.className += newClass;
  }
}

当元素的 className 属性不含有新类名时,才向元素添加新类名--毕竟谁想看到 <div class="hello hello hello hello">。

在最好的情况下,我们要在编写代码前先为该函数编写测试。但 测试驱动开发(test-driven development) 是一个复杂的主题,因此我们现在仅专注于编写测试。

开始前,让我们重温单元测试的基本思想:赋予函数特定的输入值,然后验证函数的行为是否符合预期。所以,该函数的输入值和行为是什么呢?

给定一个元素和一个类名:

若元素的 className 属性未含有该类名,则应添加。
若元素的 className 属性已含有该类名,则不应添加。
将这两种情况转化为两个测试。在 test 目录下,创建新文件 classNameTest.js 并添加以下内容:

describe('addClass', function() {
  it('should add class to element');
  it('should not add a class which already exists');
});

我们也可以将措词稍微地改成 “it should do X”,虽然可读性更强一点,但本质上仍然与我们上述语句的可读性一致。根据原来的措词联想到相应的测试也不难。

等等,测试函数跑去哪了?当我们省略 it 的第二个参数,Mocha 会在测试结果中标记这些测试为待测试项。这让设置多个测试变得更方便——就像一个备忘录,列着打算编写的测试。

接着实现第一个测试。

describe('addClass', function() {
  it('should add class to element', function() {
    var element = { className: '' };
 
    addClass(element, 'test-class');
 
    assert.equal(element.className, 'test-class');
  });
 
  it('should not add a class which already exists');
});

在该测试中,我们创建了 element 变量,并将其与字符串 test-class(作为元素的新类名) 作为参数传入 addClass 函数。然后,使用断言检查该类名是否已包含在值(element.className)里。

再一次,我们从初始的想法出发——给定一个元素和一个类名,将类名添加到 class 列表,然后以简单的方式将其转化为代码。

尽管该函数(addClass)是针对 DOM 元素的,但我们在此使用了一个简单 JS 对象(plain JS object,根据 jQuery 官方定义:含有零个或多个键值对的对象)。是的,有时我们可以利用 JavaScript 的动态特性,以上述方式简化测试。如果不这样做,我们就要创建一个实际的元素,这无疑会使测试代码变复杂。当然,这还有另一个好处,由于没使用 DOM,该测试也能在 Node.js 运行。

在浏览器运行测试

为了在浏览器运行测试,你需要在运行器添加 className.js 和 classNameTest.js。

<!-- load code you want to test here -->
<script src="js/className.js"></script>
 
<!-- load your test files here -->
<script src="test/classNameTest.js"></script>

正如下面 CodePen 中所显示的:一个测试通过,而另一个显示待测试。注意:为了让代码运行在 CodePen 环境下,代码需稍作调整。

接着,实现第二个测试…

it('should not add a class which already exists', function() {
  var element = { className: 'exists' };
 
  addClass(element, 'exists');
 
  var numClasses = element.className.split(' ').length;
  assert.equal(numClasses, 1);
});

经常运行测试是一种好习惯。因此,让我们现在运行测试看看会发生什么。

不出所料,两者均通过。

下面是在 CodePen 中实现第二个测试的例子。

但事情没那么简单!该函数的第三种情况我们并没有考虑到,这也是该函数的一个非常严重的 Bug。虽然该函数只有三行代码,但你注意到了吗?

下面为第三种情况编写多一个案例,让这个 Bug 暴露出来。

it('should append new class after existing one', function() {
  var element = { className: 'exists' };
 
  addClass(element, 'new-class');
 
  var classes = element.className.split(' ');
  assert.equal(classes[1], 'new-class');
});

你可在下面的 CodePen 中看到,这次测试失败了。导致该问题的原因很简单:元素上的 CSS 类名应以空格隔开。然而,现在实现的 addClass 并未加空格!

修复该函数,让测试通过。

function addClass(el, newClass) {
  if(el.className.indexOf(newClass) !== -1) {
    return;
  }
 
  if(el.className !== '') {
    //ensure class names are separated by a space
    newClass = ' ' + newClass;
  }
 
  el.className += newClass;
}

修复后,最终在 CodePen 测试通过。

在 Node 中运行测试

在 Node 中,只有同一文件中的内容是可见的。由于 className.js 和 classNameTest.js 在不同文件下,我们需要一种方式将一个文件导出到另一个文件内。而标准的方式是通过 module.exports。如果你需要复习相关知识,你可以看看 Understanding module.exports and exports in Node.js

代码本质不变,只是结构稍微不同:

// className.js
 
module.exports = {
  addClass: function(el, newClass) {
    if(el.className.indexOf(newClass) !== -1) {
      return;
    }
 
    if(el.className !== '') {
      //ensure class names are separated by a space
      newClass = ' ' + newClass;
    }
 
    el.className += newClass;
  }
}
 
// classNameTest.js
 
var chai = require('chai');
var assert = chai.assert;
 
var className = require('../js/className.js');
var addClass = className.addClass;
 
// 文件其它部分保持不变
describe('addClass', function() {
  ...
});

正如你所看到的,测试通过。

image
下一步呢?

正如你所看到的,测试不复杂也不难。与编写 JavaScript 应用的其它方面一样,有一些重复的基本模式。一旦你熟悉了这些,你可以一次又一次的使用它们。

但这些只是单元测试的皮毛,还有很多相关知识需要学习。

  • 测试更复杂的系统
  • 如何处理Ajax、数据库和其它“外部”的东西。
  • 测试驱动开发
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,340评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,762评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,329评论 0 329
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,678评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,583评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,995评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,493评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,145评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,293评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,250评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,267评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,973评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,556评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,648评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,873评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,257评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,809评论 2 339

推荐阅读更多精彩内容