我为什么要写Sinon.JS
博客专区 > cyper 的博客 > 博客详情
我为什么要写Sinon.JS
cyper 发表于2年前
我为什么要写Sinon.JS
  • 发表于 2年前
  • 阅读 135
  • 收藏 0
  • 点赞 0
  • 评论 0

腾讯云 技术升级10大核心产品年终让利>>>   

摘要: Sinon.JS入门教程。

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:

 


共有 人打赏支持
cyper
粉丝 57
博文 619
码字总数 143041
×
cyper
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: