ernestoalejo ernestoalejo - 1 month ago 15
AngularJS Question

bindToController in unit tests

I'm using bindToController in a directive to have the isolated scope directly attached to the controller, like this:

app.directive('xx', function () {
return {
bindToController: true,
controller: 'xxCtrl',
scope: {
label: '@',
},
};
});


Then in the controller I have a default in case label is not specified in the HTML:

app.controller('xxCtrl', function () {
var ctrl = this;

ctrl.label = ctrl.label || 'default value';
});


How can I instantiate xxCtrl in the Jasmine unit tests so I can test the ctrl.label?

describe('buttons.RemoveButtonCtrl', function () {
var ctrl;

beforeEach(inject(function ($controller) {
// What do I do here to set ctrl.label BEFORE the controller runs?
ctrl = $controller('xxCtrl');
}));

it('should have a label', function () {
expect(ctrl.label).toBe('foo');
});
});


Plnkr to test the issue: http://plnkr.co/edit/LS1PDS?p=preview

Answer

In Angular 1.3 (see below for 1.4+)

Digging into the AngularJS source code I found an undocumented third argument to the $controller service called later (see $controller source).

If true, $controller() returns a Function with a property instance on which you can set properties.
When you're ready to instantiate the controller, call the function and it'll instantiate the controller with the properties available in the constructor.

Your example would work like this:

describe('buttons.RemoveButtonCtrl', function () {

  var ctrlFn, ctrl, $scope;

  beforeEach(inject(function ($rootScope, $controller) {
    scope = $rootScope.$new();

    ctrlFn = $controller('xxCtrl', {
      $scope: scope,
    }, true);
  }));

  it('should have a label', function () {
    ctrlFn.instance.label = 'foo'; // set the value

    // create controller instance
    ctrl = ctrlFn();

    // test
    expect(ctrl.label).toBe('foo');
  });

});

Here's an updated Plunker (had to upgrade Angular to make it work, it's 1.3.0-rc.4 now): http://plnkr.co/edit/tnLIyzZHKqPO6Tekd804?p=preview

Note that it's probably not recommended to use it, to quote from the Angular source code:

Instantiate controller later: This machinery is used to create an instance of the object before calling the controller's constructor itself.

This allows properties to be added to the controller before the constructor is invoked. Primarily, this is used for isolate scope bindings in $compile.

This feature is not intended for use by applications, and is thus not documented publicly.

However the lack of a mechanism to test controllers with bindToController: true made me use it nevertheless.. maybe the Angular guys should consider making that flag public.

Under the hood it uses a temporary constructor, we could also write it ourselves I guess.
The advantage to your solution is that the constructor isn't invoked twice, which could cause problems if the properties don't have default values as in your example.

Angular 1.4+ (Update 2015-12-06):
The Angular team has added direct support for this in version 1.4.0. (See #9425)
You can just pass an object to the $controller function:

describe('buttons.RemoveButtonCtrl', function () {

  var ctrl, $scope;

  beforeEach(inject(function ($rootScope, $controller) {
    scope = $rootScope.$new();

    ctrl = $controller('xxCtrl', {
      $scope: scope,
    }, {
      label: 'foo'
    });
  }));

  it('should have a label', function () {
    expect(ctrl.label).toBe('foo');
  });
});

See also this blog post.

Comments