4

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).

1 Answer 1

4

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;
    }));
});
Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.