Search code examples
unit-testinggoogle-apps-scriptqunit

QUnit testing a function which leans on document properties


I'm trying to write some unit tests for an Apps Script add-on designed for Google Docs. A few of the functions I'd like to have unit tests for call PropertiesService.getDocumentProperties(). A simple example function in my add-on:

function baseFontSize() {
  var baseFontSize = JSON.parse(
      PropertiesService.getDocumentProperties().getProperty('baseFontSize'));
  if (baseFontSize === null) {
    baseFontSize = JSON.parse(
        PropertiesService.getUserProperties().getProperty('baseFontSize'));
    if (baseFontSize === null) {
      PropertiesService.getUserProperties().setProperty('baseFontSize', '11');
      baseFontSize = 11;
    }
    PropertiesService.getDocumentProperties()
        .setProperty('baseFontSize', JSON.stringify(baseFontSize));
  }
  return baseFontSize;
}

I'm writing my tests with the QUnit for Google Apps Script library:

function doGet(e) {
  QUnit.urlParams(e.parameter);
  QUnit.config({title: 'My test suite'});
  QUnit.load(testSuite);
  return QUnit.getHtml();
}

QUnit.helpers(this);

function testSuite() {
  // some module() and test() calls deleted for brevity...

  test('baseFontSize', function() {
    // PropertiesService.getDocumentProperties() === null
    // how to test baseFontSize()?
  });

  // more module() and test() calls...
}

Since the test suite is not running within a document, there are no document properties. It seems that the only way to test my function would be to mock the getDocumentProperties function. Of course, the only Apps Script mock/stub libraries I can find are either meant to test within a Node.js environment, or are not sufficiently complete for my needs, which means I would have to roll my own.


Solution

  • While I still hope to find a more elegant solution, until that occurs I have thrown together a simple stub framework based on the SinonJS API, and I'm injecting the stub into my function to be tested (since I can't actually stub PropertiesService, as it's frozen).

    Roughly, my solution (my project already includes Underscore, so I take advantage of it where I can):

    function stub(object, property, impl) {
      const original = object[property];
      if (_.isFunction(impl)) object[property] = wrapFunction(impl);
      else object[property] = wrapFunction(function() { return impl; });
      object[property].restore = function() {
        if (!_.isUndefined(original)) object[property] = original;
        else delete object[property];
      };
      return object[property];
    }
    
    function wrapFunction(fn) {
      const properties = _.mapObject({
        get callCount() { return calls.length; },
        get called() { return calls.length > 0; },
        get notCalled() { return calls.length === 0; },
        // etc...
      }, function(v, k, o) { return Object.getOwnPropertyDescriptor(o, k); });
      const calls = [];
      const wrappedFn = function() {
        const args = Array.prototype.slice.call(arguments);
        var error;
        var returnVal;
        try { returnVal = fn.apply(this, args); }
        catch (e) { error = e; }
        calls.push({
          thisObj: this,
          params: args,
          error: error,
          returnVal: returnVal,
        });
        return returnVal;
      };
      Object.defineProperties(wrappedFn, properties);
      return wrappedFn;
    }
    

    Then, my test:

    test('baseFontSize', function() {
      const propService = {};
      stub(propService, 'getDocumentProperties', fakeGetProperties());
      stub(propService, 'getUserProperties', fakeGetProperties());
      equal(baseFontSize(propService), 11);
    });
    
    //...
    
    function fakeGetProperties() {
      const map = {};
      const container = {
        deleteAllProperties: function() {_.each(map, function(v, k){depete map[k];});},
        deleteProperty: function(k) { delete map[k]; },
        getKeys: function() { return Object.keys(map); },
        getProperties: function() { return _.clone(map); },
        getProperty: function(key) { return map[key] || null; },
        setProperties: function(obj, deleteOthers) {
          if (deleteOthers) container.deleteAllProerties();
          _.each(obj, function(v, k) { map[k] = v; });
        },
        setProperty: function(key, value) { map[key] = value; },
      };
      return function() { return container; };
    }
    

    And the edited version of my function to test, with DI for PropertiesService:

    function baseFontSize(propService) {
      if (_.isUndefined(propService)) {
        propService = PropertiesService;
      }
    
      var baseFontSize = JSON.parse(
          propService.getDocumentProperties().getProperty('baseFontSize'));
      if (baseFontSize === null) {
        baseFontSize = JSON.parse(
            propService.getUserProperties().getProperty('baseFontSize'));
        if (baseFontSize === null) {
          propService.getUserProperties().setProperty('baseFontSize', '11');
          baseFontSize = 11;
        }
        propService.getDocumentProperties()
            .setProperty('baseFontSize', JSON.stringify(baseFontSize));
      }
      return baseFontSize;
    }