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:
$parse
always returns a function, andangular.isFunction(callback)
check is unnecessary.keyCode
is not translated towhich
when triggeringkeyup
manually.may help.
The callback is triggered because
focus
can't be triggered manually here, and it is actuallyundefined !== 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:
Then controller instance can be obtained in specs with
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 thisangular.element.prototype
orjQuery.prototype
has to be spied, like that: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.