📜 ⬆️ ⬇️

Real unit testing in AngularJS

AngularJS is young and hot when it comes to modern web development. Its unique approach to compiling HTML and two-way data binding makes it an effective tool for creating client web applications. When I learned that Quick Left (the studio in which the author works. Note. Ln.) Will use it to create an application for one of our clients, I was excited and tried to learn about angular as much as I could. I went around the whole internet, every lesson and guide I could find on Google. They were really helpful in understanding how directives, templates, compilation, and event looping work (digest), but when it came to testing, I discovered that this topic was simply missed.

I was trained in the TDD (Development through Testing) approach and I feel at ease without the Red-Green-Refactoring approach. Since we still figured out what was happening in Angular testing, the team sometimes had to rely on a “test-after” approach. This started to make me nervous, so I decided to focus on testing. I spent weeks on it, and soon the test coverage rose from 40% to 86% (By the way, if you haven’t done this yet, you can try Istabul to check the code coverage in your JS application).


Introduction


Today I want to share some of the things I learned. As good as Angular's documentation , testing a combat application is rarely as easy as the examples you'll see below. There are many pitfalls that I had to go through to get some things to work. I found several workarounds that came to me again and again. In this article we will look at some of them.
')


This article is intended for medium and advanced developers using AngularJS to write combat applications that will help reduce the pain of testing. I hope a sense of security in the testing workflow will allow the reader to start practicing the TDD approach and develop more sustainable applications.

Testing Tools


There are many frameworks and tools for testing the available Angular developer, and perhaps you already have your own preferences. Here is a list of tools that we have chosen and will use in the course of the article.



Configuring Helpers for the Test


Let's start by writing a helper that will connect the dependencies we need. Here we will use Angular Mocks, Chai, Chai-as-promised and Sinon

 // test/test-helper.js //    require('widgetProject'); //  require('angular-mocks'); var chai = require('chai'); chai.use('sinon-chai'); chai.use('chai-as-promised'); var sinon = require('sinon'); beforeEach(function() { //       this.sinon = sinon.sandbox.create(); }); afterEach(function() { //  ,     this.sinon.restore(); }); module.exports = { rootUrl: 'http://localhost:9000', expect: chai.expect } 

Getting Started: Testing Top-Down


I am a big believer in top-down testing style. Everything starts with the functionality that I want to create, I write a pseudo script describing the functionality and create a feature test. I run this test and it crashes with an error. Now I can start designing all the parts of the system that I need for the feature test to work using unit tests that guide me along the way.

For example, I will create an imaginary “Widgets” application that can display a list of widgets, create new ones, and edit current ones. The code that you see here is not enough to build a full-fledged application, but enough to understand the examples of tests. We will start by writing an e2e test describing the behavior of creating a new widget.

Reuse of Pages in e2e testing


When working on a one-page application, it makes sense to follow the principle of DRY by writing reusable "pages" that can be connected to many e2e tests.

There are many ways to structure tests in the Angular project. Today, we will use the following structure:

 widgets-project |-test | | | |-e2e | | |-pages | | | |-unit 

Inside the pages folder, we will create a WidgetsPage function that can be enabled in the e2e tests. Five tests refer to it:


In the end you get something like this:

 // test/e2e/pages/widgets-page.js var helpers = require('../../test-helper'); function WidgetsPage() { this.get = function() { browser.get(helpers.rootUrl + '/widgets'); } this.widgetRepeater = by.repeater('widget in widgets'); this.firstWidget = element(this.widgetRepeater.row(0)); this.widgetCreateForm = element(by.css('.widget-create-form')); this.widgetCreateNameField = this.widgetCreateForm.element(by.model('widget.name'); this.widgetCreateSubmit = this.widgetCreateForm.element(by.buttonText('Create'); } module.exports = WidgetsPage 

From within my e2e tests, I can now connect this page and interact with its elements. Here's how to use it:
 // e2e/widgets_test.js var helpers = require('../test-helper'); var expect = helpers.expect; var WidgetsPage = require('./pages/widgets-page'); describe('creating widgets', function() { beforeEach(function() { this.page = new WidgetsPage(); this.page.get(); }); it('should create a new widget', function() { expect(this.page.firstWidget).to.be.undefined; expect(this.page.widgetCreateForm.isDisplayed()).to.eventually.be.true; this.page.widgetCreateNameField.sendKeys('New Widget'); this.page.widgetCreateSubmit.click(); expect(this.page.firstWidget.getText()).to.eventually.equal('Name: New Widget'); }); }); 

Let's see what happens here. First, we connect the helper test, then we take the expect and WidgetsPage from it. In beforeEach we load into the browser page. Then, in the example, we use elements that are defined in the WidgetsPage to interact with the page. We check that there are no widgets, fill out the form to create one of them with the value “New Widget” and check that it is displayed on the page.

Now, by dividing the logic for a form into a reusable “page”, we can reuse it to test form validation, for example, or later in other directives.

Work with Promise Return Functions


The Assert methods we took from Protractor in the test above return Promise, so we use Chai-as-promised to check that the isDisplayed and getText return what we expect.

We can also work with promise objects inside unit tests. Let's look at an example in which we are testing a modal window that can be used to edit an existing widget. It uses the $modal service from UI Bootstrap. When a user opens a modal window, the service returns promise. When it cancels or saves the window, the promise is allowed or rejected.
Let us test that the save and cancel methods are properly connected by running Chai-as-promised.

 // widget-editor-service.js var angular = require('angular'); var _ = require('lodash'); angular.module('widgetProject.widgetEditor').service('widgetEditor', ['$modal', '$q', '$templateCache', function ( $modal, $q, $templateCache ) { return function(widgetObject) { var deferred = $q.defer(); var templateId = _.uniqueId('widgetEditorTemplate'); $templateCache.put(templateId, require('./widget-editor-template.html')); var dialog = $modal({ template: templateId }); dialog.$scope.widget = widgetObject; dialog.$scope.save = function() { //   - deferred.resolve(); dialog.destroy(); }); dialog.$scope.cancel = function() { deferred.reject(); dialog.destroy(); }); return deferred.promise; }; }]); 

The service will load the template for editing the widget into the template cache, the widget itself, and create a deferred object that will be allowed or rejected depending on whether the user rejects or saves the editing form, which returns the promise.

Here's how to test something like this:

 // test/unit/widget-editor-directive_test.js var angular = require('angular'); var helpers = require('../test_helper'); var expect = helpers.expect; describe('widget storage service', function() { beforeEach(function() { var self = this; self.modal = function() { return { $scope: {}, destroy: self.sinon.stub() } } angular.mock.module('widgetProject.widgetEditor', { $modal: self.modal }); }); it('should persist changes when the user saves', function(done) { var self = this; angular.mock.inject(['widgetModal', '$rootScope', function(widgetModal, $rootScope) { var widget = { name: 'Widget' }; var promise = widgetModal(widget); self.modal.$scope.save(); //       expect(self.modal.destroy).to.have.been.called; expect(promise).to.be.fulfilled.and.notify(done); st $rootScope.$digest(); }]); }); it('should not save when the user cancels', function(done) { var self = this; angular.mock.inject(['widgetModal', '$rootScope', function(widgetModal, $rootScope) { var widget = { name: 'Widget' }; var promise = widgetModal(widget); self.modal.$scope.cancel(); expect(self.modal.destroy).to.have.been.called; expect(promise).to.be.rejected.and.notify(done); $rootScope.$digest(); }]); }); }); 

To cope with the complexity of the promise, which returns a modal window in the widget editing test, we can do several things. Create a mock from the $modal service in the beforeEach function, replacing the function output with an empty $scope object, and blocking the destroy call. In angular.mock.module , we pass a copy of the modal window so that Angular Mocks can use it instead of the real $modal service. This approach is quite useful for dependency stubs, as we will see soon.

We have two examples, and each must wait for the promise result returned by the editing widget before it is completed. In this regard, we must pass done as a parameter to the example ourselves, and done when the test is completed.

In the tests, we again use Angular Mocks for injection into the widget's modal window and the $rootScope service from AngularJS. Having $rootScope we can call the $digest loop. In each of the tests, we load the modal window, cancel or enable it, and use Chai-as-expected to check, return the promise as rejected or as resolved . To actually call promise and destroy , we need to start $digest , so it is called at the end of each assert block.

We looked at how to work with promises in both cases, in e2e and unit tests, using the following assert calls:



Mock dependencies Directives and Controllers


In the last example, we had a service that relied on the $ modal service, which we locked in order to make sure that the destroy was actually called. The technique we used is quite useful and allows unit tests to work more correctly in Angular.

Admission is as follows:


Sometimes directives or controllers depend on many internal and external dependencies, and you need to lock them all.
Let's take a look at a more complex example in which the directive monitors the widgetStorage service and updates the widgets in its environment as the collection changes. There is also an edit method that opens widgetEditor that we created earlier.
 // widget-viewer-directive.js var angular = require('angular'); angular.module('widgetProject.widgetViewer').directive('widgetViewer', ['widgetStorage', 'widgetEditor', function( widgetStorage, widgetEditor ) { return { restrict: 'E', template: require('./widget-viewer-template.html'), link: function($scope, $element, $attributes) { $scope.$watch(function() { return widgetStorage.notify; }, function(widgets) { $scope.widgets = widgets; }); $scope.edit = function(widget) { widgetEditor(widget); }); } }; }]); 

Here's how we could test something like this by fixing the widgetStorage and widgetEditor :

 // test/unit/widget-viewer-directive_test.js var angular = require('angular'); var helpers = require('../test_helper'); var expect = helpers.expect; describe('widget viewer directive', function() { beforeEach(function() { var self = this; self.widgetStorage = { notify: self.sinon.stub() }; self.widgetEditor = self.sinon.stub(); angular.mock.module('widgetProject.widgetViewer', { widgetStorage: self.widgetStorage, widgetEditor: self.widgetEditor }); }); //   ... }); 

Access to the Child and Insulated Scope


Sometimes you need to write a directive that has an isolated or child scope inside. For example, when using the $dropdown service from Angular Strap , an isolated scope is created. Getting access to such a scope can be quite painful. But knowing self.element.isolateScope() can fix this. Here is one example of using $dropdown , which creates an isolated scope:

 // nested-widget-directive.js var angular = require('angular'); angular.module('widgetSidebar.nestedWidget').directive('nestedSidebar', ['$dropdown', 'widgetStorage', 'widgetEditor', function( $dropdown, widgetStorage, widgetEditor ) { return { restrict: 'E', template: require('./widget-sidebar-template.html'), scope: { widget: '=' }, link: function($scope, $element, $attributes) { $scope.actions = [{ text: 'Edit', click: 'edit()' }, { text: 'Delete', click: 'delete()' }] $scope.edit = function() { widgetEditor($scope.widget); }); $scope.delete = function() { widgetStorage.destroy($scope.widget); }); } }; }]); 

Assuming that a directive inherits a widget from a parent directive that has a collection of widgets, it can be quite difficult to access the child scope to check whether its properties have changed as expected. But it can be done. Let's look at how:

 // test/unit/nested-widget-directive_test.js var angular = require('angular'); var helpers = require('../test_helper'); var expect = helpers.expect; describe('nested widget directive', function() { beforeEach(function() { var self = this; self.widgetStorage = { destroy: self.sinon.stub() }; self.widgetEditor = self.sinon.stub(); angular.mock.module('widgetProject.widgetViewer', { widgetStorage: self.widgetStorage, widgetEditor: self.widgetEditor }); angular.mock.inject(['$rootScope', '$compile', '$controller', function($rootScope, $compile, $controller) { self.parentScope = $rootScope.new(); self.childScope = $rootScope.new(); self.compile = function() { self.childScope.widget = { id: 1, name: 'widget1' }; self.parentElement = $compile('<widget-organizer></widget-organizer>')(self.parentScope); self.parentScope.$digest(); self.childElement = angular.element('<nested-widget widget="widget"></nested-widget>'); self.parentElement.append(self.childElement); self.element = $compile(self.childElement)(self.childScope); self.childScope.$digest(); }]); }); self.compile(); self.isolateScope = self.element.isolateScope(); }); it('edits the widget', function() { var self = this; self.isolateScope.edit(); self.rootScope.$digest(); expect(self.widgetEditor).to.have.been.calledWith(self.childScope.widget); }); 


Madness, isn't it? First we mock widgetStorage and widgetEditor , then we start writing the compile function. This function will create two instances of the scope, parentScope and childScope , overwrite the widget and put it in the child scope. Next, compile will set up the scope and complex template: first, compiles the parent widget-organizer , to which the parent scope will be passed. When this is all completed, we will add a nested-widget child element to it, passing the child scope and at the end run $digest .

At the end, we get to magic: we can call the compile function, then climb into the compiled isolated scope of the template (which is the scope from $dropdown ) via self.element.isolateScope() . At the end of the test, we can crawl into the isolated scope for an edit call, and finally verify that the stuck widgetEditor been called with the stuck widget.

Conclusion


Testing can be painful. I remember several cases when our project had so much pain in figuring out how to do it all, that there was a temptation to go back to writing code and “click testing”, to test its performance. Unfortunately, when you get out of this process, the feeling of insecurity only increases.

After we set aside time to understand how to deal with difficult cases, it became much easier to understand when such cases occur again. Armed with the techniques described in this article, we were able to join the TDD process and confidently moved forward.

I hope that the techniques that we have looked at today will be useful in your daily practice. AngularJS is still a young and growing framework. And what techniques do you use?

Source: https://habr.com/ru/post/233705/


All Articles