读书人

单元测试101:您测试你的javascript吗

发布时间: 2012-10-20 14:12:47 作者: rapoo

单元测试101:你测试你的javascript吗?

你当然是测试你的代码。没有写出相当数量的代码后不运行一下就直接丢到产品中。在本文中我对你是如何测试的进行质疑。如果你不是已经尽可能的多的自动化测试,为生产力和信息提升做好准备吧。

一句话的警告:我将在本文中谈论单元测试和测试驱动开发(TDD),如果你已经得出结论:下面的任何理由对你都不适合,那么请继续阅读,或者至少阅读从我为什么要关心?到最后:

我使用一个库,如jQuery,它保证我的代码正确的工作 测试是一个对专业人员的高级的实践,不适合我 测试太费时间,我只想写产品代码

不同的目的,不同的测试

测试意味着很多事,如何把测试做的最好依赖于一个详尽的测试目标。这里有一些可能会在你的应用中遇到的测试的例子:

易用性测试 性能测试 一致性/回归测试

在本文中,我们专注于一致性和回归测试。换句话说,是那种保障代码做它应该做的事,并且没有缺陷。绝大多数情况下,不能证明绝对没有缺陷。我们能做的就是保证有效的减少缺陷的数量,并且防止已知缺陷爬回到我们的代码中。

如何发现缺陷

大多的程序员都会面对定期查找和修改缺陷。过去,这个任务最常用的方法是在代码中散置一些alert调用(this task was most commonly carried out bysprinkling code with alert calls),并刷新浏览器监查变量的值,或者观察哪里出现了期望的流和脚本期望的流的一致(or to observewhere the expected flow of a script diverged from the expected flow)。

如今,大多浏览器都内建一个强大的控制台。那也不容易获得一个像Firebug Lite一样有用的工具。调试过程几乎都是一样的:在代码散置console.log调用,刷新浏览器,观察实际行为,并和预期行为进行人为比较。

调试:一个例子

例如一个调试session的例子,我们来看一个jQuery插件,它期望一个元素具有一个datetime属性(如HTML5时间元素),或一个自定义data-datetime属性,包含一个日期字符串,用人类可读的、和当前时间对比的内容(如3小时之前)替换元素的innerHTML。


使用分步调试,你可以通过设置一些断点和检查所有有效值而不是记录每个你想查看的变量的值来节省一些时间。

单元测试101:您测试你的javascript吗

Console.log的问题

Console.log风格调试有一些问题:首先,console.log有讨厌的引入自身缺陷的风险。如果在演示或部署之前忘记移除最后记录语句,你知道我在说什么。悬浮的记录语句会使你的代码在不支持console对象的浏览器上崩溃,包括Firebug不可用时的火狐。“但是JavaScript是动态的”,我听到你说,“你可以定义你自己的无操作的console,然后问题就会消除”。的确,你可以这样做,但那就像是用刷漆解决你的汽车生锈的问题。

如果悬浮的console.log调用是不可接受的,我们立即认识到下一个问题:它是不可重复的。一旦调试会话结束,你去除了所有的记录语句。如果(当)新问题出现在代码的相同部分时,你又回到了起点,重新采用巧妙的记录语句。分步调试也同样是暂时的。特设调试(Adhoc debugging)是费时的、容易出错的和不可重复的。

更有效的发现缺陷

单元测试是查找缺陷和验证正确性的方法,并且不需要面对调试器临时性和人为console.log/alert调试。单元测试还有其他大量的优势,我将通过这篇文章介绍。

什么是单元测试

单元测试是你的产品代码按照预期结果的可执行部分。例如,假如我们之前在 jQuery.fn.differenceInWords中发现有两个错误没有修正,并试图用单元测试找到它们:

1.var second = 1000;2.var minute = 60 * second;3.var hour = 60 * minute;4.var day = 24 * hour;5. 6.try {7.    // Test that 8 day difference results in "1 week ago"8.    var dateStr = new Date(new Date() - 8 * day).toString();9.    var element = jQuery('Replace me');10.    element.differenceInWords();11. 12.    if (element.text() != "1 week ago") {13.        throw new Error("8 day difference expected\n'1 week ago' got\n'"+14.                        element.text() + "'");15.    }16. 17.    // Test a shorter date18.    var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;19.    dateStr = new Date(new Date() - diff).toString();20.    var element = jQuery('Replace me');21.    element.differenceInWords();22. 23.    if (element.text() != "3 days, 2 hours, 16 minutes and 10 seconds ago") {24.        throw new Error("Small date difference expected\n" +25.                        "'3 days, 2 hours, 16 minutes and 10 seconds ago' " +26.                        "got\n'" + element.text() + "'");27.    }28. 29.    alert("All tests OK!");30.} catch (e) {31.    alert("Assertion failed: " + e.message);32.}

上面的测试用例处理具有已知具有时间属性的元素,并在得到的人性化的结果字符串不是我们期望的结果时抛出异常。该代码可以保存到独立的文件或在加载该插件的页面中包含。在一个浏览器中运行会立即让我得到“所有测试正常”或一个指示什么错了的消息。

用这种方法调试你的代码好像很笨拙。我们不仅要写记录语句来帮助我们监测代码,而且我们还不得不用程序创建元素和通过插件运行它们来验证产生的文本。但这种方法有相当多的好处:

该测试可以在任何时间,任何浏览器上重复运行。 无论什么时间当我们改变代码,我们都要记得运行该测试,它可以极大的保证同样的缺陷不会重新回来。 适当的清理,这些测试提供了代码的文档。 测试是自我检查的。无论我们添加了多少测试,我们仍然只有一个页面来验证是否有错误。 测试和产品代码没有冲突,因此不会在作为产品代码的部分发布时带入内部alert和console.log调用的风险。

写该测试带来稍多的初始化效果,但我们只写一次,我们很快的会在下次需要调试同样的代码时节省时间。

使用单元测试框架

刚才我们写的测试包含相当多的套路。幸运的是,已经有很多的测试框架来帮助我们。使用测试框架让我们减少不得不嵌入到测试中的测试逻辑的数量,它进而也减少测试自身的缺陷。框架也可以给我们更多的自动测试和显示结果的选项。


断言

断言是一个特殊的方法,它执行对它的参数给定的验证,或标识一个错误(通常抛出一个类似AssertionError 异常),或什么也不做。最简单的断言是它期望参数是“真”。断言通常也可接受一个在失败时用于显示的消息。

1.      java -jar JsTestDriver-1.2.2.jar --port 4224

现在你就已经在你机器上打开了一个JsTestDriver服务器。下一步是打开一个链接为http://localhost:4224/capture的浏览器,它让浏览器转入懒测试运行从属(which will turn the browser into an idle test runningslave)。在你所有能用的浏览器上做同样的事。然后打开一个命令行,cd进入项目目录并键入:

java -jar JsTestDriver-1.2.2.jar --tests all

很快你应该能够看到一些输出:JsTestDriver在所有可用浏览器上运行的两个测试,并显示是否通过。恭喜你,你已经在多个浏览器上自动测试了!如果你的机器可以通过网络使用其他设备访问,你也可以使用这个服务器测试其他平台(OS X, Windows,Linux),你的iPhone, Android电话和其他移动设备。并且你只要在一个命令行就可以全部验证它们。多么令人激动呀!

JsTestDriver不是你自动化测试的唯一选择。如果你不喜欢它的断言框架,你也可以运行用QUnit, YUI Test 和 Jasmine写的测试。另外,雅虎YETI,一个只对YUI的类似的工具, Nicholas Zakas最近发布了YUI TestStandalone,包括了基于SeleniumWeb Driver的类似的运行器。

可测试性:用测试改善你的代码

现在,你可能希望开始实现大量节省时间的单元测试就可以了,特别是对通常预期在多个环境运行得很好的JavaScript。单元测试不仅相比手工调试和猴子补丁(monkey patching)节省大量的时间,而且可以提高你的信心、快乐和生产力。

现在已经决定开始写单元测试,你可能想知道如何开始。明显的答案是为现有的代码写一些测试。不幸的是,那结果往往是现实很困难。部分原因是写测试需要实践,而且前几个(测试)通常很难正确,甚至只是输入(正确)。然而,为什么为现有的代码写测试很困难常常还有另外一个原因:代码不是和测试思想一起写的,通常不是很测试友好的。

可测试性的例子:计算时间差

“可测试性”是特定接口的测试友好方面的度量。一个测试友好的接口使所有对它关注的部分能方便的从外部存取,不需要为测试任何一个给定部分的API而建立无关的状态。换句话说,可测试性是和良好设计有关的,松耦合、高内聚的,这只是花哨方法说对象不应该依赖于太多其他对象并且每个对象/函数只做好一件事。

作为一个可测试性的例子,我们再来看我们的jQuery插件。在前两个单元测试中,我们希望确保对8天前的日期使用插件,结果是字符串“1 weekago”,并且另一个日期的结果是一个更详细的字符串表示。注意,这两个测试没有任何DOM元素操作,虽然我们不得不创建一个对象以测试日期差计算和人类友好的描述字符串。

这个jQuery插件明显比它本来难以测试,主要原因是它做了不止一件事情:计算日期差,生成两个日期差的人类易读的描述,并且从DOM节点的innerHTML抓取日期和更新它 。

要解决这个问题,考虑下面的代码,它是同样的插件的另一种实现:

1.var dateUtil = {};2. 3.(function () {4.    var units = {5.        second: 1000,6.        minute: 1000 * 60,7.          hour: 1000 * 60 * 60,8.           day: 1000 * 60 * 60 * 24,9.          week: 1000 * 60 * 60 * 24 * 7,10.         month: 1000 * 60 * 60 * 24 * 3011.    };12. 13.    function format(num, type) {14.        return num + " " + type + (num > 1 ? "s" : "");15.    }16. 17.    dateUtil.differenceInWords = function (date) {18.        // return correct string19.    };20. 21.    jQuery.fn.differenceInWords = function () {22.        this.each(function () {23.            var datetime = this.getAttribute("datetime");24.            this.innerHTML = dateUtil.differenceInWords(new Date(datetime));25.        });26.    };27.}());
和前面的代码相同,只是重新整理了。现在有两个公开函数:jQuery插件和新的接受一个日期并返回一个人类可读的描述多长时间之前的一个字符串的dateUtil.differenceInWords。还不完美,但我们已经把它分成了两个关注点。现在jQuery插件负责用人性化的字符串替换元素的innerHTML ,而新函数负责计算成正确的字符串。虽然旧的测试仍然能通过,但测试应该针对新接口简化。
1.TestCase("TimeDifferenceInWordsTest", {2.    setUp: function () {3.        this.date8DaysAgo = new Date(new Date() - 8 * day);4.        var diff = 3 * day + 2 * hour + 16 * minute + 10 * second;5.        this.date3DaysAgo = new Date(new Date() - diff);6.    },7. 8.    "test 8 day difference should result in '1 week ago'": function () {9.        assertEquals("1 week ago", dateUtil.differenceInWords(this.date8DaysAgo));10.    },11. 12.    "test should display difference with days, hours, minutes and seconds": function () {13.        assertEquals("3 days, 2 hours, 16 minutes and 10 seconds ago",14.                     dateUtil.differenceInWords(this.date3DaysAgo));15.    }16.});

现在,在我们的测试中没有了DOM元素,而我们能更有效的测试生成正确字符串的逻辑。同样的,测试这个jQuery插件的问题是确信文本内容被替换。

为什么为测试而修改代码?

每次我向别人介绍测试和解释可测试性的概念,总是听到关于“难道你不仅让我用更多的时间写这些测试,而且我还得为了测试改变我的代码吗?”的说词。

来看我们刚才为人性化时间差而做的改变。改变是为了方便测试的目的,但你能说只有测试受益吗?恰恰相反,改变使代码更易于分离无关行为。现在,如果我们晚点决定执行如Twitter反馈到我们的页面,我们能直接使用时间戳调用differenceInWords 函数,而不是通过DOM元素和jQuery插件的笨拙的路线(Now, if we later decide to implement e.g. aTwitter feed on our pages, we can use the differenceInWords functiondirectly with the timestamp rather than going the clumsy route via a DOMelement and the jQuery plugin.)。可测试性是良好设计的固有特性。当然,你可以有可测试性和不好的设计,但你不能有一个良好的设计而不具有可测试性。考虑作为一个小例子的情况的测试—你的代码的例子—如果测试很困难,也就意味着使用代码很困难。

先写测试:测试驱动开发

当你在现有的代码中使用单元测试时,最大的挑战是可测试性问题。为了持续提高我们的工作流程,我们能做什么?这引出了一个让可测试性直接进入产品代码灵魂的万无一失的方法是先写测试

测试驱动开发(TDD)是一个开发过程,它由一些小迭代组成,并且每个迭代通常由测试开始。直到有一个失败的单元测试需要,否则不写产品代码。TDD使你关注行为,而不是你下一步需要什么代码。

比方说,我们被告知那个计算时间差的jQuery插件需要计算任意两个时间的差,而不只是和当前时间的差值。你如何使用TDD解决这个问题?好了,第一个扩展是提供用于比较的第二个日期参数:

1."test should accept date to compare to": function () {2.    var compareTo = new Date(2010, 1, 3);3.    var date = new Date(compareTo.getTime() - 24 * 60 * 60 * 1000);4. 5.    assertEquals("24 hours ago", dateUtil.differenceInWords(date, compareTo));6.}

这个测试假想该方法已经接受两个参数,并预期当比较两个传过去日期恰好有24小时的差别时,结果字符串为"24 hours ago"。运行该测试不出所料的提示它不能工作。为让测试通过,我们不得不为该函数添加第二个可选参数,同时确保没有改变函数使现有的测试失败。下面是一个实现的方法:

1.dateUtil.differenceInWords = function (date, compareTo) {2.    compareTo = compareTo || new Date();3.    var diff = compareTo - date;4. 5.    // ...6.};

所有的测试都通过了,说明新的和原来的需求都得到满足了。

现在我们接受两个日期,我们可能希望方法能描述的时间差是过去或将来。我们先用另一个测试来描述这个行为:

1."test should humanize differences into the future": function () {2.    var compareTo = new Date();3.    var date = new Date(compareTo.getTime() + 24 * 60 * 60 * 1000);4. 5.    assertEquals("in 24 hours", dateUtil.differenceInWords(date, compareTo));6.}
让这个测试通过需要一些工作量。幸运的是,我们的测试已经覆盖(部分)我们之前的要求。(两个单元测试很难构成良好的覆盖,但假想我们已经有针对该方法的完整的测试套件)。一个强大的测试套件让我们不害怕改变代码,如果我们打破它了,我们知道会得到告警。我的最终实现是这样的:
1.dateUtil.differenceInWords = function (date, compareTo) {2.    compareTo = compareTo || new Date();3.    var diff = compareTo - date;4.    var future = diff < 0;5.    diff = Math.abs(diff);6.    var humanized;7. 8.    if (diff > units.month) {9.        humanized = "more than a month";10.    } else if (diff > units.week) {11.        humanized = format(Math.floor(diff / units.week), "week");12.    } else {13.        var pieces = [], num, consider = ["day", "hour", "minute", "second"], measure;14. 15.        for (var i = 0, l = consider.length; i < l; ++i) {16.            measure = units[consider[i]];17. 18.            if (diff > measure) {19.                num = Math.floor(diff / measure);20.                diff = diff - (num * measure);21.                pieces.push(format(num, consider[i]));22.            }23.        }24. 25.        humanized = (pieces.length == 1 ? pieces[0] :26.                     pieces.slice(0, pieces.length - 1).join(", ") + " and " +27.                     pieces[pieces.length - 1]);28.    }29. 30.    return future ? "in " + humanized : humanized + " ago";31.};
注意,我没有碰jQuery插件。因为我们分离了无关的部分,我可以完全自由的修改和提升人性化字符串的方法,而不改变我的网站中jQuery使用人性化字符串的方法。

持续集成

TDD实践中,我们需要及时的反馈。反馈来自我们的测试,这意味着测试需要运行的轻松快速。JsTestDriver已经使测试运行的容易而快速,但总有局限性。限制来自多浏览器的形式。JsTestDriver能如你所愿在多个浏览器上容易的运行测试,因以下两个原因,这对TDD工作流这样做是不便的:

每一次从多个浏览器得到测试报告,使它更难看到发生了什么,并失去了TDD给你带来的便利。 一些较弱的浏览器,而通常是重要的测试对象,是缓慢的。我的意思是慢的足以毁灭TDD流程。(And I mean slow.Slow ruins the TDD flow.)

解决这个问题的一个方案是持续集成。持续集成是自动和经常进行产品质量控制的实践。这时应该包含进来一些工具,如JsLint,而它当然应该包含运行测试。

一个持续集成(CI)服务器可以确保所有开发者的工作可以正确的组合,并且负责在指定的多个浏览器是执行测试。一个构建的CI服务器通常由版本控制系统触发,如Git或Subversion,并且一般提供当发现问题时给项目成员发送邮件的功能。

我最近写了为JsTestDriver创建Hudson CI服务器指南。使用Hudson和JsTestDriver,很容易创建一个高效高质量的工作流程。对我自己而言,我基本是做什么都是TDD,通常我在本机的Firefox上运行测试,它是我发现具有最好错误信息和跟踪信息的浏览器。每次我完成一个功能,通常很小,我把它放到代码库中。这时,Hudson检出我刚提交的变化并在广泛的浏览器上运行所有的单元测试。如果有测试失败,我会收到一个说明发生了什么的邮件。此外,我可以随时访问Hudson服务器查看项目构建视图,看个人的控制台输出等等。

结论:为什么我要关心

如果,在阅读完这篇文章之后,你还不确信单元测试是一个很值得做的实践,让我们再重述一下一些常见误解。

我使用一个库,如jQuery,它确保我的代码正确的工作。

Ajax库,如jQuery,在帮助你处理跨浏览器问题上走了很远。实际上,在很多情况下,这些库完全抽象掉了所有这些讨厌的DOM缺陷,甚至是核心JavaScript的差异。然而,这些库没有,而且不能,保护你的错误的应用逻辑,而单元测试可以。

测试是对专业人员的高级实践,不适合我

我的立场是无论你认为你写代码的过程是哪种方式,你都在测试它,例如,通过刷新浏览器来验证是不是按它应该的方式工作。你简单的选择了不参与自动化和提高你的测试过程,并且在长时间运行(或不那么长时间的运行)中,你花费时间在猛击你的浏览器刷新按钮,而我花费时间写测试,然后我可以今天、明天或明年愉快的运行它。

像任何新技术一样,测试需要实践,但不需要一个“忍者”去做。测试由大量简单的语句组成,他们使用你的代码并对它做假设(真不好表达,原文:ests consistlargely of dirt simple statements that exercise your code and make assumptionsabout it.)。困难的部分是良好设计的代码并确保它是可测试的。换句话说,困难的部分是提高你的编程技巧并写之前思考你的代码。无论是专业人员或初学者,任何人没有原因不想提高

测试太花时间了,我只想写产品代码

手工和自动化测试都花时间。但是,不用花一两个小时“评估”单元测试和/或TDD,然后决定它是在浪费时间。单元测试和TDD需要的是实践,像其他任何科目一样。没有办法几个小时内做到擅长良好的自动化测试。你需要练习,而一旦掌握,你就会认识到我这里说的好处,并且认识到手工测试是多么的浪费。此外,如果你写了单元测试,并花一些时间严厉测试你的代码,你会选择什么呢?失败的真快,或能成功吗?

调整你的需求

从这篇文章中你可能得到这样的印象,我觉得各个人都应该采用我的工作方式。我没有那种感觉。但我感觉认真对待应用的质量和正确性是很重要的,并且我认为单元测试是实现的完整部分。(I do think thatunit testing is an integral part of that equation.)

这里TDD更多是一个可选部分,但我的经验告诉我,TDD极大的简化单元测试。在我实现功能之前,它帮助我提升代码设计,帮助我只实现那些必须实现的代码。当然你可以采用其他的方式也很好的实现这个目标,但是对我来说,TDD是个完美的方案。

现在开始实践吧!


About the Author

Originallya student in informatics, mathematics, and digital signal processing, ChristianJohansen has spent his professional career specializing in web and front-enddevelopment with technologies such as JavaScript, CSS, and HTML using agilepractices. A frequent open source contributor, he blogs about JavaScript, Ruby,and web development at cjohansen.no. Christian works at Gitorious.org, an open source Git hosting service.

Find Christianon:

§ Twitter - @cjno

§ Christian'sBlog

§ Christian's Book - Test-Driven JavaScriptDevelopment

原文地址:http://msdn.microsoft.com/en-us/magazine/gg655487.aspx


由于本人水平有限,虽然花费了大量的时间来翻译,错误难免。欢迎大虾批评指正。


读书人网 >JavaScript

热点推荐