Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angularjs unit test watch callback not getting called even with scope.$digest

I'm struggling unit testing a controller that watches a couple variables. In my unit tests, I can't get the callback for the $watch function to be called, even when calling scope.$digest(). Seems like this should be pretty simple, but I'm having no luck.

Here's what I have in my controller:

angular.module('app')
  .controller('ClassroomsCtrl', function ($scope, Classrooms) {

    $scope.subject_list = [];

    $scope.$watch('subject_list', function(newValue, oldValue){
      if(newValue !== oldValue) {
        $scope.classrooms = Classrooms.search(ctrl.functions.search_params());
      }
    });
});

And here's my unit test:

angular.module('MockFactories',[]).
  factory('Classrooms', function(){
    return jasmine.createSpyObj('ClassroomsStub', [
      'get','save','query','remove','delete','search', 'subjects', 'add_subject', 'remove_subject', 'set_subjects'
    ]);
  });

describe('Controller: ClassroomsCtrl', function () {

  var scope, Classrooms, controllerFactory, ctrl;

  function createController() {
    return controllerFactory('ClassroomsCtrl', {
      $scope: scope,
      Classrooms: Classrooms
    });
  }

  // load the controller's module
  beforeEach(module('app'));

  beforeEach(module('MockFactories'));

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

    Classrooms = _Classrooms_;

    controllerFactory = $controller;

    ctrl = createController();

  }));

  describe('Scope: classrooms', function(){

    beforeEach(function(){
      Classrooms.search.reset();
    });

    it('should call Classrooms.search when scope.subject_list changes', function(){
      scope.$digest();
      scope.subject_list.push(1);
      scope.$digest();

      expect(Classrooms.search).toHaveBeenCalled();
    });
  });
});

I've tried replacing all the scope.$digest() calls with scope.$apply() calls. I've tried calling them 3 or 4 times, but I can't get the callback of the $watch to get called.

Any thoughts as to what could be going on here?

UPDATE: Here's an even simpler example, that doesn't deal with mocks, stubbing or injecting factories.

angular.module('edumatcherApp')
  .controller('ClassroomsCtrl', function ($scope) {
    $scope.subject_list = [];
    $scope.$watch('subject_list', function(newValue, oldValue){
      if(newValue !== oldValue) {
        console.log('testing');
        $scope.test = 'test';
      }
    });

And unit test:

it('should set scope.test', function(){
  scope.$digest();
  scope.subject_list.push(1);
  scope.$digest();

  expect(scope.test).toBeDefined();
});

This fails too with "Expected undefined to be defined." and nothing is logged to the console.

UPDATE 2

2 more interesting things I noted.

  1. It seems like one problem is that newValue and oldValue are the same when the callback is called. I logged both to the console, and they are both equal to []. So, for example, if I change my $watch function to look like this:

    $scope.$watch('subject_list', function(newValue, oldValue){
        console.log('testing');
        $scope.test = 'test';
    });
    

the test passes fine. Not sure why newValue and oldValue aren't getting set correctly.

  1. If I change my $watch callback function to be a named function, and just check to see if the named function is ever called, that fails as well. For example, I can change my controller to this:

    $scope.update_classrooms = function(newValue, oldValue){
        $scope.test = 'testing';
        console.log('test');
    };
    
    $scope.watch('subject_list', $scope.update_classrooms);
    

And change my test to this:

it('should call update_classrooms', function(){
  spyOn(scope,'update_classrooms').andCallThrough();
  scope.$digest();
  scope.subject_list.push(1);
  scope.$digest();
  expect(scope.update_classrooms).toHaveBeenCalled();
});

it fails with "Expected spy update_classrooms to have been called."

In this case, update_classrooms is definitely getting called, because 'test' gets logged to the console. I'm baffled.

like image 785
CTC Avatar asked Jul 09 '14 06:07

CTC


2 Answers

I just ran into this problem within my own code base and the answer turned out to be that I needed a scope.$digest() call right after instantiating the controller. Within your tests you have to call scope.$digest() manually after each change in watched variables. This includes after the controller is constructed to record the initial watched value(s).

In addition, as Vitall specified in the comments, you need $watchCollection() for collections.

Changing this watch in your simple example resolves the issue:

$scope.$watch('subject_list', function(newValue, oldValue){
    if(newValue !== oldValue) {
        console.log('testing');
        $scope.test = 'test';
    }
});

to:

$scope.$watchCollection('subject_list', function(newValue, oldValue){
    if(newValue !== oldValue) {
        console.log('testing');
        $scope.test = 'test';
    }
});

I made a jsfiddle to demonstrate the difference:

http://jsfiddle.net/ydv8k4zy/ - original with failing test - fails due to using $watch, not $watchCollection

http://jsfiddle.net/ydv8k4zy/1/ - functional version

http://jsfiddle.net/ydv8k4zy/2/ - $watchCollection fails without the initial scope.$digest.

If you play around With console.log on the second failing item you'll see that the watch is called with the same value for old and new after the scope.$digest() (line 25).

like image 102
brocksamson Avatar answered Sep 28 '22 01:09

brocksamson


The reason that this isn't testable is because the way that the functions are passed into the watchers vs the way that spyOn works. The spyOn method takes the scope object and replaces the original function on that object with a new one... but most likely you passed the whole method by reference into the $watch, that watch still has the old method. I created this non-AngularJS example of the same thing:

https://jsfiddle.net/jonhartmann/9bacozmg/

var sounds = {};

sounds.beep = function () {
  alert('beep');
};

document.getElementById('x1').addEventListener('click', sounds.beep);

document.getElementById('x2').addEventListener('click', function () {
    sounds.beep = function () {
    alert('boop');
  };
});

document.getElementById('x3').addEventListener('click', function () {
    sounds.beep();
});

The difference between the x1 handler and the x3 handler is that in the first binding the method was passed in directly, in the second the beep/boop method just calls whatever method is on the object.

This is the same thing I ran into with my $watch - I'd put my methods on a "privateMethods" object, pass it out and do a spyOn(privateMethods, 'methodname'), but since my watcher was in the format of $scope.$watch('value', privateMethods.methodName) it was too late - the code would get executed by my spy wouldn't work. Switching to something like this skipped around the problem:

$scope.$watch('value', function () {
  privateMethods.methodName.apply(null, Array.prototype.slice.call(arguments));
});

and then the very expected

spyOn(privateMethods, 'methodName')
expect(privateMethods.methodName).toHaveBeenCalled();

This works because you're no longer passing privateMethods.methodName by reference into the $watch, you're passing a function that in turn executes the "methodName" function on "privateMethods".

like image 28
Jon Hartmann Avatar answered Sep 27 '22 23:09

Jon Hartmann