文档章节

我为什么要写Sinon.JS

cyper
 cyper
发布于 2015/12/08 16:46
字数 2768
阅读 168
收藏 0

Good unit tests are focused, fast and isolated. Unfortunately, the code to test sometimes depends on other code that may comprise performance and/or isolation. In this article we will explore some of those cases, and how we can deal with them by planning for testability and loose coupling, and by using test fakes.

Performance

Performance is key to a healthy test suite. I'm not talking about micro optimizations here, but rather the difference between your tests completing in a (very) few seconds as opposed to a few minutes. If tests don't finish fast, you are less likely to run them, which will put your project at risk.

The biggest threats to acceptable performance in unit tests are mainly posed by heavy DOMmanipulation, long-running timers and network activity (e.g. XMLHttpRequest). We will go through these one by one, looking for ways to avoid them slowing down our tests.

Heavy DOM manipulation

Heavy DOM manipulation is usually the easiest one to deal with because many of the performance-related issues can be mitigated by proper planning. DOM manipulating code won't necessarily slow your tests to a crawl, but needless use of the document will cause the browser to render elements which in most cases is completely unnecessary.

As an example, consider the crude jQuery plugin I presented in my previous ScriptJunkie article. It loops the elements in the collection, finds their datetime or data-datetime attributes and replaces the element text with a "humanized" time representation such as "2 hours and 3 minutes ago".

When testing this plugin, there is no reason for the elements used in tests to be attached to the document. Creating and appending elements to the document will only waste time forcing the browser to render the elements. We want to read an attribute and set innerHTML, neither of which require the element to be attached to the document or to be rendered on screen.

Large DOM manipulation tasks can be broken up into smaller functions that accept an element as input and manipulates it rather than always fetching elements from the document using either id or a CSS selector. Doing so effectively sandboxes your code, making it easier to embed without assuming control over the document at large. It also helps the performance of your tests, which will run these functions many times over. Reducing the workload in each function means less work to repeat in the test case.

Long-running timers

Timers are frequently used in JavaScript. Examples include animation, polling and other delayed activities. Imagine a method that has a side-effect of animating the height of an element over 250 milliseconds. If we need four tests to verify all aspects of this method we will be waiting for a full second for this method alone. In a regular web application, there might be many of these, causing the test suite to slow to an unbearable crawl.

Can we plan our way around the timers? In some cases we can definitely allow functions to accept options to control the duration of delayed activities. However, if we don't need (or maybe even want) this flexibility anywhere else, it's just added bloat. A better approach is to short-circuit time all together in tests.

Faking time

To short-circuit time in tests I will show you a library called Sinon.JS (disclaimer: I wrote that). Sinon has a virtual clock which can be used to trigger timeouts created with setTimeout andsetInterval without actually passing time. Doing so has two benefits:

  • Tests that use timers don't need to be asynchronous
  • Tests that use timers will be as fast as any others

The following test shows this in action with a rather silly example. It puts an element into the document (using JsTestDriver'sHTMLDoc feature), wraps it in a jQuery instance and then animates its height to 100 pixels. Rather than using setTimeout to wait for the animation to complete, the test uses the tick method of the clock property, which was injected by Sinon (throughsinon.testCase). Finally we expect the animation to have completed.

TestCase("VirtualTimeTest", sinon.testCase({ "test should animate quickly": function () { /*:DOC += */ var element = jQuery("#hey");
        element.animate({ height: "100px" }, 500); this.clock.tick(510);

        assertEquals("100px", element.css("height"));
    }
}));

Running this test confirms that, yes, the animation does indeed complete as expected, and the test completes in 3 milliseconds (in Chrome), rather than half a second.

Network activity

Perhaps the biggest threat to decent test performance is network activity. The most common source of network activity in JavaScript comes from using XMLHttpRequest. Other examples include asset loading and JSON-P.

XMLHttpRequest and tests

Live XMLHttpRequests in tests are undesirable for a number of reasons. The most obvious drawback is that it complicates the test setup itself. In addition to the client-side code you need either the back-end app, or a simplified version of it running alongside for tests to complete successfully.

The second problem is that tests will be more brittle because they are now depending on the server as well. If there is something wrong in the server-side code, the client tests might change, which is rarely what one wants in a unit test. You may also encounter problems where tests fail because of changes in test data on the server. Not to mention that maintaining the test data on the server becomes much harder because it is not visible from the test server what data the tests expect.

The clarity and documentation effect of unit tests will also suffer when running tests against a live server. Magic values will appear everywhere in assertions because the test data is not visible or otherwise obvious from inside the test.

The final problem with using live XMLHttpRequest in tests is the performance issue. Sure, your server may mostly be so fast it won't matter, but there will still be roundtrips (possibly a lot of them). One roundtrip is worse than none, especially when multiplied by the number of tests touching network code. Also, the server may have occasional hickups causing your tests to hang or worse.

Compartmentalize network access

There are a few ways to solve the network problem in unit tests. The first, as usual, is designing the application well. If XMLHttpRequest objects or jQuery.ajax calls are scattered all the way through the code-base, something is wrong.

A better approach is to design custom interfaces that solve your application's specific networking needs, and use them throughout the application. This effectively gives you looser coupling to the server and your current way of communicating with it, which has a host of benefits in itself. Luckily, it also makes testing your application a lot easier. I will cover an example of solving the testing problem through decoupling later in this article.

Fake the server

As with timers and the clock property, Sinon.JS can help us out when testing code that somehow uses XMLHttpRequest. It provides two interfaces for doing this; a stub XMLHttpRequest object and a fake server. The fake server is a high-level interface on top of the stub XMLHttpRequest object which can allow for pretty expressive tests. To use this, you don't need to change your code at all, no matter how you do your XMLHttpRequests (IE's ActiveXObject variant included).

In this example we have a blog object, which allows us to fetch and persist blog posts. It usesXMLHttpRequest to move bytes over the wire, and a simple version might look something like the following:

var blog = {
    posts: {},

    getPost: function (id, callback, errback) { if (this.posts[id]) { typeof callback == "function" && callback(posts[id]); return;
        }

        jQuery.ajax({
            url: "/posts/" + id,
            type: "get",
            dataType: "json",

            success: function (data, status, xhr) { this.posts[id] = data; typeof callback == "function" && callback(data);
            },

            errback: function (xhr, status, exception) { typeof callback == "function" && errback(status);
            }
        });
    },

    savePost: function (blogPost, callback, errback) { // ... }
};

Note that this example also codifies the "compartmentalized network access" I was talking about previously. To test that the callback is called with the JSON from the server parsed into a JavaScript object, we will use Sinon's fake server:

TestCase("BlogPostServiceTest", sinon.testCase({
    setUp: function () { this.server.respondWith( "GET", "/posts/312",
            [200, { "Content-Type": "application/json" }, '{"title":"Unit test your JavaScript","author":"Christian Johansen"' + ',"text":"..."}']);
    }, "test should call callback with parsed data from server": function () { var blogPost;

        blog.getPost(312, function (post) {
            blogPost = post;
        }); this.server.respond();

        assertEquals({
            title: "Unit test your JavaScript",
            author: "Christian Johansen",
            text: "..." }, blogPost);
    }
}));

The setUp method "configures the server" by telling it what kind of requests it should understand, as well as how they should be responded to. In this case, there is only one request the server knows how to deal with. Any GET request for /posts/312 will result in a 200 OK with a content type of application/json and a predefined JSON string as the response body. Sinon will automatically respond with a 404 for requests not mentioned in the setup.

The test then fetches a blog post through the blog object, and assigns it to a test-local variable. Because requests are asynchronous, the Sinon server cannot immediately respond to the requests. this.server.respond(); instructs the server to process any requests so far, and then we assert that our callback was called with the expected blog post object.

Using Sinon's fake server yields tests that are highly communicative. There's no question as to why we expect the specific blog post to be returned - the reason is right there in the test. The test is now also self-contained and very fast. Triple score!

Isolation

In contrast to integration tests, unit tests should ideally exercise as small parts of the application as possible. In practice, most unit tests tend towards mini integration tests because the function under test often uses other functions to compute the final answer. Most of the time, doing so is not a problem, especially not when building bottom-up. In this case, any dependencies should already be thoroughly tested and thus we should be able to trust them.

In some cases, we want to isolate a function or object from its dependencies anyway, to either rule out the possibility of them failing or because they have inconvenient side-effects. One such example is the blog.getPost method. If we are not using the fake server or something similar, this method will hit the server. Other examples of inconvenient dependencies are ones that are cumbersome to configure and use.

Decoupling interfaces

Once again, the best way to make sure you can test your code in isolation is to decouple your interfaces such that dependencies are kept to a minimum, and such that external dependencies can be controlled from the outside.

As an example, consider a widget that loads a blog post from the server and displays an excerpt. Rather than having the widget hit the server directly, we can use the blog from before. Consider the following simplified excerpt:

var blogPostWidget = {
    displayExcerpt: function (id) { this.blog.getPost(id, function (blogPost) { // Massage blog post and build DOM elements }, function (error) { // Build DOM elements, display "Found no excerpt" });
    }
};

Notice how the blog object is a property on the widget. Testing this is easy because we don't necessarily need to use the same blog object as we would use in production. For instance, we might use a very simple one:

TestCase("BlogPostWidgetTest", { "test should fetch blog post": function () { var fetchedId;

        blogPostWidget.blog = {
            getPost: function (id) {
                fetchedId = id;
            }
        };

        blogPostWidget.displayExcerpt(99);

        assertEquals(99, fetchedId);
    }
});

This test now verifies that the method in question fetches a blog post by calling this.blog.getPost. The blog object in this test is called a fake object as it is a simplified version used only in tests. More specifically, we might call the getPost function here a spy due to the way it collects information about its use. As we have already discussed two uses-cases for Sinon.JS, I will show you an example of writing the same test using Sinon's spies:

TestCase("BlogPostWidgetTest", { "test should fetch blog post": function () {
        blogPostWidget.blog = { getPost: sinon.spy(); };

        blogPostWidget.displayExcerpt(99);

        assert(blogPostWidget.blog.getPost.called);
        assert(blogPostWidget.blog.getPost.calledWith(99));
    }
});

Using sinon.spy, we were able to free the test from most of its scaffolding, improving the readability of the test while maintaining its scope.

Forcing a specific code path

A good unit test focuses on a single behavior. Sometimes we need to control the behavior of the code's dependencies to trigger the situation we want to test. Stubs are like spies, but can additionally specify behavior by calling methods on it like returns(valueToReturn).

Assume we wanted to test that the blogPostWidget correctly displayed "Found no excerpt" when the given blog post could not be found. One way to do this would be to inject a stub that calls the errback function:

TestCase("BlogPostWidgetTest", { "test should display message when post is not found": function () {
        blogPostWidget.root = document.createElement("div");
        blogPostWidget.blog = { getPost: sinon.stub().callsArg(2); };

        blogPostWidget.displayExcerpt(99); var result = div.getElementsByTagName("h2")[0]

        assertEquals("No excerpt found", result.innerHTML);
    }
});

By calling the callsArg method on the stub, the resulting function will try to call its third argument as a function when it is called. The third argument to getPost happens to be the errback, and so we expect the widget to inform the user that there was no such post. If we wanted to specify the exact type of error to occur, we could use callsArgWith:

TestCase("BlogPostWidgetTest", { "test should display message when post times out": function () {
        blogPostWidget.root = document.createElement("div");
        blogPostWidget.blog = { getPost: sinon.stub().callsArgWith(2, "timeout"); };

        blogPostWidget.displayExcerpt(99); var result = div.getElementsByTagName("h2")[0]

        assertEquals("No excerpt found", result.innerHTML);
    }
});

A word of warning on test fakes

There are two pitfalls to avoid when using spies and stubs (and mocks, which we did not cover here). The first one deals with clean-up. You can use functions like sinon.stub to stub global methods like jQuery.ajax too (which means you have yet another way of dealing with the server-in-tests problem). However, if you fake a method in one test and you want other tests to use the real implementation you need to make sure you clean up.

One way to do this clean-up is to manually remember the stubbed methods in setUp and reset them in tearDown:

TestCase("SomeTest", {
    setUp: function () { this.jqAjax = jQuery.ajax;
    },

    tearDown: function () {
        jQuery.ajax = jqAjax;
    }, "test something": function () {
        jQuery.ajax = sinon.stub(); // ... }
});

This works, but it is error-prone and cumbersome. The second pitfall to watch out for is using stubbing while TDD-ing code. Let's say we implemented the blog post widget using TDD, and the first test we wrote ensure that the widget used blog.getPost to fetch the blog post. It might look like the following:

"test should fetch blog post": function () {
    blogPostWidget.blog = { getPot: sinon.spy() };

    blogPostWidget.displayExcerpt(99);

    assert(blogPostWidget.getPot.called);
}

When the test is confirmed failing, we continue to implement it. If we don't pay attention, we will end up with a passing test and code failing in production: we misspelled "getPost", leaving out the "s". This might seem like a silly example, but even silly stuff happens. Even when programming.

Safer fakes with Sinon.JS

Sinon, as an all-round test fake library, can help you clean up, and in some cases even help you catch typos. By using sinon.testCase, as seen in the timer and XHR examples, Sinon sandboxes each test and restores any faked method created using this.spy, this.stub and othes. These work exactly like sinon.spy and friends but they are bound to a sandbox object that Sinon handles for you. Here's the jQuery.ajax example again, letting Sinon clean up:

TestCase("SomeTest", sinon.testCase({ "test something": function () { this.stub(jQuery, "ajax"); // ... }
}));

The syntax for stubbing is slightly different, but the effects are the same. When creating spies and stubs this way, Sinon will also try to help you avoid mistyping function names. Whenever you try to create spies or stubs for properties that are not already functions, Sinon will throw an error your way. Note that you can use this form to stub built-ins as well, for example to control random numbers: this.stub(Math, "random").return(0.67);

In this article we have gone over some cases where faking parts of the application is helpful for testing. We have also seen Sinon.JS in action, but with fairly simple and somewhat contrived examples. It is hard to provide good examples of unit tests without context, so in the next and final article in this series, I will walk you through TDD-ing a working jQuery plugin that uses both timers and ajax.

 

About the Author

Originally a student in informatics, mathematics, and digital signal processing, Christian Johansen has spent his professional career specializing in web and front-end development with technologies such as JavaScript, CSS, and HTML using agile practices. 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 Christian on:

 


本文转载自:https://msdn.microsoft.com/en-us/magazine/gg649850.aspx

共有 人打赏支持
cyper

cyper

粉丝 58
博文 685
码字总数 143161
作品 0
武汉
前端工程师
前端单元测试初探

原文发于我的博客:https://github.com/hwen/blogS... 要不要写单测? 关于这个 cnode 上就有个很有意思的讨论 做个调查,你的 Node 应用有写单测吗? 看完这个应该会有结论?如果没有,就回...

hwencc
2017/11/11
0
0
sinon.js基础使用教程---单元测试

译文 当我们写单元测试时一个最大的绊脚石是当你面对的代码过于复杂。 在真实的项目中,我们的代码经常要做各种导致我们测试很难进行的事情。Ajax请求,timer,日期,跨浏览器特性…或者如果...

a独家记忆
06/29
0
0
优秀Asp.Net程序员的修炼之路

初级的程序员或经验不足的程序员往往只意识到自己的程序是写给计算机的,而不会在意程序其实也是写给人的,或在意得不够、不全面。 写给机器的程序,往往追求的是运行正确、执行效率能满足要...

布雷泽
2011/03/13
0
0
为什么node的标准库这么难用?

很简单的一个httpclient操作,写起来这么复杂? 为什么不像jquery里的get方法学习? 一个简单的download方法,明明只需要url和保存的路径,为什么用nodejs的标准库要写那么一大堆东西才能实现...

五杀联盟
2013/10/10
561
8
LLVM每日谈之九 谈LLVM的学习

作者:snsn1984 从接触LLVM编译器到现在,也有多半年时间了,在这多半年的时间里,也花了不少精力在上面。现在回过头来总结一下在LLVM的学习过程中的一些感悟。(注:这里对LLVM的学习,不是...

snsn1984
2013/02/28
0
1

没有更多内容

加载失败,请刷新页面

加载更多

Generator-ES6

基本概念 Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。 Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装...

简心
13分钟前
2
0
FullCalendar日历插件说明文档

普通显示设置 属性 描述 默认值 header 设置日历头部信息。 如果设置为false,则不显示头部信息。包括left,center,right左中右三个位置,每个位置都可以对应以下不同的配置: title: 显示当...

ada_young
14分钟前
0
0
Redis知识总结--string的内部实现

SDS(Simple Dynamic String) String的数据结构是一个字节数组,但简单的获取数组长度的时间复杂度就是O(n),这对于单线程的redis来讲是不能接受的,因此string在redis中的实现是SDS类,SDS类...

looqy
24分钟前
1
0
SpringBoot开发案例之整合Dubbo分布式服务

前言 在 SpringBoot 很火热的时候,阿里巴巴的分布式框架 Dubbo 不知是处于什么考虑,在停更N年之后终于进行维护了。在之前的微服务中,使用的是当当维护的版本 Dubbox,整合方式也是使用的 ...

Java干货分享
30分钟前
1
0
美团团购订单系统优化记

团购订单系统简介 美团团购订单系统主要作用是支撑美团的团购业务,为上亿美团用户购买、消费提供服务保障。2015年初时,日订单量约400万~500万,同年七夕订单量达到800万。 目标 作为线上S...

Skqing
34分钟前
0
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部