How to test HttpClient's callback functions - aca-mobile/ti-unit GitHub Wiki

This post describes how we refactored a simple but untestable piece of HttpClient code to a testable piece of HttpClient code. It is a recipe which you can follow.

Disclaimer: we want to provide examples of how to write testable code and we want you to reflect and think about ways to make your code fully testable . Personally we prefer to use https://github.com/jasonkneen/RESTe wherever possible for http communication.

Take this simple piece of code

function _prepareRequest(successCallback, successDataCallback, errorCallback, timeout) {
    if(!networkUtil.checkNetworkAvailability()){
        return;
    }

    var client = Ti.Network.createHTTPClient({
        onload : function(e) {
            var locationHeader = this.getResponseHeader('Location');
            
            if (!_.isUndefined(successDataCallback)) {
                successDataCallback && successDataCallback(this.responseData, e, locationHeader);
            } else {
                successCallback && successCallback(this.responseText, e, locationHeader);
            }
        },
        onerror : function(e) {
            errorCallback && errorCallback(e, this.responseText);
        }
    });

    return client;
}

This code is very hard to test. We don't have access to the correct this context in our test. The following part of this blog post describes the steps we took to make our code testable/

Let's start with our first iteration:

To make sure that your callbacks can be tested, write

function __onLoadCallback(e){
 var locationHeader = this.getResponseHeader('Location');
          if(!_.isUndefined(locationHeader) && _.isEqual(locationHeader.search("https://"), -1)) {
              locationHeader = locationHeader.replace("http://", "https://");
          }

          if (!_.isUndefined(successDataCallback)) {
              successDataCallback && successDataCallback(this.responseData, e, locationHeader);
          } else {
              successCallback && successCallback(this.responseText, e, locationHeader);
          }
}

function __onErrorCallback(e){
   errorCallback && errorCallback(e, this.responseText);
}

var client = Ti.Network.createHTTPClient({
      onload : __onLoadCallback,
      onerror : __onErrorCallback,
      timeout : (timeout || DEFAULT_TIMEOUT)
  });

instead of

var client = Ti.Network.createHTTPClient({
      onload : function(e) {
          return this.getResponseHeader('Location');
      },
      onerror : function(e) {
          errorCallback && errorCallback(e, this.responseText);
      },
      timeout : (timeout || DEFAULT_TIMEOUT)
  });

Unfortunately, at the time of writing, callback functions must still be exposed to ensure testability. Therefore we have agreed upon the convention to "write 2 leading _ in front of exposed private functions. Eg.

module.exports = {
  post: _post,
  get: _get,
  __onSuccessCallback: __onSuccessCallback
}

Now we are ready to write our test

describe('sample test', function() {

    Ti = require('./jsca.api.parser').parseFromConfig();

    var codeUnderTest;

    beforeEach(function () {
        codeUnderTest = require('../app/lib/code.under.test.js');
    });

    it('callback with this object', function(){

        var callback = codeUnderTest.__onSuccessCallback;
        var callbackObj = {
            getResponseHeader: function(header){
                return "whatever you want";
            }
        };

        // If you want to manipulate the this object, you must invoke it like this
        var result = callback.apply(callbackObj, ['optional', 'arguments']);

        // Thus not like this: var result = codeUnderTest.__onSuccessCallback();

        expect(result).toBe('whatever you want');
    });

});

However, what if our surrounding function contains arguments which should be accessible from within our callback? Example: Given the following code with private function _prepareRequest and public function _post which internally makes a call to _prepareRequest:

function _prepareRequest(successCallback, successDataCallback, errorCallback, timeout) {
    if(!networkUtil.checkNetworkAvailability()){
        return;
    }


    var client = Ti.Network.createHTTPClient({
        onload : function(e) {
            var locationHeader = this.getResponseHeader('Location');
            if(!_.isUndefined(locationHeader) && _.isEqual(locationHeader.search("https://"), -1)) {
                locationHeader = locationHeader.replace("http://", "https://");
            }

            if (!_.isUndefined(successDataCallback)) {
                successDataCallback && successDataCallback(this.responseData, e, locationHeader);
            } else {
                successCallback && successCallback(this.responseText, e, locationHeader);
            }
        },
        onerror : function(e) {
            errorCallback && errorCallback(e, this.responseText);
        },
        timeout : (timeout || DEFAULT_TIMEOUT)
    });
    return client;
}

function _post(successCallback, errorCallback, url, properties, timeout){
    var client = _prepareRequest(successCallback, undefined, errorCallback, timeout);

    if (!_.isUndefined(client)) {
        client.open("POST", url);
        _setHeaders(client);
        client.setRequestHeader("Content-Type", "application/json");
        client.send(JSON.stringify(properties));
    }
}
module.exports = {
    post: _post,
};
  • mock the this object
  • invoke these callbacks from within our tests

Solution:

  • isolate the onload and onerror callbacks in separate functions (best prefixed with double underscore eg. __onLoadCallback)
  • expose them on the module.exports on a separate test object (eg. see below)
  • remove the test object from the module.exports via the build script (don’t publish it in production)

This initially solved our problem. the callback functions became testable(see example below for more information + see ACA wiki). However, the callbacks also referenced parameters from the original enclosing function.

HOWEVER ... due to our refactoring, parameters passed to the private function eg. successDataCalback are not available anymore

Therefore, we need to refactor our code a little bit more. Instead of just referencing the unload and onerror functions, we need to wrap them in a function, and invoke them in such a way that we are able to pass exactly those parameters that we want (this with function.apply)

Finally, the fully testable code becomes:

function _prepareRequest(successCallback, successDataCallback, errorCallback, timeout) {
    if(!networkUtil.checkNetworkAvailability()){
        return;
    }

    var that = this;

    var client = Ti.Network.createHTTPClient({
        onload : function(e){
            __onLoadCallback.apply(that, [e, successCallback, successDataCallback, this.getResponseHeader('Location'), this.responseData, this.responseText]);
            },
        onerror : __onErrorCallback.apply(that, [e, errorCallback, this.responseText]),
        timeout : (timeout || DEFAULT_TIMEOUT)
    });
    return client;
}

function __onLoadCallback(e, successCallback, successDataCallback, locationHeader, responseData, responseText){
    var locationHeader = locationHeader;
    if(!_.isUndefined(locationHeader) && _.isEqual(locationHeader.search("https://"), -1)) {
        locationHeader = locationHeader.replace("http://", "https://");
    }

    if (!_.isUndefined(successDataCallback)) {
        successDataCallback && successDataCallback(responseData, e, locationHeader);
    } else {
        successCallback && successCallback(responseText, e, locationHeader);
    }
}

function __onErrorCallback(e, errorCallback, responseText){
    errorCallback && errorCallback(e, responseText);
}

function _post(successCallback, errorCallback, url, properties, timeout){
    var client = _prepareRequest(successCallback, undefined, errorCallback, timeout);
    if (!_.isUndefined(client)) {
        client.open("POST", url);
        _setHeaders(client);
        client.setRequestHeader("Content-Type", "application/json");
        client.send(JSON.stringify(properties));
    }
}

module.exports = {
    post: _post,
    test: {
        onloadCallback: __onLoadCallback,
        onErrorCallback: __onErrorCallback
    }
};

Consequences:

  • callback functions may not be inlined but must be referenced