What goes around comes around (Javascript testing with Jasmine and Karma)

Javascript is fun!

Its dynamic nature allows you to do just about anything, from changing prototypes in runtime through monkey patching, and much more.

However, flexibility in a tolerant scripted language comes with a cost.

It is very easy to introduce new bugs and change the original intent of the code.

 

This is where unit testing come to the rescue.

 

I’m sure you’ve heard of it and know that unit testing is important for all these reasons and many more:

-          Check the code functionality

-          Provide fast feedback

-          Isolate failure analysis

-          Assure build quality

-          Provide documentation for the source code

-          Uncover bugs early

-          …

 

This post is all about the testing tools, not the architecture. If you’re wondering how to write testable JS code, see here and here.

And, of course, you must write testable code for it to be tested and covered properly.

 

My team uses AngularJS which was written with testing in mind “so there is no excuse for not testing”.

The dependency injection mechanism it uses is powerful for substituting real dependencies with fake ones for testing in isolation.

Separation of concerns is also inherent in the framework, so that logical code is tested separately from presentation logic.

 

Our chosen testing framework is Jasmine and we use the spectacular test runner for Javascript - Karma.

 

Jasmine

Jasmine is a behavior-driven development framework for testing your JavaScript code.

Jasmine is built in JavaScript and must be included in a JS environment, such as a web page, in order to run.

It does not depend on any other JavaScript frameworks. It does not require a DOM. And it has a clean, obvious syntax so that you can easily write tests.

 

The following services demonstrate Jasmine features. You can also find it on JSFiddle

 

// This is the class under test
function GreetingService(restService) {
    // GreetingService has a dependency on a RestService class which has a getEntity method
    this.restService = restService;
}
 
// Simulate a synchronous client service
GreetingService.prototype.getHelloMessage = function () {
    return 'Hello World!';
};
 
// Simulate an async service calling the server.
// Callback parameter should accept the String message to display
GreetingService.prototype.getGoodbyeMessage = function (callback) {
    // Simulate async call to server
    this.restService.getEntity();
    // Callback is called after 100 milliseconds to simulate response latency
    setTimeout(function () {
        callback('Goodbye World!');
    }, 100);
};
 
// Simulate a branch and an exception
// This function expects any object.
// If the object evaluates to a false condition (e.g. it is false, null, undefined)
// then it is returned to the caller, otherwise it throws an exception
GreetingService.prototype.throwIfTrue = function (boolValue) {
    if (boolValue) {
        throw {
            name: 'MyCustomError',
            message: 'value cannot be truthy'
        };
    }
    return boolValue;
};

 

 

Jasmine basics

Expectations and matchers are the read heart of the matter, i.e. validating that a given expression matches an expected value.

These checks are performed in specs written inside it blocks.

Specs are grouped in suites written inside describe blocks.

Additional actions can be performed before and after each spec.

This results in the basic test layout:

describe() {
                beforeEach() {}
                afterEach() {}
                it() {
                                expect().matcher()
                }
}

 

 

Suites and specs

Specs describe in English what the function tests, grouped in Suites.

Suites describe what the spec tests, and can be nested within other suites.

 

The code below shows the skeleton for the GreetingService test:

 

// Tests for GreetingService class
describe('Greeting Service', function () {
    var service;
 
    // Run before each test
    beforeEach(function () {
        service = new GreetingService(…);
    });
 
    it('Checks that getHelloMessage returns \"Hello World!\"', function() {…});
    it('Checks that getGoodbye calls server mock’, function() {…});
    it('waits for getGoodbyeMessage to be called', function() {…});
}
 

 

Expectations and matchers

Expectations are built with the function expect which takes a value, called the actual. It is chained with a Matcher function, which takes the expected value.

Each matcher implements a boolean comparison between the actual value and the expected value.

 

Jasmine has a set of built-in matchers for verification.

Jasmine also supports adding custom matchers to enhance test functionality and readability.

 

Expectations are written in specs.

 

The following code shows different checks performed in the GreetingService test:

 

It (‘is a spec’, function() {
        expect(service.getHelloMessage).toBeDefined();
        expect(service.getHelloMessage()).toBe('Hello World!');
        expect(service.getHelloMessage()).not.toBe('Goodbye World!');
        var x = undefined;
        expect(service.throwIfTrue(x)).toBe(x);
        expect(service.throwIfTrue(x)).not.toBeNull();
});

 

 

Spies

Jasmine integrates spies that permit many spying, mocking, and faking behaviors. A 'spy' replaces the function on which it is spying.

Jasmine spies allow you to test if functions have been called, and to inspect the arguments with which they were called.

 

The syntax used for testing spies is as follows:

                expect(spy.function).spyMatcher();

 

The most useful spy matchers are toHaveBeenCalled and toHaveBeenCalledWith.

The toHaveBeenCalled matcher will return true if the spy was called.

The toHaveBeenCalledWith matcher will return true if the argument list matches any of the recorded calls to the spy.

 

Spies can be trained to respond in a variety of ways when invoked:

spyOn(x, 'method').andCallThrough(): spies on and calls the original function spied on
spyOn(x, 'method').andReturn(arguments): returns passed arguments when spy is called
spyOn(x, 'method').andThrow(exception): throws passed exception when spy is called
spyOn(x, 'method').andCallFake(function): calls passed function when spy is called

 

 

Spy usage guidelines

Use spyOn for spying on functions of existing objects. These are usually global objects otherwise you should use dependency injection, e.g. console.log calls.

Use createSpy for functions called by the code under test with no return value, usually callbacks.

Use createSpyObj for interactions of code under test with dependencies, usually other classes.

See this fiddle for examples.

 

Example

The following code shows how the RestService is mocked and spied on in the GreetingService test:

 

describe('Greeting Service',
 
function () {
    var service,
   // Create a spy with the method getEntity. The spy name is used for logging purposes.
    restService = jasmine.createSpyObj('RestService', ['getEntity']);
 
    // Run before each test
    beforeEach(function () {
        service = new GreetingService(restService);
    });
 
   it('Checks that getGoodbye calls rest service',
 
    function () {
        // Create a generic spy object so that getGoodbyeMessage doesn’t create an error.
        // Since the purpose of this test is not to verify the callback being called, no further checks are done here.
        var callback = jasmine.createSpy();
 
        // Call method under test
        service.getGoodbyeMessage(callback);
 
        // Check that mock service was called
        expect(restService.getEntity).toHaveBeenCalled();
    });

 

 

Matching anything

jasmine.any takes a constructor name as an expected value.

It returns true if the constructor matches the constructor of the actual value.

It is useful for validating the type of an argument regardless of its value.

 

The following code creates a spy and verifies that it was called with 12 as the first argument and a function as the second argument:

 

it("is useful for comparing arguments", function () {
            var foo = jasmine.createSpy('foo');
            foo(12, function () {
                return true
            });
 
            expect(foo).toHaveBeenCalledWith(12, jasmine.any(Function));
});

 

 

Mocking the clock

The Jasmine Mock Clock can be used for testing callbacks within setTimeout or setInterval functions.

It provides the test author the ability to advance the JS clock manually so it easier to control and test the timer callbacks.

 

The following example shows that the timer callback is called only when the test author manually ticks the setTimeout clock:

    it("causes a timeout to be called synchronously", function () {
        timerCallback = jasmine.createSpy('timerCallback');
        jasmine.Clock.useMock();
       
        setTimeout(function () {
            timerCallback();
        }, 100);
 
        expect(timerCallback).not.toHaveBeenCalled();
        // Wait for 150 msec to demonstrate usage of mock clock
        waits(150);
        expect(timerCallback).not.toHaveBeenCalled();
       
        jasmine.Clock.tick(101);
 
        expect(timerCallback).toHaveBeenCalled();
    });

 

 

Asynchronous specs

Jasmine supports specs that test asynchronous operations.

Using the commands waits, waitsFor and runs, the test can wait for an async condition to be met and then run verifications.

 

The following code shows how the GreetingService test checks that the callback to getGoodbyeMessage is actually called after the timeout, and verifies its parameters:

 

it('waits for getGoodbyeMessage to be called',
    function () {
        // Create a bare spy to track its calls and their parameters
        var callback = jasmine.createSpy();
 
        // Call the method under test providing the spy callback
        service.getGoodbyeMessage(callback);
 
        // Check that the spy callback is not called synchronously
        runs(function () {
            expect(callback).not.toHaveBeenCalled();
        });
 
        // Wait until callback is called, up to 250 millis timeout
        waitsFor(function () {
            return callback.callCount > 0;
        }, 'Callback was never called!', 250);
 
        // Verify callback call parameters
        runs(function () {
            expect(callback).toHaveBeenCalledWith('Goodbye World!');
        });
    });

 

 

 

How active is the framework?

Jasmine is actively developed on Github and has an active community.

Release 2.0 is already in the works J.

 

Karma

Karma is a unit test runner with many built in adapters to commonly used JS unit testing FWs such as Jasmine, QUnit, Mocha and more.

Tests run by loading both source JS files and test files to the browser.

Karma also loads the testing FW adapter of choice and runs the tests, providing a number of output formats.

 

Karma’s main features are:

  • Executing tests on every save by watching source files
  • Smooth Jenkins integration thru grunt plug-ins
  • Use a built-in JUnit reporter
  • Testing source files with RequireJS dependencies
  • Use Istanbul to automagically generate coverage reports
  • Run test suites simultaneously on different browsers, checking code validity on different JS engines

 

Karma is shipped as a node package.

You must install NodeJS as a pre-requisite to working with Karma.

 

In order to do all that and more, Karma just needs you to provide configuration details:

  • Where to find your source and test files
  • Which browser to use
  • Whether you want to auto watch changes to files
  • Which reporters to use (JUnit , coverage, progress, growl…)

 

Another benefit is the active community and github development (both on Master and stable branches).

 

Be sure to check out the website and download it today.

 

This post was written by Eitan Peer

Labels: web
Leave a Comment

We encourage you to share your comments on this post. Comments are moderated and will be reviewed
and posted as promptly as possible during regular business hours

To ensure your comment is published, be sure to follow the Community Guidelines.

Be sure to enter a unique name. You can't reuse a name that's already in use.
Be sure to enter a unique email address. You can't reuse an email address that's already in use.
Type the characters you see in the picture above.Type the words you hear.
Search
About the Author


Follow Us
The opinions expressed above are the personal opinions of the authors, not of HP. By using this site, you accept the Terms of Use and Rules of Participation