1

I have built an angular directive onInputChange that should fire a callback when the users changes a value of an input by either clicking outside of the input (blur) or hitting ENTER. The directive can be used like:

<input type="number" ng-model="model" on-input-change="callback()"/>

It uses the following code:

app.directive('onInputChange', [
    "$parse",
    function ($parse) {
        return {
            restrict : "A",
            require : "ngModel",
            link : function ($scope, $element, $attrs) {
                //
                var dirName     = "onInputChange",
                    callback    = $parse($attrs[dirName]),
                    evtNS       = "." + dirName,
                    initial     = undefined;

                //
                if (angular.isFunction(callback)) {
                    $element
                        .on("focus" + evtNS, function () {
                            initial = $(this).val();
                        })
                        .on("blur" + evtNS, function () {
                            if ($(this).val() !== initial) {
                                $scope.$apply(function () {
                                    callback($scope);
                                });
                            }
                        })
                        .on("keyup" + evtNS, function ($evt) {
                            if ($evt.which === 13) {
                                $(this).blur();
                            }
                        });
                }

                //
                $scope.$on("$destroy", function () {
                    $element.off(evtNS);
                });
            }
        };
    }
]);

The directive works as I would expect in my app. Now I've decided to write some tests to really ensure this is the case:

describe("directive", function () {

    var $compile, $rootScope, $scope, $element;

    beforeEach(function () {
        angular.mock.module("app");
    });

    beforeEach(inject(function ($injector) {

        $compile = $injector.get("$compile");
        $scope = $injector.get("$rootScope").$new();

        $scope.model = 0;

        $scope.onchange = function () {
            console.log("called");
        };

        $element = $compile("<input type='number' ng-model='model' on-input-change='onchange()'>")($scope);
        $scope.$digest();

        spyOn($scope, "onchange");
    }));

    afterEach(function () {
        $scope.$destroy();
    });

    it("has default values", function () {
        expect($scope.model).toBe(0);
        expect($scope.onchange).not.toHaveBeenCalled();
    });

    it("should not fire callback on internal model change", function() {
        $scope.model = 123;
        $scope.$digest();

        expect($scope.model).toBe(123);
        expect($scope.onchange).not.toHaveBeenCalled();
    });

    //this fails
    it("should not fire callback when value has not changed", function () {
        $element.focus();
        $element.blur();

        $scope.$digest();

        expect($scope.model).toBe(0);
        expect($scope.onchange).not.toHaveBeenCalled();
    });

    it("should fire callback when user changes input by clicking away (blur)", function () {
        $element.focus();
        $element.val(456).change();
        $element.blur();

        $scope.$digest();

        expect($scope.model).toBe(456);
        expect($scope.onchange).toHaveBeenCalled();
    });

    //this fails
    it("should fire callback when user changes input by clicking enter", function () {
        $element.focus();
        $element.val(789).change();
        $element.trigger($.Event("keyup", {keyCode:13}));

        $scope.$digest();

        expect($scope.model).toBe(789);
        expect($scope.onchange).toHaveBeenCalled();
    });

});

Now, my problem is that two of my tests are failing after run with karma:

A:

Failed directive should not fire callback when value has not changed Expected spy onchange not to have been called.

B:

Failed directive should fire callback when user changes input by clicking enter Expected spy onchange to have been called.


I've created a Plunker where you can try it yourself.

1. Why does my callback gets called even if the value has not changed?

2. How can I simulate the user hitting ENTER on my input? I already tried different ways but none works.

Sorry for the long question. I hope I was able to provide enough information so that maybe someone can help me out on this. Thank you :)


Other questions here on SO that I've read regarding my issue:

Community
  • 1
  • 1
Fidel90
  • 1,828
  • 6
  • 27
  • 63

1 Answers1

1

$parse always returns a function, and angular.isFunction(callback) check is unnecessary.

keyCode is not translated to which when triggering keyup manually.

$element.trigger($.Event("keyup", {which:13}))

may help.

The callback is triggered because focus can't be triggered manually here, and it is actually undefined !== 0 in ($(this).val() !== initial condition.

There are a couple of reason for focus to not work. It isn't instant, and the spec should become asynchronous. And it won't work on detached element.

focus behaviour can be fixed by using $element.triggerHandler('focus') instead of $element.focus().

DOM testing belongs to functional tests, not to unit tests, and jQuery may introduce a lot of surprises when being treated like that (the spec demonstrates the tip of the iceberg). Even when the specs are green, in vivo behaviour may differ from in vitro, this renders unit tests almost useless.

A proper strategy for unit-testing a directive that affects DOM is to expose all event handlers to scope - or to controller, in the case of no-scope directive:

require: ['onInputChange', 'ngModel'],
controller: function () {
  this.onFocus = () => ...;
  ...
},
link: (scope, element, attrs, [instance, ngModelController]) => { ... }

Then controller instance can be obtained in specs with

var instance = $element.controller('onInputChange');

All controller methods can be tested separately from the relevant events. And events handling can be tested by watching for on method calls. In order to do this angular.element.prototype or jQuery.prototype has to be spied, like that:

spyOn(angular.element.prototype, 'on').and.callThrough();
spyOn(angular.element.prototype, 'off').and.callThrough();
spyOn(angular.element.prototype, 'val').and.callThrough();
...
$element = $compile(...)($scope);
expect($element.on).toHaveBeenCalledWith('focus.onInputChange', instance.onFocus);
...
instance.onFocus();
expect($element.val).toHaveBeenCalled();

The purpose of unit test is to test a unit in isolation from other moving parts (including jQuery DOM actions, for this purpose ngModel can be mocked too), that's how it is done.

Unit tests don't make functional tests obsolete, especially in the case of complex multidirective interactions but may offer solid testing with 100% coverage.

Community
  • 1
  • 1
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • Thank you very much for your in-depth explanation. The first half of your answer helped me to fix the tests so everything passes. Anyway I'm now on the way to fix my method of testing as you described in the second half. I'm new to testing angular modules and your answer is a great help! +1 – Fidel90 Jul 13 '16 at 05:37
  • Can you please take a look at this updated plunker: https://plnkr.co/edit/bxCuhvp5NazZxsA1tD1t?p=preview I'm not sure how to apply and trigger the spies correctly. – Fidel90 Jul 13 '16 at 06:53
  • 1
    I've updated your plunker with a couple of specs to show how it is done https://plnkr.co/edit/dMl0UdxBypjGvsBkb281?p=info . I've changed a few things to be test-friendly. Private `_` variables are supposed to be reachable for tests. And `on*` listeners are provided directly to `on` listeners, so we can be sure that listener is the function that we're expecting (otherwise we have to catch anonymous function with `var listener $element.on.calls.argsFor(...)[1]` and test if it does what it is supposed to do, which is a PITA). – Estus Flask Jul 13 '16 at 13:11
  • 1
    For this reason `bind` is unwelcome, `on*` should have access to non-lexical `this`. Notice that we just test our own code line by line, not its behaviour. We safely assume that `ngModel` works as we expect (it was tested by Angular). And we safely assume that the events will trigger event listeners (it was tested by Angular/jQuery). The behaviour may be tested additionally in slower functional/e2e tests with Protractor in *real* DOM and may also be affected by other moving parts (package versions, etc), `focus` alone is quite costly and may require a huge delay (500 to 1000 ms). – Estus Flask Jul 13 '16 at 13:20
  • Thanks for your effort. I'll take a look on it :) – Fidel90 Jul 14 '16 at 05:18