Why does my jasmine tests fail on this directive?

2019-07-29 06:44发布

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:

1条回答
在下西门庆
2楼-- · 2019-07-29 07:13

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

查看更多
登录 后发表回答