CodeView

QUnit extensions for testing cacheable resources

testmanager.js

/**
 * Test manager for self-invoking scripts acting on cacheable resources
 * ====================================================================
 * 
 * Use the test manager when testing cacheable resources, such as images, with
 * scripts or functions which are self-invoking.
 *
 * .............................................................................
 * 
 * IMPORTANT: Put the following line at the end of your test suite (inside the
 * document-ready wrapper):
 *
 * $.testManager.setupComplete();
 *
 * .............................................................................
 * 
 * ## Dependencies
 *
 * - jQuery 1.6.1 or newer
 * - QUnit
 * - Promises extension
 *   (http://www.zeilenwechsel.de/it/code/browse/jquery-promises/prod/promises.js)
 *
 * ## Rationale
 * 
 * Self-invoking functions execute as soon as they are loaded, so they can be run
 * only once in a test suite. This is a problem, in particular when cacheable
 * resources are involved. They require a specific order of events:
 *
 * 1. The test HTML and CSS must be set up for all tests before the script under
 *    test (SUT) is loaded. The SUT frequently expects the HTML to be there and 
 *    the document-ready event to be fired before it actually does its work.
 *    Also, latency for non-cached resources can be simulated by setting up the
 *    HTML (e.g. an <img> tag) without specifying the src attribute just yet.
 *   
 * 2. Resources which are tested from the cache must have loaded.
 *   
 * 3. The SUT must load and execute.
 *   
 * 4. Now is the time to end the fake latency. Set the src for resources which
 *    are supposed to be interacting with the SUT while they are still downloading.
 *   
 * 5. Once the last resource is there, inspect the results of the interaction
 *    by actually running the tests.
 *
 * This is a fairly complicated setup. It would spread out the parts of each test
 * over the the functions handling the various setup phases, making for unreadable
 * test code which is difficult to grasp.
 *
 * That's why the testManager handles the process behind the scenes. It provides
 * a centralized, one-time general setup and coherent test code for each test.
 *
 *
 * ## API
 * 
 * ### The API for the setup phase:
 *
 * o $.testManager.setSutSrc( urlpath ):
 *   Sets the src attribute for the script under test. Required.
 *   
 * o $.testManager.setGlobalTestTemplate( template ):
 *   Defines an HTML snippet to be used for each test. Required. Will be wrapped
 *   in a div#testId.
 *   
 *   Don't specify the src attribute of the cacheable resource (will be set by
 *   the test manager). Do not use IDs in the snippet, as it will be cloned for
 *   every test. Examples: '<p class="outerPara"><img /></p>', '<img />'
 *   
 * o $.testManager.setGlobalResourceSelector( selector ):
 *   Defines the jQuery selector identifying the cacheable resource in the snippet,
 *   relative to div#testId. Required. Example: 'img'
 *   
 * o $.testManager.setGlobalResourceSrc( url ):
 *   Sets the src attribute of the cacheable resource. Required.
 *   
 * o $.testManager.logToConsole( [yesno] ):
 *   Logs test setup and execution. Optional.
 *   
 * o $.testManager.showCanvas( [yesno] ):
 *   Shows the test HTML at the bottom of the page. Optional.
 *   
 * o $.testManager.setCanvas( selector ):
 *   Changes the canvas selector. Optional. Default is #qunit-fixture-nocleanup.
 *   You need to set up the corresponding div on your test page. Note that you
 *   CAN'T use the QUnit default, #qunit-fixture. See the comment in setCanvas
 *   for details.
 *
 * Some of these parameters can be overridden in an individual test. Just create
 * the corresponding property for the testSetup object, e.g. testSetup.logging =
 * true. Setting testSetup.resourceSrc and testSetup.resourceSelector individually
 * is possible, but untested.
 *
 * ### Test functions and commands
 *
 * The test functions are living in the global namespace, just like standard
 * QUnit functions. Use them to define and run your tests.
 *
 * o cacheAwareTest( testSignature, testSetup, cbTestBody ):
 *   Runs a test, based on two config objects and the test body containing the
 *   actual assertions.
 *
 *   1. testSignature object:
 *   
 *     - testSignature.test: 
 *       Display name of the test. Required.
 *     
 *     - testSignature.module:
 *       Module name, as string. Optional.
 *     
 *       Note that modules are not fully supported, as tests of one module don't
 *       necessarily execute in sequence.
 *
 *   2. testSetup object:
 *   
 *     - testSetup.useCachedResource:
 *       Whether or not the resource used in this test should be present in the
 *       cache, or else be in the process of downloading when the SUT acts on it.
 *       Boolean, required.
 *     
 *     - testSetup.stageCssProps:
 *       Object defining the CSS rules for the test stage (div#testID). Example:
 *       testSetup.stageCssProps = { 'width': '200px', 'float': 'left' };
 *     
 *       Use it to set the properties relevant to the test itself. General
 *       styling should be done globally on the test page. The test stage divs
 *       can be targeted with a .testStage rule.
 *     
 *     - testSetup.resourceCssProps:
 *       Object defining the CSS rules for the cacheable resource. Example:
 *       testSetup.resourceCssProps = { 'width': '100px', 'max-width': '50px' };
 *       
 *       Again, general styling should be done globally on the test page, using a
 *       selector like '.testStage img'.
 *     
 *     - testSetup.logging:
 *       Enables or disables logging for an individual test. Boolean, optional.
 *       Used to override the global setting ($.testManager.logToConsole).
 *
 *     Note that the testSetup.testId property is generated by the test manager.
 *     Do not set it manually.
 *
 *   3. cbTestBody function:
 *
 *     The callback containing the assertions. The function signature must be
 *     function ( fixture, setup ) { your assertions here }.
 *
 *     - fixture:
 *       Provides access to the test stage as a whole with fixture.stage, and to
 *       the cacheable resource with fixture.resource. Both contain the jQuery
 *       object wrapping the element.
 *
 *     - setup:
 *       The testSetup object. It also contains the testID (setup.testId).
 *   
 * o runTestGroup( testSignature, testSetup, cbTestBody ):
 *   Runs a test setup twice, against a cached and an uncached resource. Invoked
 *   in the same way as cacheAwareTest() - just omit testSetup.useCachedResource,
 *   which will be set automatically.
 *   
 * o $.testManager.setupComplete:
 *   Required. Needs to be called at the end of the test suite.
 *   
 * ### Other parts of the test manager API:
 *   
 * o $.testManager.TestCase:
 *   Constructor, creates a new test case. Normally not called directly anywhere
 *   in the tests. Test case creation is instead handled by cacheAwareTest(). If
 *   used directly, invoke it with 'new'.
 *   
 * o $.testManager.utils.clone:
 *   Utility method, creates a deep clone of an object or array. Used internally
 *   in runTestGroup().
 *
 * ## Examples:
 *
 * Single test:
 *
 *  cacheAwareTest(
 *      {
 *          module: 'Testing uncached image',
 *          test: 'Image has px width, max-width in %, max-width is binding'
 *      },
 *      {
 *          resourceCssProps: { 'width': '100px', 'max-width': '25%' },
 *          stageCssProps: { 'width': '200px' },
 *          useCachedResource: false
 *      },
 *      function ( fixture, setup ) {
 *          equals( fixture.resource.width(),  50, 'image width equals max-width' );
 *          equals( fixture.resource.height(), 50, 'image height is scaled by max-width' );
 *      }
 *  );
 *
 * Test group:
 * 
 *  runTestGroup(
 *      {
 *          module: 'Basic testing',
 *          test: 'Image has px width, max-width in %, max-width is binding'
 *      },
 *      {
 *          resourceCssProps: { 'width': '100px', 'max-width': '25%' },
 *          stageCssProps: { 'width': '200px' }
 *      },
 *      function ( fixture, setup ) {
 *          equals( fixture.resource.width(),  50, 'image width equals max-width' );
 *          equals( fixture.resource.height(), 50, 'image height is scaled by max-width' );
 *      }
 *  );
 *
 * See http://www.zeilenwechsel.de/it/code/browse/ie8-max-width-fix/tests/ie8-fix-maxwidth-test.js
 * for an example test case.
 *
 * ## Other
 * 
 * @author  Michael Heim, http://www.zeilenwechsel.de/
 * @license MIT, http://www.opensource.org/licenses/mit-license.php
 * @version 0.1.1, 13 July 2011
 */
 
jQuery.extend( {
 
    testManager: ( function ( $ ) {
 
            // private properties
        var canvas, 
            showCanvas = false,
            testIDs = [],
            logging = false,
            preSutSetupDone = new $.Promises(),
            sutSrc,
            testHtmlTemplate,
            resourceSrc,
            resourceSelector,
            sutLoaded,
 
            // private functions
            cloneDeep = function( source, cloneOnto ) {
 
                var i,
                    toStr = Object.prototype.toString,
                    astr = "[object Array]";
 
                cloneOnto = cloneOnto || {};
                for (i in source) {
 
                    if (source.hasOwnProperty(i)) {
 
                        if (typeof source[i] === "object") {
 
                            cloneOnto[i] = (toStr.call(source[i]) === astr) ? [] : {};
                            cloneDeep(source[i], cloneOnto[i]);
 
                        } else {
 
                            cloneOnto[i] = source[i];
 
                        }
 
                    }
 
                }
 
                return cloneOnto;
 
            },
 
            loadScriptUnderTest = function () {
 
                if ( logging ) console.log( 'SUT: starting to load' + sutSrc );
                return $.getScript( sutSrc ).promise();
 
            },
 
            setSutSrc = function ( urlpath ) {
 
                sutSrc = urlpath;
                return this;
 
            },
 
            setGlobalTestTemplate = function ( template ) {
 
                testHtmlTemplate = template;
                return this;
 
            },
 
            setGlobalResourceSrc = function ( url ) {
 
                resourceSrc = url;
                return this;
 
            },
 
            // for the cacheable resource:
            setGlobalResourceSelector = function ( selector ) {
 
                resourceSelector = selector;
                return this;
 
            },
 
            logToConsole = function ( yesno ) {
 
                logging = ( yesno === undefined ? true : yesno );
 
                if ( logging ) {
 
                    preSutSetupDone
                        .done( function () { console.log( 'test setup: complete' ); } )
                        .fail( function () { console.log( 'test setup: not completed properly, a problem has occurred' ); } );
                    sutLoaded
                        .done( function () { console.log( 'SUT: is loaded' ); } )
                        .fail( function () { console.log( 'SUT: not loaded properly, a problem has occurred' ); } );
 
                }
 
                return this;
 
            },
 
            setCanvas = function ( selector ) {
 
                // It is important NOT to use the default #qunit-fixture. That
                // would create conflicts during the delayed, staggered test
                // invocation. The default #qunit-fixture is cleaned up by
                // QUnit.reset() automatically after each test, which would mess
                // up the setup process.
 
                if ( selector === "#qunit-fixture" || selector === "div#qunit-fixture" ) throw new Error( "Test setup: don't use the default #qunit-fixture with cache-aware tests" );
 
                selector = selector || '#qunit-fixture-nocleanup';
                canvas = $( selector );
 
                if ( ! canvas ) throw new Error( "Test setup: the QUnit fixture for these tests does not exist in your test page HTML. Please create '" + selector + "' or set a different canvas with $.testManager.setCanvas( selector )" );
 
                canvas.css( {
                    'position': ( showCanvas ? 'static' : 'absolute' ),
                    'top': '-10000px',
                    'left': '-10000px'
                } );
 
                return this;
            }
 
            showCanvas = function ( yesno ) {
 
                showCanvas = ( yesno === undefined ? true : yesno );
                canvas.css( 'position', ( showCanvas ? 'static' : 'absolute' ) );
                return this;
 
            },
 
            setupComplete = function () {
 
                if ( logging ) console.log( 'test setup: unblocking completion' );
                preSutSetupDone.stopPostponing();
 
            },
 
            TestCase = function ( testSignature, testSetup, cbTestBody ) {
 
                    // private vars
                var self = this,
                    fixture,
                    testNode,
 
 
                    // private methods
                    createTestNode = function () {
 
                        testNode = $( '<div id="' + self.setup.testId + '" />' )
                                    .addClass( 'testStage' )
                                    .append( testHtmlTemplate )
                                    .appendTo( canvas );
 
                        if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': test HTML: inserted into DOM' );
 
                    },
 
                    loadCacheableResource = function () {
 
                        // The source attribute of the resource must be set
                        // _after_ the resource is added to the document tree.
                        // This will trigger the normal (buggy) IE behaviour.
                        //
                        // If the source attribute were added before the node is
                        // appended to an existing element in the DOM, that
                        // would trigger a different set of bugs (e.g. height()
                        // reporting the original, unscaled height of an image,
                        // as if no CSS had been applied).
 
                        var dfd = $.Deferred( function ( dfd ) {
 
                            // NB: Even cached resources must be downloaded fresh
                            // into the cache for each test. IE is buggy when
                            // re-referencing the same resource over and over
                            // again in a test suite and at some point fails to
                            // load the resource (although it is readily available
                            // in the cache).
                            var query = '?' + $.now(),
                                lim = setTimeout( function () { dfd.reject( 'timeout' ); }, 1000 );
 
                            if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': resource: starting to load' + ' - src=' + self.setup.resourceSelector + query );
                            $( self.setup.resourceSelector, testNode )
                                .attr( 'src', self.setup.resourceSrc + query )
                                .load( function () { if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': resource: has loaded' ); } )
                                .load( dfd.resolve )
                                .error( function () { dfd.reject( 'error' ); } );
 
                        } );
 
                        dfd.fail( function ( err ) {
                            throw new Error( 'resource: ' + err + ', preloading did not work in test #' + self.setup.testId + ' for src ' + $( self.setup.resourceSelector, testNode ).get(0).src );
                        } );
 
                        return dfd.promise();
 
                    },
 
                    prepareTest = function () {
 
                        var dfd = $.Deferred( function ( dfd ) {
 
                            // Set up the HTML stucture and the CSS for the test
                            var resourceCssProps = self.setup.resourceCssProps || {},
                                stageCssProps = self.setup.stageCssProps || {};
 
                            createTestNode();
 
                            fixture = {
                                'stage':     testNode.css( stageCssProps ),
                                'resource':  $( self.setup.resourceSelector, testNode ).css( resourceCssProps )
                            };
 
                            if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': test HTML: CSS is applied' );
 
                            // Set up the loading of the cacheable resource
                            if ( self.setup.useCachedResource ) {
 
                                preSutSetupDone.add( loadCacheableResource() );
 
                            } else {
 
                                $.when( sutLoaded ).done( loadCacheableResource );
 
                                // Add an immediately resolved promise to
                                // preSutSetupDone, in order to resolve it if
                                // nothing else is added. Otherwise, execution
                                // would stall. 
                                preSutSetupDone.add( $.Deferred().resolve() );
 
                            }
 
                            // Set up the execution of the actual tests
                            if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': test execution: now waiting for the SUT to be fully loaded' );
 
                            $.when( sutLoaded ).done( function () {
 
                                if ( fixture.resource.get(0).complete ) {
 
                                    if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': test execution: resource already loaded, running assertions immediately' );
                                    if ( ! self.setup.useCachedResource ) throw new Error( 'resource has finished loading too fast, ie before the SUT was ready. Testing an uncached resource still in the process of downloading is now impossible' );
                                    dfd.resolve();
 
                                } else {
 
                                    if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': test execution: resource not yet loaded completely, waiting for it to finish, then running assertions' );
                                    fixture.resource.load( dfd.resolve );
 
                                }
 
                            } );
 
                        } );
 
                        return dfd.promise();
 
                    };
 
 
                // init and public properties
 
                this.htmlTemplate = testHtmlTemplate;
                this.signature = testSignature;
                this.setup = testSetup;
                this.cbTestBody = cbTestBody;
 
                if ( resourceSelector             === undefined ) throw new Error( 'Testmanager setup: the selector for the cacheable resource must be defined' );
                if ( resourceSrc                  === undefined ) throw new Error( 'Testmanager setup: the url of the cacheable resource must be defined' );
                if ( this.setup.useCachedResource === undefined ) throw new Error( 'Test setup: useCachedResource is required when calling createTestNode' );
 
                if ( this.setup.logging          === undefined ) this.setup.logging = logging;
                if ( this.setup.resourceSelector === undefined ) this.setup.resourceSelector = resourceSelector;
                if ( this.setup.resourceSrc      === undefined ) this.setup.resourceSrc = resourceSrc;
 
                this.setup.testId = 'test' + ( testIDs.length + 1 ) + ( this.setup.useCachedResource ? '_cached' : '_uncached' );
                testIDs.push(
                    {
                        id:     this.setup.testId,
                        name:   this.signature.test,
                        module: this.signature.module
                    }
                );
 
 
                // Public methods
                this.go = function () {
 
                    if ( this.setup.logging ) console.log( '#' + this.setup.testId + ': test execution: start setup' );
 
                    $.when( prepareTest() )
 
                        .done( function () {
 
                            if ( self.signature.module ) module( self.signature.module );
 
                            test( self.signature.test, function () {
 
                                    if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': test execution: start evaluating assertions' );
                                    self.cbTestBody( fixture, self.setup );
                                    if ( self.setup.logging ) console.log( '#' + self.setup.testId + ': test execution: assertions evaluated' );
 
                                } );
 
                        } )
                        .fail( function () {
                            throw new Error( 'Test setup: prepareTest did not finish properly, a problem has occurred' );
                        } );
 
                };
 
            },
 
            init = function () {
 
                $( document ).ready( function () {
                    setCanvas();
                } );
 
                preSutSetupDone.postpone();
                sutLoaded = $.when( preSutSetupDone ).pipe( loadScriptUnderTest );
 
                preSutSetupDone.fail( function () { throw new Error( 'test setup: not completed properly, a problem has occurred' ); } );
                sutLoaded.fail( function () { throw new Error( 'SUT: not loaded properly, a problem has occurred' ); } );
 
            };
 
        // init
        init();
 
        // Public API
        return {
            setSutSrc:                 setSutSrc,
            setGlobalTestTemplate:     setGlobalTestTemplate,
            setGlobalResourceSelector: setGlobalResourceSelector,
            setGlobalResourceSrc:      setGlobalResourceSrc,
            logToConsole:              logToConsole,
            showCanvas:                showCanvas,
            setCanvas:                 setCanvas,
            setupComplete:             setupComplete,
            TestCase:                  TestCase,
            utils: { clone: cloneDeep }
        };
 
    } )( jQuery )
 
} );
 
 
/**
 * Global test functions
 */
 
function cacheAwareTest ( testSignature, testSetup, cbTestBody ) {
 
    new $.testManager.TestCase( testSignature, testSetup, cbTestBody ).go();
 
};
 
function runTestGroup ( testSignature, testSetup, cbTestBody ) {
 
    var test2Signature = $.testManager.utils.clone( testSignature ),
        test2Setup     = $.testManager.utils.clone( testSetup );
 
    // First pass: using a cached resource
    testSetup.useCachedResource = true;
    testSignature.test += ' - from cache';
    cacheAwareTest( testSignature, testSetup, cbTestBody );
 
    // Second pass: using a loading resource
    test2Setup.useCachedResource = false;
    test2Signature.test += ' - while loading';
    cacheAwareTest( test2Signature, test2Setup, cbTestBody );
 
};