Hello World
Spiga

Jscex单元测试:喝着咖啡品着茶

2012-06-13 00:47 by 老赵, 7394 visits

这段时间在香港出差,跟高帅富们一起工作。高帅富的办公室免费供应咖啡,放在壶里随你倒。茶叶也一样,立顿普洱茉莉自取。于是乎我每天也会喝一两杯提提神,尤其是午饭后,感觉还不错。技术人员似乎都挺热衷于这些饮料,也喜欢拿饮料来为项目取名,这方面最让人想到的例子估计就是Java了。这几天我为Jscex整理代码,准备发布其0.6.5版本,并为0.7.0做准备。这方面的主要工作之一便是为Jscex补充尽可能完整的单元测试。

Jscex的单元测试

我很喜欢单元测试,在目前工作的项目里,绝大部分的单元测试都是我写的,几乎每提交一个功能或修改一个Bug都会补上配套的单元测试。有人说编写单元测试会降低开发的速度,但我倒觉得,如果没有单元测试这种可以验证程序正确性的代码片段,难道每次都要起一个完整的系统才能观察修改效果?更别说单元测试可以减少以后来回返工的可能了,有了单元测试,无论是进行修改和提交都令人放心地很。

而且作为程序员来说,看到这样的界面难道不会让您感到心旷神怡吗?

而Jscex作为一个可以同时在浏览器和Node.js里运行的JavaScript类库,自然也是需要在浏览器里运行单元测试的。当然Jscex单元测试不光如此,还有一些“高级模式”用来测试不同加载环境下的支持情况。

咖啡和茶

虽然理论上不一定,但既然要同时在Node.js和浏览器里进行测试,那么很自然的选择便是使用同时支持两者的单元测试工具。Jscex使用Mocha作为单元测试框架,并使用Chai作为断言类库——真可谓一边喝咖啡一边品茶。之前我用过一段时间的should.js,但它不支持浏览器,更重要的是它的作者说“有Chai所以就不做浏览器支持了”让我稍觉不爽,因此全面切换为Chai。不过平心而论,should.js相比Chai还是有其优势,例如它对于错误的描述更为详细,而Chai的优势主要是支持各种断言形式,而should.js显然只支持一种。

Mocha可以搭配任何断言类库使用,它只关注测试过程中有没有抛出异常,而Chai只关注各种断言API,并再断言失败的时候抛出异常。在Node.js中使用这两个组件基本只需require进来:

require("chai").should();

describe("Jscex", function () {
    it("should be powerful for asynchronous programming", function () {
        ...
    });

    ... more tests ...
});

... more tests ...

这里连Mocha的组件就不需要,因为在Node.js环境下,Mocha更像是一个“执行环境”,我们在安装了Mocha之后,会使用mocha命令,而不是node命令执行测试脚本:

$ [sudo] npm install -g mocha
...

$ mocha your-tests.js

这样Mocha就会执行脚本里定义的所有测试,并告诉我们执行结果。Mocha支持许多输出模式,例如上图便使用了spec模式,它的输出就好像一份规约文档。不过一般开发时我会使用默认的“简约”模式,它只会告诉我们哪些单元测试没有通过。

而在浏览器里使用Mocha和Chai就略显麻烦一些了:

<html>
<head>
    <meta charset="utf-8">
    <title>Unit Testing with Mocha and Chai</title>
    
    <!-- Mocha -->
    <link rel="stylesheet" href="mocha.css" />
    <script src="mocha.js"></script>
    <script>mocha.setup('bdd');</script>
    
    <!-- Chai -->
    <script src="chai.js"></script>
    <script>chai.Should();</script>
</head>
<body>
    <div id="mocha"></div>

    <script>
        describe("Jscex", function () {
            it("should be powerful for asynchronous programming", function () {
                ...
            });

            ... more tests ...
        });

        ... more tests ...
    </script>

    <script>
        mocha.run();
    </script>
</body>
</html>

在浏览器里使用Mocha时需要额外引入一个CSS文件,并在页面上放置一个id为mocha的div。同时,我们还需要使用代码设置Mocha的单元测试模式(例如上面是BDD模式),以及Chai的断言模式(例如上面是Should模式)。使用这种方式,便能得到一张漂亮的单元测试页面,它甚至可以查看当前测试的代码:

真是多亏了JavaScript函数上的toString方法,才能出现像Mocha和Jscex这种酷到爆的东西。

测试代码结构

可以发现,两种环境里单元测试的定义方式是一样的,唯一的区别只是两个不同的执行方式,因此我们需要将所有的单元测试定义独立出来。例如在Jscex里,单元测试就是定义在独立的tests.js里的:

var exports = (typeof window === "undefined") ? module.exports : window;

exports.setupTests = function (Jscex) {

    describe("underscore", function () {

        var _ = Jscex._;

        describe("isArray", function () {
        
            it("should return true for array", function () {
                _.isArray([]).should.equal(true);
            });
            
            it("should return false for others", function () {
                _.isArray("").should.equal(false);
                _.isArray(1).should.equal(false);
                _.isArray({}).should.equal(false);
            }); 
        });

        ... more tests ...
    });

    ... more tests ...
});

这个文件会根据环境将setupTests方法定义在window对象或是exports对象上,于是就可以共享给Node.js和浏览器执行。例如在Node.js里:

var Jscex = require("../../src/jscex");
require("chai").should();
require("./tests").setupTests(Jscex);

而在浏览器里便可以:

<script src="../../src/jscex.js"></script>
<script src="tests.js"></script>

<script>
    window.setupTests(Jscex);
    mocha.run();
</script>

于是乎,我就能随时知道两个环境里单元测试的执行情况了。

高级单元测试模式

Jscex的单元测试的时候还有一些特殊需求:Jscex内置对某些包加载器的支持,这部分代码需要在不同环境里测试,例如“普通浏览器环境”以及“AMD环境”。

测试包加载器有个特别的地方,因为无论是在哪个环境,在使用过程中脚本都只会加载一次,都只会产生一个Jscex对象。如果不同的测试用例都针对同一个Jscex对象则容易相互影响,这便是副作用的麻烦之处。在一个标准的单元测试框架中,总有机会可以设置SetUp和TearDown函数,会在每个测试用例执行前后调用,Mocha也不例外。例如在普通浏览器的测试中,我便定义了这样一个beforeEach操作:

var Jscex;

beforeEach(function (done) {
    delete Jscex;
    loadScript("../../../src/jscex.js", done);
});

beforeEach操作会在每个测试案例执行之前运行,这是一个异步操作——JavaScript是一门离不开异步的语言,因此Mocha对于异步的单元测试有很好的支持,例如我们可以选择性地调用done函数来表示测试结束。一不小心忘了调用done也没有关系,因为Mocha会为每个操作设定一个超时时间,十分友好。在上面的beforeEach里,我们删除Jscex根对象,并重新加载Jscex脚本(这里用到我写的一个简单的loadScript辅助方法)。加载Jscex之后则又会在window上出现一个全新的Jscex根对象。

不过问题又来了,Jscex是我写的,出现哪些根对象我自然清楚明白,但如果是第三方的脚本,难道要完全解读其实现,了解它在window上定义了哪些成员才行吗?事实上,我们有个十分容易的“探测”方法,这里又得为伟大的Mocha框架记下一功。

Mocha框架为开发人员考虑了很多,例如在开发JavaScript程序时,一个拼写错误很容易在根对象上留下一个成员(这种情况在ECMAScript 5的Strict Mode下会有所好转)。此时程序可能执行也很正常,但一旦出现问题便会很难找出原因。但Mocha有一个很贴心的功能便是在测试用例执行前后比较根对象上是否出现了额外的成员,一旦出现这种情况便会告诉我们出现了“泄露”。例如:

beforeEach(function (done) {
    loadScript("require.js", done);
});

it("should tell us the global members", function () { });

这个beforeEach操作会加载RequreJS的脚本,它会在根对象上添加多个成员,而执行后会告诉我们检测到哪些泄露:

例如这里我们知道泄露了requirejs,require和define三个成员。知道以后那就好办多了,补充几行代码即可:

var requirejs;
var require;
var define;

beforeEach(function (done) {
    delete requirejs;
    delete require;
    delete define;

    loadScript("require.js", done);
});

我们只要在Mocha执行测试前先定义几个成员,在加载require.js之前删除,让它再定义回来,这样就能“骗”过Mocha了。剩下的便是编写各种异步的单元测试,Mocha对此支持的很好,例如在AMD环境下的单元测试中:

it("should support complicated module", function (done) {
    require(['jscex'], function (Jscex) {
        var loaded = [];
        define("jscex-m0", function () { return { init: function () { loaded.push("m0"); } }; });
        define("jscex-m1", function () { return { init: function () { loaded.push("m1"); } }; });

        Jscex.coreVersion = "0.5.0";
        Jscex.modules["d0"] = "0.1.0";
        Jscex.modules["d1"] = "0.2.5";

        Jscex.define({
            name: "test",
            version: "0.8.0",
            autoloads: ["m0", "m1"],
            dependencies: {
                "core": "~0.5.0",
                "d0": "~0.1.0",
                "d1": "~0.2.0"
            },
            init: function (root) {
                root.hello = "world";
            }
        });

        require(["jscex-test"], function (test) {
            loaded.should.be.empty;

            test.init(Jscex);

            loaded.should.eql(["m0", "m1"]);
            Jscex.hello.should.equal("world");
            Jscex.modules["test"] = "0.8.0";

            done();
        });
    });
});

写完单元测试之后,我会感到自己是一个非常专业的程序员,突然就有了强烈的码农自豪感和自尊心。当然,Jscex的单元测试之路还很长,不过基础已经打好,剩下的就是要靠自己把握了。

无关的事情

最后再说两件无关的事情,一是上周末Jscex得到了大名鼎鼎的唐凤大侠的肯定

二是给自己新订了一个玩具,离香港近就是这点好:

正所谓“顶配解千愁”,古之人不余欺也。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

57 条回复

  1. haohaolee
    121.63.85.*
    链接

    haohaolee 2012-06-13 08:52:20

    后知后觉在推特上看到唐凤大师写的q-jscex,还想cc给老赵呢。 话说Jscex本身是没有promise的功能的吗?

  2. 老赵
    admin
    链接

    老赵 2012-06-13 09:11:40

    @haohaolee

    Jscex本身的Task对象也能当一个Promise或Future使用,但它没有遵循Promise/A之类的标准或草案等等,所以就是计划中要写一个绑定方法来支持Promise,例如跟fromCallback类似的fromPromise。

  3. Gsanidt
    203.126.130.*
    链接

    Gsanidt 2012-06-13 09:45:33

    老赵这个武器很霸道!

  4. 老羊肖恩
    12.24.60.*
    链接

    老羊肖恩 2012-06-13 11:29:36

    这篇文章,老赵是来炫耀自己的新MBP的。鉴定完毕。呵呵~~~

  5. 老赵
    admin
    链接

    老赵 2012-06-13 11:45:05

    @老羊肖恩

    花了四小时写文章其中一小时反复截图就为了一像素的边框结果你说我是为了炫耀MBP情何以堪啊……

  6. ccm417
    182.151.205.*
    链接

    ccm417 2012-06-13 13:50:25

    测试一下老赵的评论功能~

  7. july
    183.62.146.*
    链接

    july 2012-06-13 15:48:52

    也来测试一下老赵的评论功能~~~

  8. boxsir
    58.240.35.*
    链接

    boxsir 2012-06-13 21:48:48

    @老赵:

    Jscex是你生平最得意的作品吗?

  9. 老赵
    admin
    链接

    老赵 2012-06-13 22:10:34

    @boxsir

    得意不好说,用心是真的。

  10. boxsir
    58.240.35.*
    链接

    boxsir 2012-06-14 10:41:30

    @老赵:

    怎样看一个程序员是否对写程序有兴趣?我的方法是,看他有没有在业余时间做一些自己的软件,他有没有自己的代表性作品。在业余时间免费做的东西,多半是出于内心的喜爱,因此有可能做的非常好。希望Jscex能为成为你的代表作,将来别人提起jscex就能联想到你。

  11. 老赵
    admin
    链接

    老赵 2012-06-14 12:05:38

    @boxsir

    一般我就要他的项目链接看看咯,看看完整度如何,例如文档,示例,单元测试等等。

    其实现在别人提起Jscex肯定能想到我啦,只是不太提起Jscex而已,哈哈。

  12. 老赵
    admin
    链接

    老赵 2012-06-15 18:44:29

    顺便一提:这礼拜刚QA Release了现在正在做的WPF项目,比计划提前两天,大老板对于功能和速度都很满意。性能么……今天和昨天打印了点Log分析下来,启动和关闭速度都有极大提高(耗时降低80~90%),其实就是用了点并行和缓冲。现在我们每天都会更新一两次,大老板上礼拜出差过来的时候,中午提一个需求,下面就看到改进了,这年头开发效率多重要啊。

  13. xmchyabi
    84.49.61.*
    链接

    xmchyabi 2012-06-16 13:43:27

    忘了说, 那电脑好贵哦!那显示屏看电影肯定爽

  14. xanpeng
    219.133.0.*
    链接

    xanpeng 2012-06-18 09:31:52

    发一条OT的评论. 自动隐藏OT评论的功能很赞! 体验很好. 是否考虑再加一个功能: "点此隐藏"? 因为我"点此显示"之后, 就不能回到原来的清爽界面了:)

  15. 老赵
    admin
    链接

    老赵 2012-06-18 09:46:25

    @xanpeng

    过两天吧,等我闲一点,呵呵。

  16. jiyinyiyong
    115.200.23.*
    链接

    jiyinyiyong 2012-06-19 10:45:46

    微博上有同学转关于 CPS 这篇文章, 正在努力看懂... 结尾有评论 Jscex 的.. 我正想学 CPS 呢. 求楼主鉴定: Call/CC 与 Node.js

  17. 老赵
    admin
    链接

    老赵 2012-06-19 11:18:42

    @jiyinyiyong

    这篇文章记得在什么地方转载的时候我回复过,就是说callcc当然是好东西,也是LISP研究成果,但Jscex也是Monad的研究成果,同样是“站在巨人肩膀上”。而且,用callcc需要fiber这个node原生扩展,首先并非所有node托管环境都能装原生扩展,其次你在浏览器里怎么用fiber?Jscex用的方式就是对环境有着最小的需求,只要是JavaScript引擎就能跑,无论是浏览器还是Node.js甚至是任何内嵌移动平台甚至JS引擎,拿来就能跑。

    文章后面评论里winter也表达了同样的意思。

  18. kinogam
    59.41.225.*
    链接

    kinogam 2012-06-19 23:37:26

    "should support complicated module" 这个单元测试一眼看过去没看懂…… 然后就是github上面源代码的单元测试好像都是针对了内部机制来写单元测试…… 感觉还是基于功能或者某些情况来写会比较好,谁想到那天自己突然想大改架构呢…… 不过jscex还是挺复杂。

  19. 老赵
    admin
    链接

    老赵 2012-06-20 08:09:41

    @kinogam

    没有啊,测试的都是外部表现。当然某些机制也是外部表现的一部分,例如模块加载机制。

  20. 链接

    airwolf2026 2012-06-28 11:40:00

    哈哈 真不懂会有这样的人.孜孜不倦

  21. 陈阳
    182.149.205.*
    链接

    陈阳 2015-03-11 16:45:41

    为什么用windows live账号登录了总是没反应呢

已自动隐藏某些不合适的评论内容(主题无关,争吵谩骂,装疯卖傻等等),如需阅读,请准备好眼药水并点此登陆后查看(如登陆后仍无法浏览请留言告知)。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我