David DvoraJasmine Unit Tests – Testing Legacy Pages

Intro

When approaching testing client side JavaScript code, you first need to ask yourself – “What are we testing?”

We also tried answering it, when we realized that our client-side architecture was mixing DOM manipulation with application logic. This is not surprising for developers that use jQuery in the old fashioned way, as it seems that jQuery actually encourages that kind of behavior because of its syntax and the simplicity of using it.

So which part do we really want to test? This depends on what’s more important for you to verify in your application. One thing we agreed on: we need to separate the client side code into layers in order to make it testable. One layer will be pure application data logic, and the other one will be a wrapper to the code that actually manipulates the DOM (first one uses the latter).

When we started writing pages using AngularJS, the separation of these layers works out of the box – the controllers manipulate the views and views manipulate the DOM. This allowed us to test the core JavaScript code on controllers without the need to reference the DOM at all.

Jasmine was the natural selection of unit testing framework that goes hand in hand with AngularJS, Karma and WebStorm IDE. We experienced some difficulties when we needed to test old jQuery, such as legacy pages, and this is the part I will review here.

The problem

Most of our legacy pages are ASP pages that contain JavaScript script tags (inline or from a file). Those scripts usually use a lot of jQuery selectors in a way that application logic and DOM manipulation are mixed. When we decided to add a new feature to a legacy page like this without rewriting it, we needed a technique to unit test it. We need to make a clear separation between logic and UI code or components in order to make it testable.

 

Our solution

This is a very simplified example of testing JavaScript code located in a legacy ASP page. This code’s purpose is to take a list of objects, generate a markup of a list of links and insert it to DOM:

Code embedded on legacy ASP page:

// Main.aspx

<script type="text/javascript">
   (function ($) {
       $(document).ready(function () {

            // fetch relevant data
            var linkPattern = <%= EnvLinkPattern() %>;
            var placeholder = <%= EnvLinkPlaceholder() %>;
            var envDetailsList = <%= EnvsList() %>;

            var markup = '';
            var envsList = [];

            _.each(envDetailsList, function (envDetails) {
                envsList.push('<a href="' +
                    linkPattern.replace(placeholder, envDetails.Token) + '">'
                    + envDetails.Name
                    + ', started '
                    + envDetails.StartTimeInUserTime
                    + '</a>');
            });

            if (envsList.length > 0) {
                markup += '<ul>';
                _.each(envsList, function (envLink) {
                    markup += '<li>' + envLink + '</li>';
                });
                markup += '</ul>';
            } else {
                markup += 'None';
            }

            // put markup on DOM
            $('#Environments').html(markup);
       });
   })(jQuery);
</script>

We extracted the core logic to a separate .js file (we mimic the object so it will act like an object-oriented class instance):

 

// MarkupGenerator.js

MarkupGenerator = (function () {

   function MarkupGenerator(envDetailsPagePattern, envTokenPlaceholder) {

       this.envDetailsPagePattern = envDetailsPagePattern;
       this.envTokenPlaceholder = envTokenPlaceholder;
   };

   MarkupGenerator.prototype.generateEnvLink = function(envDetails) {

       return '<a href="' + this.envDetailsPagePattern.replace(this.envTokenPlaceholder, envDetails.Token) + '">' + envDetails.Name + ', started ' + envDetails.StartTimeInUserTime + '</a>';
   };

   MarkupGenerator.prototype.generateEnvironmentsMarkup = function (envDetailsList) {

       var that = this;
       var markup = '';
       var envLinksList = [];

       _.each(envDetailsList, function (envDetails) {
           envLinksList.push(that.generateEnvLink(envDetails));
       });

       if (envLinksList.length > 0) {
           markup += '<ul>';
           _.each(envLinksList, function (envLink) {
               markup += '<li>' + envLink + '</li>';
           });
           markup += '</ul>';
       } else {
           markup += 'None';
       }

       return markup;
   };

   return MarkupGenerator;
})();

Then invoke it from the ASP page:

// Main.aspx

<script type="text/javascript">
   (function ($) {
       $(document).ready(function () {

            // fetch relevant data
            var linkPattern = <%= EnvLinkPattern() %>;
            var placeholder = <%= EnvLinkPlaceholder() %>;
            var envDetailsList = <%= EnvsList() %>;

            // constructor
            var environmentsLinksGenerator = new MarkupGenerator(linkPattern, placeholder);

            // generate markup and put it on DOM
            $('#Environments').html(environmentsLinksGenerator.generateEnvironmentsMarkup(envDetailsList));
       });
   })(jQuery);
</script>

Finally – adding the relevant Jasmine unit tests:

// MarkupGeneratorSpec.js

describe('MarkupGenerator', function () {

    beforeEach(function() {

        var controller = null;
        beforeEach(function () {

            controller = new MarkupGenerator('https://use.cloudshare.com/view/_PLACEHOLDER_', '_PLACEHOLDER_');
        });
    });

    describe('generateEnvLink', function () {

        it("should return a valid anchor markup for environment details given", function () {

            var envDetails = { Token: 'ENV_ID', Name: 'NewEnv', StartTimeInUserTime: 'TIME' };
            expect(controller.generateEnvLink(envDetails)).toEqual('<a href="https://use.cloudshare.com/view/ENV_ID">NewEnv, started TIME</a>');
        });

    });

    describe('generateEnvironmentsMarkup', function () {

        it("should return multiple links in a list when got multiple ent app envs details", function () {

            spyOn(controller, 'generateEnvLink').andReturn('X');

            expect(controller.generateEnvironmentsMarkup([{}, {}], false)).toEqual('<ul><li>X</li><li>X</li></ul>');
        });

        it("should return 'None' if ent app environment list is empty", function () {

            expect(controller.generateEnvironmentsMarkup([], false)).toEqual('None');
        });

    });

});

The test of ‘generateEnvLink’ just checks that for a given object the correct markup would return, and the second one makes sure that the whole markup of the list of links is created. In order to simplify the test I even created a mock (a ‘spy’ in Jasmine vocabulary) that lets me concentrate on the actual function I want to test without the need to satisfy the inner function that was in use.

This small example of refactoring demonstrates how we can reduce code size on the main page, and get reasonable test coverage

 

Increasing coverage

You might ask yourself “Can we test the rest of the client-side code in this example?” The answer is yes:

The client-side line that was not covered is the line that actually replaces the markup on the DOM. We can cover it by mocking jQuery. The changes to be made are:

1. Inject jQuery to ‘MarkupGenerator’ and add the appropriate method

// MarkupGenerator.js

function MarkupGenerator(envDetailsPagePattern, envTokenPlaceholder, jQuery) {

    this.envDetailsPagePattern = envDetailsPagePattern;
    this.envTokenPlaceholder = envTokenPlaceholder;
    this.jQuery = jQuery;
};

...

MarkupGenerator.prototype.addMarkupToPage = function (selector, markup) {

    this.jQuery(selector).html(markup);
};

2. Invoke new method from main page

// Main.aspx

<script type="text/javascript">
   (function ($) {
       $(document).ready(function () {

            ...

            // constructor
            var environmentsLinksGenerator = new MarkupGenerator(linkPattern, placeholder, $);

            // generate markup and put it on DOM
            var markup = environmentsLinksGenerator.generateEnvironmentsMarkup(envDetailsList);
            environmentsLinksGenerator.addMarkupToPage('#Environments', markup);
       });
   })(jQuery);
</script>

3. Add the test

// MarkupGeneratorSpec.js

describe('addMarkupToPage', function () {

    var controller = null;

    beforeEach(function () {

        controller = new MarkupGenerator('https://use.cloudshare.com/view/_PLACEHOLDER_', '_PLACEHOLDER_', $);
    });

    it("should call 'html' function of jQuery with given markup", function () {

        var spy = spyOn(controller.jQuery.fn, 'html');
        controller.addMarkupToPage('#Environments', '<a></a>');

        expect(spy.mostRecentCall.object.selector).toEqual('#Environments');
        expect(spy).toHaveBeenCalledWith('<a></a>');
    });
});

As you can see, in order to check and test the jQuery part, we inject the actual jQuery and spy on the “html” method. We have two asserts here: one for the selector and one for the “html” method parameter.

I personally think that those kind of jQuery tests are a bit of an overkill because most of the bugs that will be caught in it will be visible immediately to a developer during development, but that’s a matter of taste. My experience shows that most of the bugs that slip away can be caught when testing the code that deals with the application logic, and not the UI Code/DOM manipulation code.

Dealing with legacy JavaScript code that couples UI and application logic is a challenging task. It is better working with a framework that forces your code to be structured in a testable manner. A structure that will force dependency injection and allows mocking of the services and objects that are in use. But when changing frameworks is not possible, refactoring some legacy code and making it testable is.

 

David Dvora

David Dvora

Senior web developer @CloudShare

Leave a Reply