jQuery Plugin unit testing

Get Around the Event Loop

By @johnkpaul

jQuery Conference 2012

@johnkpaul

Just this guy

From NYC

and unit tests his javascript

My first plugin


            var constrainBoundaries = function(el) {
                    var pos = el.position();
                    var parentHeight = el.parent().height();
                    var parentWidth= el.parent().width();

                    if(pos.top > 0) {
                            el.css("top",0);
                    } else if(pos.top < -1*(el.height()- parentHeight)) {
                            el.css("top",-1*(el.height()-parentHeight));
                    }
                    if(pos.left> 0) {
                            el.css("left",0);
                    } else if(pos.left < -1 *(el.width()- parentWidth)) {
                            el.css("left", -1 * (el.width()-parentWidth));
                    }

            };
            var calculateCrop = function(el) {
                    var pos = el.position();
                    var parentHeight = el.parent().height();
                    var parentWidth= el.parent().width();
                    var cropBoundary = {
                            "x1":-1 * pos.left,
                            "y1":-1 * pos.top,
                            "x2":(-1 * pos.left)+parentWidth,
                            "y2":(-1 * pos.top)+parentHeight,
                    }

                    el.data("crop", cropBoundary);
            };
                                                

but debugging is frustrating

  • Write some code
  • Refresh every few seconds
  • Have to use the page to get into the correct state
  • Step debugging to actually find and address problem

Unit testing is the answer

  • no need to manually use the browser to check your code
  • much less console/alert debugging
  • really fast feedback
  • test in isolation, no need to use your whole app

Some parts of unit testing are easy

  • imperative code, do this, then do this, then do that

            function sumAndSquare(a, b){
                
            }

            function testSum(){
                equal(sumAndSquare(1,2), 9);
                equal(sumAndSquare(9,4), 169);
                equal(sumAndSquare(100,32), 17424);     
            }

            testSum();
                                            

Some parts of unit testing are easy

  • functions that don't call other function, leaves of your execution tree

            function arrayLength(arr){
                
            }

            function testArrayLength(){
                equal(arrayLength([1]), 1);
                equal(arrayLength([1, 2, 3]), 3);
                equal(arrayLength(Array(100)), 100);
            }

            testArrayLength();
                                            

Some parts of unit testing are easy

  • library code should be trusted
  • Don't do this, don't worry about it


            function testjQueryActsAsAdvertised(){
                var $div = $(document.createElement('div'));
                $div.addClass('test');

                equal($div.get(0).className, 'test');

                $div.appendTo(document.body);
                equal($('.test').length, 1);
            }

            testjQueryActsAsAdvertised();
                                            

The real bad parts

Events: How would you test this?


        $('.accordion').click(function(){
            $(this).find('ul').show();     
        });

       $(window).scroll(function(event){
            var offset = $(this.element).offset();
            if( $('window').scrollTop() > offset.top || 
                $('window').scrollLeft() > offset.left){
                $(this.element).addClass('scrolledOff'); 
            } 
            else{
                $(this.element).removeClass('scrolledOff'); 
            }
        });

                                            

The Short Answer?

don't write code like that

it does too much, in one place

plugins need organization

learn patterns and best practices


    function Plugin( element, options ) {
        this.$element = $(element);
        this.$sections = this.$element.find('ul').hide();
        this.init();
    }

    Plugin.prototype.init = function () { 
        var self = this;
        this.$element.click(function(){
            self.handleAccordionClick();
        });
    };

    Plugin.prototype.handleAccordionClick = function () {
        this.$sections.show();
    };

    $.fn['accordionPlugin'] = function ( options ) {
        return this.each(function () {
            $.data(this, 'accordionPlugin', new Plugin( this, options ))
        });
    }
                                            

test event handlers with trigger


        var clicked = false;
        var $ul = $(document.createElement('ul'));

        $ul.accordionPlugin();
        $ul.data('accordionPlugin')
          .handleAccordionClick = function(){clicked = true;}

        $ul.trigger('click');

        equal(clicked, true);
                                            

SinonJS makes this much easier

With SinonJS


        var $ul = $(document.createElement('ul'));

        $ul.accordionPlugin();
        var plugin = $ul.data('accordionPlugin');
        plugin.handleAccordionClick = sinon.spy();

        $ul.trigger('click');
        ok(plugin.handleAccordionClick.called,'spy was called');
                                            

Can test plugin "methods" directly


        var $ul = $(document.createElement('ul'));
        var $innerUl = $(document.createElement('ul'));
        $ul.appendTo(document.body).append($innerUl);
        $innerUl.hide().wrap(document.createElement('li'));

        equal($innerUl.css('display'), 'none');
        $ul.accordionPlugin();
        $ul.data('accordionPlugin').handleAccordionClick();

        equal($innerUl.css('display'), 'block');
                                            

Using fixtures/jQuery's DOM builder makes this easier

I want to build this

Somewhere, we have code like this


    Plugin.prototype.init = function(){
        var self = this,
        offset = $(this.element).offset();
        $(window).scroll(function(event){
            if(elementIsOutsideViewport(event.target, offset)){
                self.elementScrolledOff(); 
            } 
            else{
                self.elementScrolledOn();
            }
        });

    }

    function elementIsOutsideViewport(viewport,offset){
        var $viewport = $(viewport);
        return $viewport.scrollTop() > offset.top || 
               $viewport.scrollLeft() > offset.left;
    }
                                            

I want my test like this


    var $el = $(document.createElement('div'));
    
    $el.stickyScroll();

    var plugin = $.data($el.get(0), "plugin_stickyScroll");
    plugin.elementScrolledOff = sinon.spy();
    plugin.elementScrolledOn = sinon.spy();

    var event = jQuery.Event("scroll");
    event.target = $({scrollTop:101});
    $(window).trigger(event);

    equal(plugin.elementScrolledOff.called, true, 'off works');
    equal(plugin.elementScrolledOn.called, false, 'on works');
                                            

Ajax tests

    Plugin.prototype.init = function(){
        var self = this;
        this.getData().done(function(data){
            self.$element.text(data.message);
        });
    }
    Plugin.prototype.getData = function(){ return $.get('/data');}
     var xhr = sinon.useFakeXMLHttpRequest();
     var requests = this.requests = [];
     xhr.onCreate = function (newXhr) {
            requests.push(newXhr);
     };  

     var $el = $(document.createElement('div')).ajaxPlugin();
     requests[0].respond(200, 
                        { "Content-Type": "application/json" },
                        '{"message":"hey!"}')
     equal($el.text(), "hey!"); 

Browser integration tests?

What did we talk about?

  • Utilize jQuery event objects and trigger
  • Write plugins with seams
  • Use utility libraries
  • Read the source - where to mock your data

Resources

QUESTIONS?

thanks for listening!

http://bit.ly/jqconunittesting

by @johnkpaul

www.johnkpaul.com

johnkpaul.tumblr.com

email: john@johnkpaul.com

irc: johnkpaul