Tahsis Claus
Tahsis Claus

Reputation: 1929

Unit Testing Angular 1.5 component that requires ngModel

To test angular 1.5 components, the docs recommend you use ngMock's $componentController instead of using $compile if you don't need to test any of the DOM.

However, my component uses ngModel which I need to pass into the locals for $componentController, but there is no way to programmatically get the ngModelController; the only way to test it is to actually $compile an element with it on it, as this issue is still open: https://github.com/angular/angular.js/issues/7720.

Is there any way to test my components controller without resorting to $compiling it? I also don't want to have to mock the ngModelController myself as its behavior is somewhat extensive and if my tests rely on a fake one rather than the real thing there is a chance newer versions of Angular could break it (though that probably isn't an issue given Angular 1 is being phased out).

Upvotes: 4

Views: 716

Answers (1)

p0lar_bear
p0lar_bear

Reputation: 2275

tl;dr: Solution is in the third code block.

but there is no way to programmatically get the ngModelController

Not with that attitude. ;)

You can get it programmatically, just a little roundabout. The method of doing so is in the code for ngMock's $componentController service (paraphrased here); use $injector.get('ngModelDirective') to look it up, and the controller function will be attached to it as the controller property:

this.$get = ['$controller','$injector', '$rootScope', function($controller, $injector, $rootScope) {
    return function $componentController(componentName, locals, bindings, ident) {
        // get all directives associated to the component name
        var directives = $injector.get(componentName + 'Directive');
        // look for those directives that are components
        var candidateDirectives = directives.filter(function(directiveInfo) {
            // ...
        });

        // ...

        // get the info of the component
        var directiveInfo = candidateDirectives[0];
        // create a scope if needed
        locals = locals || {};
        locals.$scope = locals.$scope || $rootScope.$new(true);
        return $controller(directiveInfo.controller, locals, bindings, ident || directiveInfo.controllerAs);
    };
}];

Though you need to supply the ngModelController locals for $element and $attrs when you instantiate it. The test spec for ngModel demonstrates exactly how to do this in its beforeEach call:

beforeEach(inject(function($rootScope, $controller) {
    var attrs = {name: 'testAlias', ngModel: 'value'};


    parentFormCtrl = {
        $$setPending: jasmine.createSpy('$$setPending'),
        $setValidity: jasmine.createSpy('$setValidity'),
        $setDirty: jasmine.createSpy('$setDirty'),
        $$clearControlValidity: noop
    };


    element = jqLite('<form><input></form>');


    scope = $rootScope;
    ngModelAccessor = jasmine.createSpy('ngModel accessor');
    ctrl = $controller(NgModelController, {
        $scope: scope,
        $element: element.find('input'),
        $attrs: attrs
    });


    //Assign the mocked parentFormCtrl to the model controller
    ctrl.$$parentForm = parentFormCtrl;
}));

So, adapting that to what we need, we get a spec like this:

describe('Unit: myComponent', function () {
    var $componentController,
        $controller,
        $injector,
        $rootScope;

    beforeEach(inject(function (_$componentController_, _$controller_, _$injector_, _$rootScope_) {
        $componentController = _$componentController_;
        $controller = _$controller_;
        $injector = _$injector_;
        $rootScope = _$rootScope_;
    }));

    it('should update its ngModel value accordingly', function () {
        var ngModelController,
            locals
            ngModelInstance,
            $ctrl;

        locals = {
            $scope: $rootScope.$new(),
            //think this could be any element, honestly, but matching the component looks better
            $element: angular.element('<my-component></my-component>'),
            //the value of $attrs.ngModel is exactly what you'd put for ng-model in a template
            $attrs: { ngModel: 'value' }
        };
        locals.$scope.value = null; //this is what'd get passed to ng-model in templates
        ngModelController = $injector.get('ngModelDirective')[0].controller;

        ngModelInstance = $controller(ngModelController, locals);

        $ctrl = $componentController('myComponent', null, { ngModel: ngModelInstance });

        $ctrl.doAThingToUpdateTheModel();
        scope.$digest();

        //Check against both the scope value and the $modelValue, use toBe and toEqual as needed.
        expect(ngModelInstance.$modelValue).toBe('some expected value goes here');
        expect(locals.$scope.value).toBe('some expected value goes here');
    });
});

ADDENDUM: You can also simplify it further by instead injecting ngModelDirective in the beforeEach and setting a var in the describe block to contain the controller function, like you do with services like $controller.

describe('...', function () {
    var ngModelController;

    beforeEach(inject(function(_ngModelDirective_) {
        ngModelController = _ngModelDirective_[0].controller;
    }));
});

Upvotes: 4

Related Questions