This post originally appeared on strongloop.com. StrongLoop provides tools and services for companies developing APIs in Node. Learn more...

In the first three articles in this series, you built a simple mobile app and server using the Ionic Framework and StrongLoop LoopBack. In this article, you'll learn about one key advantage of building mobile apps with Ionic: access to the JavaScript testing ecosystem. Testing native apps is hard. TravisCI has beta support for Android builds, and setting up iOS testing on Travis is a nightmarish tangle of accounts and certificates. Since your Ionic app is just JavaScript, however, you can test your AngularJS code in a browser. In this article, you'll use karma and PhantomJS to test the directives in your mobile app.

Karma Basics

TLDR; See a diff for this step on GitHub

Karma is a browser automation tool for testing. The general purpose of karma is to start a browser, run some code, and log the output to the shell. In this section, you'll learn enough about karma to run a basic directive test in Travis; if you're interested in learning more, I wrote a more detailed guide to Karma on the StrongLoop blog.

Karma is like gulp; it's a lightweight core that's highly pluggable, but needs a plugin to do anything non-trivial. In particular, you need plugins to enable karma to launch specific browsers. For this article, you'll use 2 karma plugins: one that enables karma to launch PhantomJS, and one that provides an adapter for the mocha testing framework.

devDependencies: {
  "karma": "0.13.15",
  "karma-mocha": "0.2.0",
  "karma-phantomjs-launcher": "0.2.1",
  "mocha": "2.3.3",
  "phantomjs": "1.9.18"
}

Once you've installed karma, you'll need to do a little extra work to make your tests easy to run. You'll need to create a separate AngularJS module that contains all the stopwatch-specific logic from the third article in this series. Why? Getting the whole Ionic bundle to run in a browser is unnecessary in this case, because the stopwatch directives don't touch any Ionic-specific code. All you really need to test is how the directives work in conjunction with the HTTP interceptors and other Ionic-specific configuration.

Once you've created this stopwatch.js file that contains an AngularJS module with all of your stopwatch-specific directives, it's time to set up a karma config file. Karma config files can be intimidating at first, but they make sense once you remember the 3 things that karma is responsible for: starting browsers, running some JavaScript, and reporting output.

module.exports = function(config) {
  config.set({
    // Start these browsers
    browsers: ['PhantomJS'],
    // Load these files
    basePath: '../',
    files: [
      'http://code.jquery.com/jquery-1.9.1.js',
      'https://cdnjs.cloudflare.com/ajax/libs/chai/3.4.0/chai.js',
      'https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular.js',
      'https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular-resource.js',
      'https://ajax.googleapis.com/ajax/libs/angularjs/1.4.7/angular-mocks.js',
      'https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.6/moment.js',
      'www/js/stopwatch.js',
      'www/js/directives/index.js',
      'www/js/templates/index.js',
      // These are all your test files
      'test/*.test.js'
    ],
    frameworks: ['mocha'],
    // And a couple other details
    port: 9876,
    singleRun: true
  });
};

The above config assumes that all the tests will be in the test/ directory. Let's write a basic mocha test and run it.

describe('test', function() {
  it('works', function() {
    assert.equal('A', 'A');
  });
});

In order to make karma easier to run, you can create a script in your package.json:

"scripts": {
  "test": "karma start test/karma.phantom.conf.js"
}

Now, once you run npm test, you should see output that looks like this:

$ npm test

> stopwatch-example@1.0.0 test
> karma start test/karma.phantom.conf.js

05 11 2015 18:43:30.691:INFO [karma]: Karma v0.13.15 server started at http://localhost:9876/
05 11 2015 18:43:30.699:INFO [launcher]: Starting browser PhantomJS
05 11 2015 18:43:31.357:INFO [PhantomJS 1.9.8 (Linux 0.0.0)]: Executed 1 of 1 SUCCESS (0.038 secs / 0 secs)

Testing AngularJS Directives

TLDR; See a diff for this step on GitHub

No offense to Aristotle, but a test that asserts that A is A is not particularly interesting. Part of what makes AngularJS so powerful is the ability to test directives from the level of user interactions without a server. In other words, you can test your directives using jQuery's .click() and .val() methods without having to set up a server.

How does this work? The key is AngularJS' $compile service. If you don't understand the $compile docs, don't worry, I don't either. At a high level, the $compile service takes in an HTML string and an AngularJS scope, and compiles the HTML into a DOM element attached to the given scope. In other words, the $compile service lets you instantiate your directives.

var injector = angular.injector(['stopwatch', 'ngMockE2E']);

injector.invoke(function($rootScope, $compile) {
  parentScope = $rootScope.$new();

  var html = '<timer on-time-saved="onSaved(time);"></timer>';
  element = $compile(html)(parentScope);
  // Can now do things like `element.css('display')` and
  // `element.find('button').click();`
});

The angular.injector() function is what you use to generate your $compile service. This creates a new AngularJS dependency injector from the given modules. Since the injector knows about the stopwatch module, the $compile service will know about the timer directive and give you a jQuery handle to an instantiated timer directive.

In order to really run this test, you're going to need three more details. First, you're going to need to define the onSaved() function. This is just going to be a stub for testing.

parentScope.onSaved = function(time) {
  parentScope.onSaved.calls.push(time);
};
parentScope.onSaved.calls = [];

Secondly, recall that the timer directive creates a new AngularJS scope. The parentScope variable is useful, but what if we want access to the directive's internals? Thankfully, a scope has a $$childHead member that points to its first child scope.

scope = parentScope.$$childHead;

Finally, you're going to want to test that clicking on the 'Save' button triggers the correct HTTP request. Thankfully, that's what AngularJS' $httpBackend service (part of the ngMockE2E module you saw above) is for.

injector.invoke(function($rootScope, $compile, $httpBackend) {
  parentScope = $rootScope.$new();
  httpBackend = $httpBackend;
});

With that, you're ready to write your first test. The full code can be found on GitHub. Let's walk through the high-level concepts in this test. Now that you have the element, you first want to click on the 'Start' button in the timer directive and make sure the directive reacted correctly.

// Click the 'Start' button
element.find('button[ng-click="startTimer()"]').click();
// Make sure the internal state updated
assert.equal(scope.state, 'RUNNING');
assert.equal(scope.ms, 0);
// And make sure the 'Start' button no longer appears
assert.ok(element.find('button[ng-click="startTimer()"]').
  hasClass('ng-hide'));

Now, you want to wait a while and make sure the timer updates every second correctly.

setTimeout(function() {
  assert.equal(scope.ms, 1000);
}, 1100);

Once the timer has updated to 1 second, your test is going to click the stop button and assert that the directive reacted correctly.

// Click on the 'Stop' button
element.find('button[ng-click="stopTimer()"]').click();
// Make sure the internal state updated correctly
assert.equal(scope.state, 'STOPPED');

// Tell AngularJS to expect an HTTP POST request whose body satisfies
// this function
var validateData = function(data) {
  assert.deepEqual(JSON.parse(data), { time: 1000 });
  return true;
};
httpBackend.expectPOST('http://localhost:3000/api/Times', validateData).
  respond(200, { result: 'success' });

// Click on the 'Save' button and trigger the request
element.find('button[ng-click="save()"]').click();

// Tell AngularJS to respond to the HTTP POST request
httpBackend.flush();

// Make sure the internal state updated correctly
assert.equal(scope.state, 'SUCCESS');

If you're interested in learning more, there's a detailed guide to testing AngularJS directives on my blog.

Setting Up Travis

TLDR; See a diff for this step on GitHub

Once you have tests for your mobile app, you can set up Travis to run your tests on every commit to GitHub. The tests are the hard part, setting up Travis to work with Node is much easier than Android or iOS. All you need to do is set up an account on Travis, add your Ionic app's GitHub repo, and add the below .travis.yml file to your repo.

language: node_js
node_js:
  - "4"
script: "npm test"

Easy, right? If you need extra help setting up Travis, there's a detailed guide to setting up Travis for Node.js on the StrongLoop blog.

Conclusion

That's a wrap! You've now built a LoopBack REST API, a desktop web client, and an Ionic Framework mobile app. You even put together a CI setup for your mobile app. LoopBack and it's corresponding AngularJS SDK enabled you to generate a REST API and it's corresponding UI components with a few commands and minimal coding. The Ionic Framework let you leverage these browser-based UI components to build an easily-testable mobile app. Looks like JavaScript isn't just for the browser (or the server) anymore.

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus