fvanwijk/mox

View on GitHub
src/mox.js

Summary

Maintainability
F
3 days
Test Coverage
var moxConfig = {};
angular.module('mox', [])
  .service('Mox', MoxBuilder);

var mox = angular.injector(['mox']).get('Mox');

beforeEach(function () {
  mox.get = {};
});

/**
 * Constructor function for a Mox object
 */
function MoxBuilder() {

  var
    moduleNames,
    moduleFns,
    postInjectFns;

  function execute() {
    var moduleResult;
    if (moduleFns.length) {
      moduleResult = angular.mock.module.apply(this, moduleFns);
      angular.mock.inject(); // to make sure that moduleFns had ran
    }
    postInjectFns.forEach(function (cb) { cb(); });
    return moduleResult;
  }

  /**
   * Reset the queues of moduleFns and postInejctFns so that next mox usage starts with a fresh new setup
   */
  function cleanUp() {
    moduleNames = [];
    moduleFns = [];
    postInjectFns = [];
  }

  function assertDeprecatedArguments(args) {
    if (!args[0]) {
      throw Error('Please provide arguments');
    }
  }

  function createResourceConstructor(resource) {
    // Create a mocked constructor that returns the mock itself plus the data that is provided as argument
    var fn = function (data) {
      return angular.extend({}, resource, data);
    };
    resource.constructor = jasmine.createSpy('constructor');
    spyCallFake(resource.constructor, fn);
    angular.extend(resource.constructor, resource);

    return resource.constructor;
  }

  /**
   * For now a service is a resource when the name ends with resource
   * @param {Object} service
   * @returns {boolean}
   */
  function isFilter(service) {
    return angular.isFunction(service) && service.name !== 'Resource';
  }

  function spyCallFake(spy, callback) {
    if (currentSpec.isJasmine2) {
      spy.and.callFake(callback);
    } else {
      spy.andCallFake(callback);
    }
  }

  function spyReturn(spy, returnValue) {
    if (currentSpec.isJasmine2) {
      spy.and.returnValue(returnValue);
    } else {
      spy.andReturn(returnValue);
    }
  }

  cleanUp();

  this.factories = moxConfig; // Factory functions for creating mocks
  this.get = {}; // Cache for mocked things
  this.testTemplateAppendSelector = '#mox-container';

  /**
   * Injects one or multiple services and returns them
   *
   * @param {string} name of the inject to get
   * @returns {Object}
   */
  this.inject = function inject(name) {
    if (!currentSpec.$injector) {
      throw Error('Sorry, cannot inject ' + name + ' because the injector is not ready yet. Please load a module and call mox.run() or inject()');
    }
    var args = Array.prototype.slice.call(arguments, 0);
    var injects = {};
    angular.forEach(args, function (injectName) {
      injects[injectName] = currentSpec.$injector.get(injectName);
    });
    return args.length === 1 ? injects[name] : injects;
  };

  /**
   * Saves modules or module config functions to be passed to angular.mocks.module when .run() is called.
   *
   * @returns {Object}
   */
  this.module = function module() {
    var args = Array.prototype.slice.call(arguments, 0);
    angular.forEach(args, function (arg) {
      if (angular.isString(arg)) {
        moduleNames.push(arg);
      }
    });

    moduleFns = moduleFns.concat(args);

    return this;
  };

  /**
   * Return module config function for registering mock services.
   * It creates mocks for resources and filters automagically.
   * The created mock is saved to the mox.get object for easy retrieval.
   *
   * @param {...string|string[]} mockName service(s) to mock
   */
  this.mockServices = function mockServices() {
    function getMethodNames(obj) {
      var methodNames = [];
      angular.forEach(obj, function (method, methodName) {
        if (angular.isFunction(method)) {
          methodNames.push(methodName);
        }
      });
      return methodNames;
    }

    function spyOnService(service) {
      if (isFilter(service)) {
        if (!service.isSpy) {
          service = { filter: service };
          spyOn(service, 'filter');
          service = service.filter;
        }
      } else {
        angular.forEach(getMethodNames(service), function (methodName) {
          if (!service[methodName].isSpy) {
            spyOn(service, methodName);

            // Temporary solution to support resource instance methods immediately
            if (service.name === 'Resource') {
              service['$' + methodName] = jasmine.createSpy('$' + methodName);
            }
          }
        });

        if (service.name === 'Resource') {
          service = createResourceConstructor(service);
        }
      }

      return service;
    }

    assertDeprecatedArguments(arguments);

    var mockNames = arguments;

    moduleFns.push(function mockServicesFn($provide) {
      angular.forEach(mockNames, function (mockName) {
        var mockArgs;
        if (angular.isArray(mockName)) {
          mockArgs = angular.copy(mockName);
          mockName = mockArgs.shift();
          mockArgs.unshift($provide);
        } else {
          mockArgs = [$provide];
        }

        if (mockName in mox.factories) {
          mox.factories[mockName].apply(this, mockArgs);
        } else {
          $provide.decorator(mockName, function ($delegate) {
            if (!(mockName in mox.get)) {
              $delegate = spyOnService($delegate);
              mox.get[mockName] = $delegate;
            }

            return $delegate;
          });

          // Make sure that the decorator function is called
          postInjectFns.push(function cacheMock() {
            mox.inject(mockName);
          });
        }
      });
    });

    return this;
  };

  /**
   * Register constants to be mocked and define their value. These mocks can be injected in a config function immediately.
   * Pass a name and value as parameters for one constant, or an object with definitions for multiple constants.
   *
   * @param {...string|Object} config
   */
  this.mockConstants = function mockConstants(config) {
    assertDeprecatedArguments(arguments);

    if (angular.isString(config)) {
      var key = arguments[0];
      config = {};
      config[key] = arguments[1];
    }

    moduleFns.push(function mockConstantsFn($provide) {
      angular.forEach(config, function (value, mockName) {
        mox.save($provide, mockName, value, 'constant');
      });
    });

    return this;
  };

  /**
   * Register directive(s) to be mocked. The mock will be an empty directive with the same isolate scope as the original directive,
   * so the isolate scope of the directive can be tested:
   *
   *   compiledElement.find('[directive-name]').toContainIsolateScope({ key: value });
   *
   * Accepts 3 types of input:
   * 1. a directive name: the same as with an array, but just for one directive
   * 2. a directive factory object, for your own mock implementation.
   *   - name property is required
   *   - template, templateUrl, require, controller, link and compile properties are overwritable
   * 3. an array of directive names (see 1) or objects (see 2)
   *
   * @param {...string|string[]|...Object|Object[]} directiveName directive(s) to mock
   * @returns {Object}
   */
  this.mockDirectives = function mockDirectives() {
    assertDeprecatedArguments(arguments);

    var directiveNames = arguments;

    moduleFns.push(function mockDirectivesFn($provide) {
      angular.forEach(directiveNames, function (directive) {
        var mock = angular.isString(directive) ? { name: directive } : directive;
        /*
         * Cannot use $compileProvider.directive because that does not override the original directive(s) with this name.
         * We decorate the original directive so that we can reuse the isolate bindings and other non-mockable DDO properties.
         */
        $provide.decorator(mock.name + 'Directive', function ($delegate) {
          angular.extend($delegate[0], {
            require: mock.require || undefined,
            template: mock.template || undefined,
            templateUrl: mock.templateUrl || undefined,
            transclude: mock.transclude || undefined,
            controller: mock.controller || angular.noop,
            compile: mock.compile || undefined,
            link: mock.link || undefined
          });

          // All directives are unregistered and replaced with this mock
          return [$delegate[0]];
        });
      });
    });

    return this;
  };

  /*
   * This function "disables" the given list of directives, not just mocking them
   * @param {string[]|string} directiveName directive(s) to disable
   * @returns {Object}
   */
  this.disableDirectives = function () {
    assertDeprecatedArguments(arguments);

    var directiveNames = arguments;

    moduleFns.push(function disableDirectivesFn($provide) {
      angular.forEach(directiveNames, function (directiveName) {
        $provide.factory(directiveName + 'Directive', function () { return {}; });
      });
    });

    return this;
  };

  /**
   * Registers controllers to be mocked. This is useful for view specs where the template contains an `ng-controller`.
   * The view's `$scope` is not set by the controller anymore, but you have to set the `$scope` manually.
   *
   * @param {...string|string[]} controllerName
   * @returns {Object}
   */
  this.mockControllers = function mockControllers() {
    assertDeprecatedArguments(arguments);

    var controllerNames = arguments;

    moduleFns.push(function ($controllerProvider) {
      angular.forEach(controllerNames, function (controllerName) {
        $controllerProvider.register(controllerName, angular.noop);
      });
    });

    return this;
  };

  /**
   * Replace templates that are loaded via ng-include with a single div that contains the template name.
   *
   * @param {...string|string[]|...Object|Object[]} template
   * @returns {Object}
   */
  this.mockTemplates = function mockTemplates() {
    assertDeprecatedArguments(arguments);

    var templates = arguments;

    postInjectFns.push(function () {
      var $templateCache = mox.inject('$templateCache');
      angular.forEach(templates, function (templateConfig) {
        var path;
        var template;
        if (angular.isString(templateConfig)) {
          path = templateConfig;
          template = '<div>This is a mock for ' + path + '</div>';
        } else {
          angular.forEach(templateConfig, function (val, key) {
            template = val;
            path = key;
          });
        }
        $templateCache.put(path, template);
      });
    });

    return this;
  };

  /*
   * Define return values or fake callback methods for methods of multiple mocks
   *
   * Usage:
   * mox.setupResults(function() {
   *   return {
   *     MockResource1: {
   *       get: mockResult
   *     },
   *     MockResource2: {
   *       query: fakeFunction
   *     },
   *     MockFilter: 'returnValueString' // object as return value not allowed!
   *   }
   * });
   *
   * @return {Object}
   */
  this.setupResults = function setupResults(configFn) {
    postInjectFns.push(function setupResultsFn() {
      var config = configFn();
      angular.forEach(config, function (mockConfig, mockName) {
        var mock = mox.inject(mockName);
        if (!(mockName in mox.get)) {
          cleanUp();
          throw new Error(mockName + ' is not in mox.get');
        }

        function setSpyResult(spy, returnValue) {
          if (typeof returnValue === 'function') {
            spyCallFake(spy, returnValue);
          } else {
            spyReturn(spy, returnValue);
          }
        }

        // Iterate over methods of mock
        if (typeof mockConfig === 'object' && mockConfig.constructor === Object) {
          angular.forEach(mockConfig, function (returnValue, method) {
            if (!(method in mock)) {
              cleanUp();
              throw new Error('Could not mock return value. No method ' + method + ' created in mock for ' + mockName);
            }
            setSpyResult(mock[method], returnValue);
          });
        } else { // the mock itself is a spy
          setSpyResult(mock, mockConfig);
        }
      });
    });

    return this;
  };

  /**
   * Executes the module config and post inject functions.
   *
   * @returns result of the angular.mock.module function
   */
  this.run = function run() {
    var moduleResult = execute();
    cleanUp();

    return moduleResult;
  };

  /**
   * Registers a mock and save it to the cache.
   * This method usually is used when defining a custom mock factory function or when manually creating a mock
   *
   * @param {Object} $provide
   * @param {string} mockName
   * @param {Object} mock
   * @param {string} recipe
   * @returns {*}
   */
  this.save = function saveMock($provide, mockName, mock, recipe) {
    recipe = recipe || 'value';
    $provide[recipe](mockName, mock);
    mox.get[mockName] = mock;
    return mock;
  };

  /**
   * Simple wrapper around jasmine.createSpyObj, ensures a new instance is returned for every call
   * @param {string} mockName the name of the service to register the mock for
   * @param {array} mockedMethods methods to create spies for on the mock. If mockedMethods is undefined,
   *                the mock itself will be a spy. If false, the mock will be undefined and registered as constant.
   *
   * @returns {Object}
   */
  this.createMock = function createMock(mockName, mockedMethods) {

    return function ($provide, nameOverride) {

      var mock;
      if (angular.isUndefined(mockedMethods)) {
        mock = jasmine.createSpy(mockName);
      } else if (mockedMethods !== false) {
        mock = jasmine.createSpyObj(mockName, mockedMethods);
      }

      if ($provide) {
        mox.save($provide, nameOverride || mockName, mock, mockedMethods === false ? 'constant' : undefined);
      }

      return mock;
    };
  };

  /**
   * Creates a mock for angular $resources which can be initialized in the spec with a $provide
   * to immediately inject it into the current module. The instance functions ($get, etc) are added to the mock
   * as well, so this mock is used both as a "class" $resource and instance $resource.
   *
   * Usage example:
   *
   * // in MockServices
   * // ...
   * FooResource: createResourceMock('FooResource')
   * // ...
   *
   * // in a spec:
   *
   * fooResource = mox.module('...').register('FooResource').run();
   */
  this.createResourceMock = function createResourceMock(mockName, methodNames) {
    var allMethods = {};
    function addToMethodList(methodName) {
      allMethods[methodName] = methodName;
      allMethods['$' + methodName] = '$' + methodName;
    }
    angular.forEach(methodNames, addToMethodList);
    return function ($provide) {
      var mock = jasmine.createSpyObj(mockName, Object.keys(allMethods));

      mock = createResourceConstructor(mock);

      if ($provide) {
        mox.save($provide, mockName, mock.constructor);
      }

      return mock.constructor;
    };
  };

  return this;
}