Jasmine & ExtJS’ MVC: A Love Story

I’ve been looking for a way to perform unit tests on my new project’s UI code. The projects I worked on before were practically prepackaged and handed to me — Java has a pretty mature testing and build suite: JUnit and Maven. My current project is 99% JavaScript, and there isn’t a defacto test suite for that. Googling around has led me to several testing tools, such as Selenium and JSpec. I even began digging deeper into some of them, but then I discovered Jasmine, and that Sencha developers use Jasmine. It was a sign.

So, with Jasmine downloaded, I began playing with it and trying its examples. I loved it. Pure JavaScript solution with no external dependencies; I could simply hit the test page and off the tests go. I’m sure with a little more digging it would work in a offline/build/cli environment with PhantomJS or node.js. The only hurdle I had left was how (and what) I wanted to test. Not every file/class in this UI stand alone, or the application state. How would I want to simulate the server? So many things to sort out before I was satisfied that I had a solution.

Then I had an idea. What if the tests ran under their own version of Ext.Application? My main app.js file was simple: it defined the “Application”, its namespace, its path, the controllers and the launch method, which, for me, only initiated the login sequence. Basically, my idea was to copy the app.js to app-test.js, then copy index.html to test.html (this application is using the ExtJS4 MVC project layout).

So, here’s my folder structure. It will look a little familiar as I’ve based this example on the documentation’s example layout.

Folder Structure

So, we have the jasmine package under app-test/lib, and mock data under app-test/mock and all the test suites (“specs”) under app-test/specs.

index.html and test.html look very similar, except that test doesn’t include the loading markup and loads the jasmine scripts and app-test.js instead of app.js.

Those familiar with either ExtJS or Jasmine can probably see where I’m going with this. Jasmine doesn’t start automatically. It has a bootstrap method that you call to run the tests and show the output. The Jasmine examples always show the test-runner.html with a script block in the body that runs as soon as the browser hits that block. This isn’t what we want for this application. Not all of the resources and code will be loaded, at least not consistently at this point. So we have the app-test.js define the Application in “test mode.” Maybe a visual is better in this case.

This is the main entry point and the app.js.

<html>

<head>
    <title>App</title>
    <link rel="shortcut icon" href="resources/favicon.ico">
    <link rel="stylesheet" type="text/css" href="extjs/resources/css/ext-all-gray.css">
    <link rel="stylesheet" type="text/css" href="resources/css/main.css">
</head>
<body>

<div id="loading-mask"></div>
<div id="loading">
    <div class="loading-indicator">Loading...</div>
</div>

<script type="text/javascript" src="extjs/ext-debug.js"></script>
<script type="text/javascript" src="app.js"></script>

</body>
</html>

 

Ext.application({
    name: 'App',
    appFolder: 'app',

    controllers: [
        'Session'
        /*... all your controllers here...
         * they will include your views, models, etc
         */
    ],

    launch: function() {
        setTimeout(
            function clearMask(){
                Ext.get('loading').remove();
                Ext.get('loading-mask').fadeOut({remove:true});
                resizeBlocker(Ext.Element.getViewWidth());
            },
            100);

        /* This is this application's kickstart... this shows a login
         * form if not logged in and opens the Viewport once logged in.
         * If a login/session is detected on reload, it will just open
         * the viewport.
         */
        App.controller.Session.login();
    }
});

Now this is what goes into test.html and app-test.js:

<!--
This is a Jasmine test runner. please see the wiki on how to write tests.
https://github.com/pivotal/jasmine/wiki
-->
<html>
<head>
    <title>App - Jasmine Test Runner</title>
    <link rel="shortcut icon" href="resources/favicon.ico">
    <link rel="stylesheet" type="text/css" href="app-test/lib/jasmine-1.1.0/jasmine.css">

    <!-- all your framework code here -->
    <script type="text/javascript" src="extjs/ext-debug.js"></script>

    <!-- Jasmin code here -->
    <script type="text/javascript" src="app-test/lib/jasmine-1.1.0/jasmine.js"></script>
    <script type="text/javascript" src="app-test/lib/jasmine-1.1.0/jasmine-html.js"></script>

    <!-- include specs here -->
    <script type="text/javascript" src="app-test/specs/example.spec.js"></script>

    <!-- test launcher -->
    <script type="text/javascript" src="app-test.js"></script>
</head>
<body></body>
</html>

 

/*
 * Define mock framework objects here
 */
var AppConfig = {host: 'test:'};

var io = {} //socket.io mock?...

Ext.application({
    name: 'App',
    appFolder: 'app',

    controllers: [
        'Session'
        /*... all your controllers here...
         * they will include your views, models, etc
         */
    ],

    launch: function() {
        hookAjax();

        //include the tests in the test.html head
        jasmine.getEnv().addReporter(new jasmine.TrivialReporter());
        jasmine.getEnv().execute();
    }
});

Next, what about those pesky server calls? Lets reroute those to our mock directory. In my application all Ajax calls use a url in a config for the host. This allows us to change the host without deploying to a new server. So, because of this, its super easy to detect a server call: its prefixed with the config value of the host and I’ve set the mock config object above to have the host set to “test:”, so here is my hook:

function hookAjax()
{
    Ext.Ajax.request_forReal = Ext.Ajax.request;
    Ext.Ajax.request = function test_ajax(o){
        if(/^test:/i.test(o.url)){
            o.url = o.url.replace(/^test:/i, './app-test/mock');
        }
        this.request_forReal.apply(this, arguments);
    };
}

In summation, what does this get us? We now have a way to isolate anything we want in the application and test the heck out of it. We can test integration of components/controllers/models, or individual components.

Bam. *mike drop*

Enhanced by Zemanta
  • Pingback: ExtJS Unit Testing Using Jasmine | Atomic Spin()

  • telekosmos

    Just a point (a bit late, but later is better than never :-D)
    Regarding to stores, views, models, they MUST BE in a controller, and the controller in the ‘controllers’ array in the Application instance. Otherwise they would be undefined. IMHO, this one restricts the unit testing, as you are unable to test a model or store in isolation (you always have to have the store in a controller).

    Maybe including a mock controller…

  • Sebastian Woinar

    Hey Jonathan,
    I want to fall in love with Jasmine too 😉

    But I tried to create an instance of one of my controllers (which uses automatically created getter and setter with
    stores: [‘TestStore’],
    views: [‘TestView’] …
    ) the whole day but just got TypeErrors during the creation of the accessors.

    Could you please post one of your controller tests? How do you instantiate them?

    Thank you,
    Sebastian

  • Great post Jonathan! I have also struggled with integrating my Jasmine tests into ExtJS and Sencha Touch apps, and it’s good to see you’ve found a solid way to do it.

    I recently started a project with Sencha Touch MVC which I have hosted on GitHub (https://github.com/arthurakay/Prize-Patrol). My unit tests are still a work in progress, but I am trying to take Jasmine testing to the next step – automation.

    When all is said and done, I’m hoping to have JsTestRunner launch my unit tests (built with Jasmine) on every commit. My project isn’t quite there yet… but I’m getting close.

    Good luck to you on your project! I look forward to reading more about your progress!

    • Thanks 🙂

    • Marc

      @Arthur, just to let you know, your link is broken (it’s got a bracket at the end). Cheers,