Press "Enter" to skip to content

JavaScript单元测试基础

软件单元测试可分为测试单元的构建,和测试程序的编写,两步。一般软件测试是指测试程序的编写,默认可用的测试单元,这个默认条件对【JS单元测试】来说常常不充分。JS单元测试则有一个构建测试单元的前置任务,本文简单介绍了使用了【代码重构(refactoring)】的技术,来完成这项前置工作,最后还试编写了一个测试程序,引出【单元测试框架】的概念。本文略译自《Introduction To JavaScript Unit Testing

Introduction To JavaScript Unit Testing

你可能意识到「软件测试」在软件生产中(包括JS开发)的好处;从软件测试活动的角度看(有别软件功能开发的软件构建任务),前端JS的运行模式先天不具可测试性。

JS代码「缺乏可测试性」

在前端的生产环境里,最大的问题是前端JS代码「缺乏可测试性」,这主要因为JS代码散落到程序中各个页面当中,需要在网络中下载到浏览器运行,JS代码会依赖服务端逻辑,又与HTML有关联。更糟的是,JS代码还会与HTML标签混合在一起,例如inline events handlers。

EM:软件功能测试只是一种特殊的“使用”,一种有计划的“功能使用”,“使用”——将程序功能运行起来就是具有可测试性了。然而,JS程序形式很松散,运行条件比较费准备成本。

EM:writing a uint test? build test uint first! 准备一个可测试的单元,是编写测试程序的前提。准备测试单元,和编写测试程序任务不同。

JS代码「缺乏可测试性」,和没有完整的测试单元,在不使用「DOM操作库」时表现特别明显,想一想直在HTML标签定义事件处理函数,比通过DOM API绑定要容易得多。幸好,越来越多的开发者使用像jQuery这样的库来对DOM操作进行抽象,将DOM操作代码独立成一个scripts(scripts标签,或者scripts文件)。不过,将代码独立成文件并不代表这些代码「具备可测试性」。独立只是一个可测试的一个基础条件。

理想的测试单元

那么,到底「测试单元」是指什么呢?在理想的情况下,「测试单元」是一个纯功能函数,并且你很方便的“使用它”——给出计算输入,产生一个固定的计算输出。「理想的测试单元」因为很容易“使用”,所以也很容易“测试”。而大多数实际的代码中,代码与环境有关联,我们必须处理副作用(side effects),例如JS代码输入输出都要处理DOM的(不是纯粹的输出一些简单值)。当然,如果我们掌握了「测试单元和测试活动」的实质,手动去构建测试单元也就不难。

创建测试单元

由此可见,由简入繁,学习JS单元测试(手动构建具有测试性的单元)从零开始会更容易一些。不过,本文并不讲从零开始,而是讲如何从现在有代码中抽取出可测试的单元,从中测试出可能存在的bugs。

EM:测试是发现bug,调试是debug。

从现有的代码中抽取出部分代码,改变它的形态但不改它的计算行为,这个过程叫【代码重构(refactoring)】。代码重构是改善现有代码设计的有效办法,然而对于复杂的代码,重构有损害代码功能的风险。原地测试是最安全的,但是可能不具有可测试性,重构测试能提高可测试性,则有损害的风险,具体怎么做,取决于你的软件测试知识和经验。

EM:你对软件本体的认识,测试活动操作的认识。

理论暂到此,我们来看一个实例,看如何测试一个依赖页面输出的JS代码块。

一个依赖页面输出的JS代码块

页面上有一系列的帖子(posts),每个帖子都显示了具体的发表时间,代码任务是,将所有帖子的具体发表时间转换为一个相对的更友好的时间描述,像“5 days ago”:

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Mangled date examples</title>
    <script>
    function prettyDate(time){
        var date = new Date(time || ""),
            diff = ((new Date().getTime() - date.getTime()) / 1000),
            day_diff = Math.floor(diff / 86400);

        if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
            return;
        }

        return day_diff == 0 && (
                diff < 60 && "just now" ||
                diff < 120 && "1 minute ago" ||
                diff < 3600 && Math.floor( diff / 60 ) + " minutes ago" ||
                diff < 7200 && "1 hour ago" ||
                diff < 86400 && Math.floor( diff / 3600 ) + " hours ago") ||
            day_diff == 1 && "Yesterday" ||
            day_diff < 7 && day_diff + " days ago" ||
            day_diff < 31 && Math.ceil( day_diff / 7 ) + " weeks ago";
    }
    window.onload = function(){
        var links = document.getElementsByTagName("a");
        for (var i = 0; i < links.length; i++) {
            if (links[i].title) {
                var date = prettyDate(links[i].title);
                if (date) {
                    links[i].innerHTML = date;
                }
            }
        }
    };
    </script>
</head>
<body>

<ul>
<li class="entry" id="post57">
    <p>blah blah blah…</p>
    <small class="extra">
        Posted <a href="/2008/01/blah/57/" title="2008-01-28T20:24:17Z">January 28th, 2008</a>
        by <a href="/john/">John Resig</a>
    </small>
</li>
<!-- more list items -->
</ul>

</body>
</html>

当你执行这个例子,你会发现有问题:帖子上的时间一个都没有被转换。

然而,代码基本有效的。它叠代页面上的所有a标签,检查它们的title属性,并且将值传给prettyDate进行调用,如果 prettyDate 有返回,a标签会被更新。

让 prettyDate 跑起来

prettyDate 函数没有效果的原因,是它原定设计只对一个月内的帖子时行处理,只要帖子在31天之前发表(从测试时开始算)的就会返回undefined。为了基本的测试,我们可稍微调整下取消这个限制,让 prettyDate 跑起来,我们只需要将最新发表帖子的时间“硬编码”进prettyDate函数(以这个时间为计算基准,而不现在的测试时间),看看效果:

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Mangled date examples</title>
    <script>
    function prettyDate(now, time){
        var date = new Date(time || ""),
            diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
            day_diff = Math.floor(diff / 86400);

        if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
            return;
        }

        return day_diff == 0 && (
                diff < 60 && "just now" ||
                diff < 120 && "1 minute ago" ||
                diff < 3600 && Math.floor( diff / 60 ) + " minutes ago" ||
                diff < 7200 && "1 hour ago" ||
                diff < 86400 && Math.floor( diff / 3600 ) + " hours ago") ||
            day_diff == 1 && "Yesterday" ||
            day_diff < 7 && day_diff + " days ago" ||
            day_diff < 31 && Math.ceil( day_diff / 7 ) + " weeks ago";
    }
    window.onload = function(){
        var links = document.getElementsByTagName("a");
        for (var i = 0; i < links.length; i++) {
            if (links[i].title) {
                var date = prettyDate("2008-01-28T22:25:00Z", links[i].title);
                if (date) {
                    links[i].innerHTML = date;
                }
            }
        }
    };
    </script>
</head>
<body>

<ul>
<li class="entry" id="post57">
    <p>blah blah blah…</p>
    <small class="extra">
        Posted <a href="/2008/01/blah/57/" title="2008-01-28T20:24:17Z">January 28th, 2008</a>
        by <a href="/john/">John Resig</a>
    </small>
</li>
<!-- more list items -->
</ul>

</body>
</html>

现在运行,我们看到效果:2 hours ago,” “Yesterday”等了。prettyDate函数已经具有「测试性」了,但是还不是一个【理想的经济的测试单元】。因为它还是关联着DOM,任何测试改进都可能要维护DOM,这种测试模式效率是很差的,时间性价比低。

重构,第一步

为了得到一个真正可测试的测试单元,我们要对代码进行重构。

我们需要做两步:

  • 第一,给 prettyDate 增加一个相对的时间参数,并硬编码一个可用值(current),让prettyDate具有可测试性(前面已经处理);
  • 第二,将 prettyDate 抽取出来单独保存在一个js文件里,这样我们可以在另外的页面(通过include)上对其进行独立的测试工作。

抽取得到的prettydate.js:

function prettyDate(now, time){
    var date = new Date(time || ""),
        diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
        day_diff = Math.floor(diff / 86400);

    if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
        return;
    }

    return day_diff == 0 && (
            diff < 60 && "just now" ||
            diff < 120 && "1 minute ago" ||
            diff < 3600 && Math.floor( diff / 60 ) + " minutes ago" ||
            diff < 7200 && "1 hour ago" ||
            diff < 86400 && Math.floor( diff / 3600 ) + " hours ago") ||
        day_diff == 1 && "Yesterday" ||
        day_diff < 7 && day_diff + " days ago" ||
        day_diff < 31 && Math.ceil( day_diff / 7 ) + " weeks ago";
}

特制的测试框架

现在我们算是有了一个完整的测试单元对象——prettydate.js,那么开始为其编写「测试程序」:

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Refactored date examples</title>
    <script src="prettydate.js"></script>
    <script>
    function test(then, expected) {
        results.total++;
        var result = prettyDate("2008-01-28T22:25:00Z", then);
        if (result !== expected) {
            results.bad++;
            console.log("Expected " + expected + ", but was " + result);
        }
    }
    var results = {
        total: 0,
        bad: 0
    };
    test("2008/01/28 22:24:30", "just now");
    test("2008/01/28 22:23:30", "1 minute ago");
    test("2008/01/28 21:23:30", "1 hour ago");
    test("2008/01/27 22:23:30", "Yesterday");
    test("2008/01/26 22:23:30", "2 days ago");
    test("2007/01/26 22:23:30", undefined);
    console.log("Of " + results.total + " tests, " + results.bad + " failed, "
        + (results.total - results.bad) + " passed.");
    </script>
</head>
<body>

</body>
</html>

这段「测试程序」代码是一段针对prettyDate特制(ad-hoc )的,其中包含了全套通用测试框架(testing framework)的形式部分,例如测试程序主体(test),测验输入(then)输出(result),测验的参考“预期答案”(expected),对输出的断言操作(!==),测试报告等。从代码看到整个测试的流程(测试程序的执行):一次测验会用输入(then)调用功能函数,接收输出(result)并进行断言(!==expected),最后记录测试结果用于报告。一次测试由多个测验组成。

这段特制的测试框架代码脱离了对DOM的依赖,例如使用控制台作为测试输出,它可运行在非浏览器环境中,例如 Node.js 或 Rhino。

如果测试过程有发现“不通过”,它详细的报告哪一条“答案”(expected)不对,和实际测试的结果(actual result )。在测试最后,测试框架会汇总所有结果,包含测试次数(total),测试通过量(passed)和未通过量(failed)。

如果全部测验都通过了,你将会在控制台看如下报告输出:

Of 6 tests, 0 failed, 6 passed.

要试下看看不通过的样子,你可故意调一些错误,报告输出是这样的:

Expected 2 day ago, but was 2 days ago.

Of 6 tests, 1 failed, 5 passed.

这段特制的测试实验只是用来验证一些关键概念,证明一个测试运行器( test runner)只有数行代码而已,在实际生产中,我们会使用全套的测试框架(unit testing framework),来【编写测试程序】,使用它提供的运行器,报告工具进行【程序单元测试】。

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *