Saturday, January 30, 2016

To Unit Test AngularJS with Jasmine in Visual Studio

Сегодняшнею статью я решил посвятить покрытию unit-тестами вашего AngularJS приложения с Jasmine, а также тому, как отлаживать этот проект с Visual Studio. Так как на это все я потратил достаточно долго времени, решил поделиться с вами тем, что у меня получилось. Отправной точкой начала работы для меня стала статья To Unit Test AngularJS with Jasmine in Visual Studio, благодаря которой я смог настроить дебаг тестов с Visual Studio. Также я подружил Resharper 9 с этими тестами. Так что весь этот процесс мы с вами сейчас и рассмотрим. 
1.Первым делом устанавливаем с помощью Visual Studio Extension компонент Chutzpah – это open source Unit test runner. Для этого переходим в меню Tools->Extensions and Updates
Затем открываем Online->Visual Studio Gallery и ищем в поиске по слову Chutzpah.
Для студии я себе ставил только екстеншн, который позволяет запускать с контекстного меню. Вы же можете поставить себе оба расширения. Мне для отладки приложения полностью хватило только одного расширения по той причине, что я работаю с тестами через Resharper. Для того чтобы это работало в Test Explorer, ставьте себе первый в списке Chutzpah test-адаптер.
2. Нам нужно создать пустое ASP.NET Web Application (Empty), которое назовем AngulaJsUnitTestSample.
На следующем этапе выбираем просто Empty шаблон и нажимаем на кнопку OK.
3. С помощью NuGet Package Manager ставим себе AngularJS.Core.
Либо с помощью Package Manager Console (ALT+T, N, O):
install-package AngularJS.Core
4. Поставим через NuGet Package Manager еще и Jasmine test-фреймворк.
Либо то же самое через PMC (Package Manager Console)
install-package JasmineTest
Затем удаляем с проекта выбранные файлы, как показано на рисунке ниже.
5. Создаем фейковый тест, чтобы проверить работоспособность. Для этого создадим в корне папку Tests, в которую добавим файл JavaScript файл, который назовем jasmineWorks.js, и добавим в него код, показанный ниже.
describe('jasmine works', function () {
    it('sanity check', function () {
        expect(0).toBe(0);
    });
});
После этого, если вы ставили себе адаптер, вы можете запустить ваш тест с Test Explorer (если он не открыт, то выбираем Test -> Windows -> Test Explorer).
И проверяем, что тест успешно отработал.
Можно это тест запустить с кода, используя расширения для контекстного меню. Тогда у вас будет показан результат в виде симпатичной веб-страницы. Для этого поставим курсор в наш тест, нажмем правую клавишу мыши, и в контекстном меню выберем пункт Run JS Tests, если просто хотим собрать тесты и проверить, что они проходят.
Либо выбрать Open in browser, чтобы посмотреть на результат.
Опции Run Chutzpah With позволяют запускать тесты в режиме отладки и посмотреть покрытие кода тестами.
Ниже показано запуск в режиме отладки.
И также покрытие тестами.
6. Добавить просто AngularJS контроллер.
В папку Scripts добавим новый JavaScript файл, который назовем appController.js, реализация которого приведена ниже.
angular.module('app', []).controller('appController', function ($scope) {
    $scope.value = 5;
});
7. Добавить unit test для appController.
Перейдем в папку Tests и добавим туда новый JavaScript файл, который назовем appControllerSpec.js. Ниже приведена его реализация.
/// <reference path="../Scripts/angular.js" />
/// <reference path="../Scripts/angular-mocks.js" />
/// <reference path="../Scripts/appController.js" />

describe('When using appController ', function () {
    //initialize Angular
    beforeEach(module('app'));
    //parse out the scope for use in our unit tests.
    var scope;
    beforeEach(inject(function ($controller, $rootScope) {
        scope = $rootScope.$new();
        var ctrl = $controller('appController', { $scope: scope });
    }));

    it('initial value is 5', function () {
        expect(scope.value).toBe(5);
    });
});
8. Запускаем наш тест.

Это все хорошо для простых проектов, у которых нет никаких зависимостей и т.д. Но что делать, если у нас проект посложнее, в котором есть работа с сетью, есть модули, в которых прописана маршрутизация, и контроллеры принимают множество параметров. Поэтому мы с вас попробуем на уже существующем тестовом проекте для работы со студентами покрыть тестами хотя бы один контроллер. Но для начала я покажу, как настроить Resharper, для того чтобы не запускать тесты через неудобный Test Explorer, который все ваши тесты показывает одним списком, смешивая в кучу все Unit/Integration тесты. JavaScript тесты через решарпер запускаем через WebKit, который называется PhantomJS. Загружаем себе файл phantomjs.exe (настройки актуальны для Windows) по ссылке выше.
Затем открываем Resharper ->Options и ищем в самом низу Tools. Затем в Tools выбираем пункт Unit Testing -> JavaScript Tests.
Теперь по самим настройкам. Первое: ставим галочку Enable Jasmine support и выбираем с выпадающего пункта Jasmine version версию 2.0. После этого нужно также выбрать пункт PhantomJS с указанием пути к папке, в которую вы скачали ваш PhantomJS.exe. На рисунке ниже показано, как все нужно сделать.
Сохраняем и проверяем, что все работает. Для этого выбираем наш Solution и запускам наши тесты с меню решарпера.
Или быстрой комбинацией Ctrl+T, R. Ниже показан результат запуска через Resharper этих же тестов.
Теперь давайте усложним пример, как я писал выше. У нас для примера есть контроллер, который называется studentsCtr. Ниже приведена его реализация.
(function () {
    'use strict';

    angular
        .module('app')
        .controller('studentsCtr', studentsCtr);

    studentsCtr.$inject = ['$scope', '$http', '$location'];

    function studentsCtr($scope, $http, $location) {
        console.log("studentsCtr");

        var url = 'api/Students';
        $http.get(url)
            .success(function (data) {
                $scope.students = data;
            })
            .error(function (error) {
                console.log(error.message);
            });

        $scope.addNewStudent = function () {
            $location.path('/create');
        };

        $scope.editRow = function (entity) {
            $location.path('/edit/' + entity.Id);
        };

        $scope.removeRow = function (index, entity) {
            $http.delete('api/Students/' + entity.Id)
            .success(function () {
                $scope.students.splice(index, 1);
            })
            .error(function (error) {
                console.log(error.Message);
            });
        };
    }
})();
Нам в целом не важно, как это все реализовано на web-странице, нам это просто не интересно. Нас может, разве что, заинтересовать, как построен сам модуль app.js.
(function () {
    'use strict';

    angular.module('app', [
        // Angular modules
        'ngRoute',
        'ui.bootstrap',
        'ui.bootstrap.tpls'
        // Custom modules

        // 3rd Party Modules
       
    ])
    .config(function ($routeProvider, $locationProvider) {
        console.log("app");

        $routeProvider
                .when('/', {
                    templateUrl: 'Views/appview/studentsview.html',
                    controller: 'studentsCtr'
                })
                .when('/create', {
                    templateUrl: 'Views/appview/createstudent.html',
                    controller: 'createStudentCtr'
                })
                .when('/edit/:studentId', {
                    templateUrl: 'Views/appview/createstudent.html',
                    controller: 'editStudentCtr'
                })
                .otherwise({
                    redirectTo: '/'
                });

        $locationProvider.html5Mode(true);
    });
})();
Все этим примеры взяты со статьи. Исходники на скачивание этих примеров будут доступны в конце статьи.
Вернемся теперь к нашему контроллеру studentCtr. Как вы можете заметить, первое действие, которое выполняется здесь, – это с помощью $http контекста мы загружаем данные по студентам с какого-то Web API контроллера. На всякий случай, ниже приведена реализация этого метода.
private StudentContext db = new StudentContext();

// GET: api/Students
public IQueryable<Student> GetStudents()
{
    return db.Students;
}
В данном случае нам даже не важно, как реализован класс Student. Нам важно, чтобы после того, как отработал метод,
var url = 'api/Students';
$http.get(url)
    .success(function (data) {
        $scope.students = data;
    })
    .error(function (error) {
        console.log(error.message);
    });
в $scope.students появились нужные нам данные. Для этого нужно замокать $http наш контекст. Делается это очень просто: в angular-mocks.js есть фейковая реализация $httpBackend, которая заменяет реальную реализацию $httpBackend. Важное замечание: $httpBackend – это не то же самое, что и $http сервис. $http сервис использует $httpBackend для отправки HTTP-сообщений.
Так как теорию мы рассмотрели и, по сути, поняли, что нам нужно мокать, настало самое время приступить к реализации. Для этого нам нужно повторить все действия с данной статьи с пункта 1 по 8-й. Затем, когда у нас будет установлен JasmineTest, можно приступать к самой реализации. Для этого в папку Tests (если этой папки еще нет, то нужно ее создать) добавляю новый JavaScript файл с названием studentsCtrSpec.js. Ниже приведена полная реализация этого файла.
/// <reference path="../Scripts/angular.js" />
/// <reference path="../Scripts/angular-route.js" />
/// <reference path="../Scripts/angular-ui/ui-bootstrap.js" />
/// <reference path="../Scripts/angular-ui/ui-bootstrap-tpls.js" />
/// <reference path="../Scripts/angular-mocks.js" />
/// <reference path="../Scripts/app/app.js" />
/// <reference path="../Scripts/app/controllers/studentsCtr.js" />

describe('When using studentsCtr ', function () {
    beforeEach(module('app'));

    describe("studentsCtr", function () {
        var scope, httpBackend;
        beforeEach(inject(function ($rootScope, $controller, $httpBackend, $http, $location) {
            scope = $rootScope.$new();
            httpBackend = $httpBackend;
            httpBackend.when("GET", "api/Students").respond([{}, {}, {}]);
            $controller('studentsCtr', {
                $scope: scope,
                $http: $http,
                $location: $location
            });
        }));

        it('should GET studentsCtr', function () {
            httpBackend.flush();
            expect(scope.students.length).toBe(3);
        });
    });
});
С самого начала я добавил ссылки на все зависимости, которые есть в проекте. Без них ваш код не заработает. Сейчас я постараюсь детально объяснить почему. Первой строчкой у нас идет вызов такого кода:
beforeEach(module('app'));
Мы пытаемся подгрузить наш app-модуль. Но здесь есть основная проблема. В этом модуле прописано куча зависимостей, без которых это все не будет работать.
angular.module('app', [
    // Angular modules
    'ngRoute',
    'ui.bootstrap',
    'ui.bootstrap.tpls'
    // Custom modules

    // 3rd Party Modules
       
])
Как видите, у нас зависимости от следующих частей:
  • ngRoute – файл angular-route.js
  • ui.bootstrap – ui-bootstrap.js
  • ui.bootstrap.tpls – ui-bootstrap-tpls.js
Теперь вы поняли, почему мы добавили загрузку этих всех зависимостей. Для более сложных проектов эти зависимости можно добавлять в отдельный файл _references.js и использовать уже только его.
Как видите, зависимостей, которые нужно подгружать, достаточно много. И чем их больше, тем сложнее писать нам свои тесты. Ниже приведен код, который может быть и который нужно покрыть тестами.
angular.module("app", ['ngRoute', 'ngTouch', 'ui.grid', 'ui.grid.edit', 'ui.grid.rowEdit',
        'ui.grid.cellNav', 'ui.grid.pagination', 'ui.grid.resizeColumns', 'ui.bootstrap', "ui.bootstrap.tpls", "ui.grid.grouping",'ui.grid.expandable', 'ui.grid.selection', 'ui.grid.pinning', "ngAnimate",
        "crumble", 'angular-loading-bar', 'ng.deviceDetector','ui-notification',"xeditable"])
Как видите, зависимостей очень много. Так что, если у вас такое на проекте, выносите сразу все в отдельный файл.
Следующий код также не очень сложный. В нем мы создаем новый контекст, и если у нас вызывается наш запрос, то возвращаем три пустых объекта, что будет равно трем студентам для нашего списка.
beforeEach(inject(function ($rootScope, $controller, $httpBackend, $http, $location) {
    scope = $rootScope.$new();
    httpBackend = $httpBackend;
    httpBackend.when("GET", "api/Students").respond([{}, {}, {}]);
    $controller('studentsCtr', {
        $scope: scope,
        $http: $http,
        $location: $location
    });
}));
И в самом конце – проверка, что все вызвалось успешно.
it('should GET studentsCtr', function () {
    httpBackend.flush();
    expect(scope.students.length).toBe(3);
});
Метод flush() также проводит проверку, что никаких лишних запросов, кроме наших, не было вызвано. Проверка на количество полученных объектов также в комментировании не нуждается. Ниже показано, как это выглядит в дебаг-режиме.
Если вы запустите в дебаге через Chutzpah, то получите следующий результат:
Я же предпочитаю запуск через Resharper.
Итоги
Думаю, на этой позитивной ноте мы можем заканчивать. Надеюсь, что если вы только начинаете с разбираться с этой темой, сегодняшний материал поможет вам сэкономить время и позволит вам более легко погрузиться в мир тестирования вашего AngularJS и JavaScript кода в ваших проектах.

Исходники к статье: Unit Test AngularJS with Jasmine in Visual Studio