Avoiding calls to $watch functions immediately after initialization

3,315 views
Skip to first unread message

John Williams

unread,
Mar 1, 2013, 4:04:34 PM3/1/13
to ang...@googlegroups.com
I'm writing unit tests for what seems like a pretty simple use case, and I'm running into a problem that's not a showstopper, but it's inconvenient enough that it makes me wonder if I've got something wrong in my basic approach.

The page I'm working on is a simple options page with some text boxes and radio buttons, kind of like this:

<input type="text" ng-model="foo">
The controller code looks like this:

$scope.foo = dataStore.getFoo();
$scope.$watch('foo', function(newValue) {
  dataStore.setFoo(newValue);
  dataStore.commit();
});
Everything works fine, but when replace dataStore with a mock object, I can see that setFoo and commit are being called as soon as the scopes $digest method runs. The troubles me for a couple of reasons. First, it violates my intuition that initializing a form should be a read-only operation. Second, it complicates my unit tests; I want to write a Jasmine test that looks like this:

describe('MyCtrl', function() {
  var scope;
  var dataStore;

  beforeEach(function() {
    inject(function($rootScope) {
      scope = $rootScope.$new();
    });
    dataStore = jasmine.createSpyObj('dataStore', ['setFoo', 'commit']);
    dataStore.getFoo = function() { return 'hello, world'; };
  });

  it('called commit after foo is modified', function() {
    MyCtrl(scope);
    scope.$digest(); // Calls dataStore.commit, but shouldn't.
    scope.foo = 'new value';
    scope.$digest(); // Calls dataStore.commit again.
    expect(dataStore.commit).toHaveBeenCalled();
  });
});
The intent of this test is to verify that the second call to scope.$digest calls dataStore.commit, but it's not actually verifying anything because the first call to scope.$digest calls dataStore.commit, and the test is written so it can't distinguish the two cases. To make the test do what I really want, it looks like I would need to code it more like this:

  it('called commit after foo is modified', function() {
    MyCtrl(scope);
    scope.$digest();
    var oldLength = dataStore.commit.calls.length; // Remember how many unwanted calls there were.
    scope.foo = 'new value';
    scope.$digest();
    expect(dataStore.commit.calls.length > oldLength).toBe(true); // Check for the desired call.
  });
...or like this:
  it('called commit after foo is modified', function() {
  MyCtrl(scope);
  scope.$digest();
  dataStore.commit.reset(); // Forget the unwanted call to commit.
  scope.foo = 'new value';
  scope.$digest();
  expect(dataStore.commit).toHaveBeenCalled(); // Check for the
desired call.
});
I don't think either version is satisfactory because in order to write either one, I have to know about the extraneous call to dataStore.commit and account for it explicitly in the test. As the complexity of the controller increases, this turns into a substantial amount of boilerplate in the test code.

Does anyone have suggestion for how to make this code correct without adding a lot of complexity?

Peter Bacon Darwin

unread,
Mar 1, 2013, 4:14:42 PM3/1/13
to ang...@googlegroups.com
The $watch handler function actually takes two params: function(newVal, oldVal).  The handler is usually called once at the start to initialize everthing.  In this case the newVal === oldVal.  So if you check for this in your handler you can ignore this first call.  This is documented here: http://docs.angularjs.org/api/ng.$rootScope.Scope#$watch.

After a watcher is registered with the scope, the listener fn is called asynchronously (via $evalAsync) to initialize the watcher. In rare cases, this is undesirable because the listener is called when the result ofwatchExpression didn't change. To detect this scenario within the listener fn, you can compare thenewVal and oldVal. If these two values are identical (===) then the listener was called due to initialization.


--
You received this message because you are subscribed to the Google Groups "AngularJS" group.
To unsubscribe from this group and stop receiving emails from it, send an email to angular+u...@googlegroups.com.
To post to this group, send email to ang...@googlegroups.com.
Visit this group at http://groups.google.com/group/angular?hl=en-US.
For more options, visit https://groups.google.com/groups/opt_out.
 
 

John Williams

unread,
Mar 1, 2013, 4:33:30 PM3/1/13
to ang...@googlegroups.com
Thanks for the suggestion. I'll go with that approach for now, even though it still seems pretty messy. In my actual application there are a lot of calls to $watch, and every one of them needs the same test. Since this special case was important enough to describe in the docs, I think it's calling out for a new $watch-like method that encapsulates the pattern. I wonder what it should be called though...$whenChanged, maybe? It's a shame the name $watch is already taken.

--
You received this message because you are subscribed to a topic in the Google Groups "AngularJS" group.
To unsubscribe from this topic, visit https://groups.google.com/d/topic/angular/5ZuAizW7PXs/unsubscribe?hl=en-US.
To unsubscribe from this group and all its topics, send an email to angular+u...@googlegroups.com.

Peter Bacon Darwin

unread,
Mar 1, 2013, 4:37:09 PM3/1/13
to ang...@googlegroups.com
I don't tend to find this is a problem in my apps, since things that commit changes usually come from event handlers rather than watches.  I try to keep watches for keeping the data model in synch.
Reply all
Reply to author
Forward
0 new messages