Wednesday, April 10, 2013

TDD - jQuery simple slideshow plugin and unit testing

As a primarily back-end developer, first time I heard someone mentioning unit testing of front-end components, it did not ring a bell how useful it could be, I was naively thinking that front-end testing should end on tester clicking on screen. Of course, I was terribly wrong.

Faced with challenge of delivering front-end heavy project, I had to dig deeper into modern tools used throughout development of client-side apps. I'll try to cover in this blog posts simple workflow in TDD approach when writing jQuery plugin.

For an example let's build simple plugin that displays image gallery with fade effect. Technical and functional requirements would be:
  • Ability to initialize plugin with list of image paths
  • Specify time between images being changed
  • Ability to hook onto event when image is being changed
  • Ability to jump to specific image by it's index
  • Control whatever images are looping or not
  • Specify transition and animation duration
  • Specify container size
As unit testing library I'll use qunit. All of the source below can be found on github.

Test 1: Initialization

Upon initialization, we want to make sure that first image is displayed. At this point we'll assume that images are being displayed via css background property, rather than img tag.

       
   var arrImageList =['http://www.westminsterkennelclub.org/breedinformation/working/images/samoyed.jpg',
                      'http://static.tumblr.com/o9yg6la/zsymaeh1o/samoyedaa.jpg',
                      'http://fotos.tsncs.com/img/20120410/7273071/cachorros-de-samoyedo-24703108_3.jpg',
                      'http://t2.gstatic.com/images?q=tbn:ANd9GcRRV6TveS6Vc6qkRJZKE- vvdFwKQjGCqxbQz82_VTev5HAGy6K8'],
       $module;

   test("moduleInit",function(){  
        var expectedCssProp = "url(" + arrImageList[0] + ")";  
       
        $module = $('#module').imageSlideshow({  
            imageList : arrImageList  
        });  
   
        equal($module.find('.current-img').css('background-image'),
              expectedCssProp,
              "First background image upon init OK");  
   });  

Test 2: Make sure that image is changed after specified time amount

We want to specify time between image transitions in milliseconds due initialization via transitionTime plugin configuration parameter, while animationTime parameter while control how long image transitions last. So we'll have to test on every (animationTime + transitionTime * k + overhead) milliseconds if images has changed and is matching next image in array. Overhead mentioned here is to make sure any overhead operations complete, and I'll set it to 20ms which is more than enough on modern computers to execute. K parameter represent ordinal number of last transition happened. We'll have to use asynchronous calls for this, and thus utilize qunit's start() and stop() functions (more details about these functions on qunit official site)


      var arrImageList =['http://www.westminsterkennelclub.org/breedinformation/working/images/samoyed.jpg',
                'http://static.tumblr.com/o9yg6la/zsymaeh1o/samoyedaa.jpg',
                'http://fotos.tsncs.com/img/20120410/7273071/cachorros-de-samoyedo-24703108_3.jpg',
                'http://www.dogbreedinfo.com/images17/SamoyedHolly3YearsOld.JPG'],
            $module;
    
    
            test("initAndTransition",function(){
      
                var expectedCssProp = "url(" + arrImageList[0] + ")",           
                    transitionTestCounter =1,
                    animationTime = 300,
                    transitionTime = 2000;
      
                $module = $('#module').imageSlideshow({
                    imageList : arrImageList,
                    animationTime : animationTime,
                    transitionTime : transitionTime
                });
                
                //test if first image displayed OK
                equal($module.find('.current-img').css('background-image'),
                      expectedCssProp,"First background image upon init OK");
               
                stop();
               
                    var testTransition = function(){
                    //notify qunit we're to starting test so test context can be initalized
                    start();
                   
                    expectedCssProp = "url(" + arrImageList[transitionTestCounter++] + ")";            
                    
                    equal($module.find('.current-img').css('background-image'),
                           expectedCssProp,(transitionTestCounter-1) + " transition OK");
                    
                    if(transitionTestCounter < arrImageList.length){
                        //notify qunit test is finished
                        stop();
                        
                        //on each successive execution after first wait for transitionTime + overhead (20ms) to test again
                        setTimeout(testTransition, transitionTime + 20);
                    }
                };
                //schedule first async call
                setTimeout(testTransition,animationTime + transitionTime + 20);            
            });

Note that wit this code we're not only testing IF image changed, we are also testing WHEN it changed. We could predict that plugin fires event that can notify outside world about event happening, and use event handler to test for image url, but this way we have two flies in one hit.

Test 3: Control whatever animation loops or not

We'll just extend test above to include loop plugin configuration variable that controls whatever animation loops once last image has been reached. Below is modified test code that includes described test

    test("initAndConfiguration",function(){
      
                var expectedCssProp = "url(" + arrImageList[0] + ")",transitionTestCounter =1
                ,animationTime = 300
                ,transitionTime = 2000;
      
                $module = $('#module').imageSlideshow({
                    imageList : arrImageList,
                    animationTime : animationTime,
                    transitionTime : transitionTime,
                    loop: false
                });
     
                equal($module.find('.current-img').css('background-image'),
                         expectedCssProp,"First background image upon init OK");

                stop();
                var testTransition = function(){
                    //notify qunit we're to starting test so test context can be initalized
                    start();
                    expectedCssProp = "url(" + arrImageList[transitionTestCounter++] + ")";            
                    equal($module.find('.current-img').css('background-image'),
                               expectedCssProp,(transitionTestCounter-1) + " transition OK");
                    
                    if(transitionTestCounter < arrImageList.length){
                        //notify qunit test is finished
                        stop();
                        //on each successive execution after first wait for
                        // transitionTime + overhead (20ms) to test again

                        setTimeout(testTransition, transitionTime + 20);
                    } else {
                         stop();
                        setTimeout(function(){
                            start();
                            
                            //after last transition and loop set to false
                            // we're expected to see last image in array as image shown
                            expectedCssProp = "url(" + arrImageList[arrImageList.length - 1] + ")";     
                            equal($module.find('.current-img').css('background-image'),
                                        expectedCssProp,"Loop false OK");                 
                                                    
                        },transitionTime + 20);
                    }
                };
                setTimeout(testTransition,animationTime + transitionTime + 20);               
            });

Note that in test above we're only covering case when loop is set to false. To have more code coverage, we would have to write opposite test that sets loop configuration variable to true, and asserts whatever image swithced to first after reaching end of array.

Test 4: Verify that plugin events and methods for changing image work properly

WIll try to hook onto plugin's imageChanged event,  and ability that plugin user can jump to any image in given gallery. Also, we'll introduce another configuration variable  autoplay that controls whatever plugin automatically starts changing images upon initialization - we're not testing effect of this variable on plugin at this point, but it's assumed if we're about to control slideshow programatically, there ain't going to be any auto-advance. Notice usage of ok() function form qunit which validates boolean value.


            test('moveAndEvents',function(){
                var eventFired = false,
                
                //variable eventFired shall be asserted later in code
                eventHandler = function(){
                    eventFired = true;
                },
                expectedCssProp = "url(" + arrImageList[2] + ")"
            
                $module = $('#module').imageSlideshow({
                    imageList : arrImageList,
                    animationTime : 300,
                    transitionTime : 2000,
                    loop: false,
                    autoPlay: false
                });
     
                $module.bind('imageChanged',eventHandler);
                
                //display third image
                $module.imageSlideshow('displayImage',2);
                
                //test if third image shown
                equal($module.find('.current-img').css('background-image'),
                                      expectedCssProp,"displayImgae method OK");
                
                ok(eventFired, "imageChanged event fired OK");               
            });

Event with lots of code above there is still space for improvement and even greater code coverage with tests. One of ideas is testing for loop:true, testing whatever object fired with imageChanged event carries proper information and image currently displayed, validating css configuration object (not mentioned above, examine source code for more details). Whole source code for both plugin and tests can be found at github. Below you can see demo of plugin in action, showing pictures of beautiful sammy dogs:








No comments:

Post a Comment