Статьи

Интеграционные тесты AngularJS с Mocks и Magic

Как веб-разработчик, я не большой поклонник комплексных тестов. Мое мнение меняется со зрелыми фреймворками, такими как транспортир, но я все еще думаю, что поиск «кнопки» с «id» — это хрупкий тест, который, возможно, придется часто менять. Меня гораздо больше интересует, что происходит при нажатии кнопки, чем сама кнопка, потому что то, что начинается с кнопки, может закончиться гиперссылкой, блоком текста или чем-то совершенно другим.

angularintegration

Привязка данных обеспечивает мощную абстракцию между логикой представления и пользовательским интерфейсом. «Применение фильтра» по-прежнему является логикой представления, но привязка данных позволяет мне представить это как метод объекта, а затем позволить разработчику беспокоиться о деталях того, с чем он связан. Это был эффективный способ кодировать и тестировать в дни XAML, когда мы использовали «модели просмотра», и я считаю, что он так же эффективен в Интернете.

По этой причине вместо настоящего «сквозного» теста я предпочитаю усовершенствовать свои модульные тесты AngularJS интеграционными тестами. Юнит тест не имеет зависимостей. Я должен иметь возможность запустить его и смоделировать зависимости, чтобы он выполнялся независимо от сетевого подключения, наличия веб-службы или состояния базы данных. С другой стороны, интеграционные тесты требуют небольшой настройки и ожидают, что что-то там будет, будь то активная служба или даже горячая база данных на сервере. Я проверяю до привязки данных, но оставляю UI нетронутым.

Сложность Angular заключается в том, что библиотека ngMock позволяет очень легко полностью абстрагировать уровень HTTP. Это также облегчает общее тестирование, поэтому я люблю использовать его даже в своих интеграционных тестах. Проблема в том, что, насколько я знаю, я не могу отказаться от $ httpBackend . (Если я ошибаюсь и есть другой способ, кроме использования сквозной фиктивной библиотеки, дайте мне знать!) Не поймите меня неправильно, это здорово для модульных тестов. Чтобы проиллюстрировать мою точку зрения …

Модульный тест

Рассмотрим почти наименьшее в мире приложение Angular, которое не делает ничего, кроме предоставления API, который вызывает конечную точку службы и возвращает значение в зависимости от того, успешно ли оно подключено. Это приложение:

(function (app) {

    app.factory("oDataSvc", ['$q', '$http', function ($q, $http) {
        return {
            checkEndPoint: function () {
                var deferred = $q.defer();
                $http.get("http://services.odata.org/V4/TripPinServiceRW")
                    .then(function () {
                        deferred.resolve(true);
                    }, function () {
                        deferred.resolve(false);
                    });
                return deferred.promise;
            }
        };
    }]);

})(angular.module('test', []));

Now I can write a test. First, I’m going to wire up the latest version of Jasmine and make sure Jasmine is working.

(function() {

    var url = "http://services.odata.org/V4/TripPinServiceRW";

    describe("jasmine", function() {
        it("works", function() {
            expect(true).toBe(true);
        });
    });
})();

Теперь я могу настроить свой юнит-тест. Сначала я хочу получить службу и $ httpBackend и убедиться, что обработал все запросы после каждого теста.

describe("angular unit test", function() {

    var oDataService, httpBackend;

    beforeEach(function() {
        module("test");
    });

    beforeEach(inject(function($httpBackend, oDataSvc) {
        httpBackend = $httpBackend;
        oDataService = oDataSvc;
    }));

    afterEach(function() {
        httpBackend.verifyNoOutstandingExpectation();
        httpBackend.verifyNoOutstandingRequest();
    });
});

Затем я могу убедиться, что смог получить сервис:

it("is registered with the module.", function () {
    expect(oDataService).not.toBeNull();
});

Now comes the fun part. I can set up my test but it will hang on the promise until I set up expectations for the backend and then flush it. I can do this all synchronously and test how my service deals with hypothetical response codes. Here’s the example that I use to set up “success”:

describe("checkEndPoint", function() {

    it("should return true upon successful connection",
        function () {
        oDataService.checkEndPoint()
            .then(function(result) {
                expect(result).toEqual(true);
            }, function() {
                expect(false).toBe(true);
            });
        httpBackend.expectGET(url)
            .respond(200, null);
        httpBackend.flush();
    });
});

The expectation is set in the “expectGET” method call. The service will block on returning until I call flush, which fires the result based on the expectation I set, which was to return a 200-OK status with no content. You can see the failure example in the jsFiddle source.

The Integration Test

The rub comes with an integration test. I’d love to use the mock library because it sets up my module, the injector, and other components beautifully, but I’m stuck with an $http service that relies on the backend. I want the “real” $http service. What can I do?

The answer is that I can use dependency injection to my advantage and play some tricks. In the context of my app, the injector will provide the mocked service. However, I know the core module has the live service. So how can I grab it from the main module and replace it in my mocked module without changing the mocks source?

Before I grab the service, it is important to understand dependencies. $http relies on the $q service (I promise!). The $q service, in turn, relies on the digest loop. In the strange world of a mocked test object, if I manage to call the real $http service it is not going to respond until a digest loop is called.

“Easy,” you might say. “Get the $rootScope and then call $apply.”

“Not so fast,” is my reply. The $rootScope you probably plan to grab won’t be the same $rootScope used by the $http service we get from the injector. Remember, that is a different “container” that we are accessing because it hasn’t been overwritten in our current container that has the mocks library!

This is easier to explain with code:

beforeEach(function () {
    var i = angular.injector(["ng"]),
        rs = i.get("$rootScope");
    http = i.get("$http");

    flush = function () {
        rs.$apply();
    }

    module("test", function ($provide) {
        $provide.value("$http", http);
        $provide.value("$rootScope", rs);
    });
});

Angular’s modules overwrite their contents with a “last in wins” priority. If you include two module dependencies with the same service, the last one wins and you lose the original. To get the live $http, I need to create a new container. That container is completely isolated from the one I’m using to test. Therefore I need to grab that container’s $rootScope as well. The flush method gives me a reusable function I can easily use throughout my code. When I mock the module for the integration test, I intercept the provider by using the $provide service to replace the ones already there.

The replacement of $rootScope is important. It’s not good enough to capture the flush method because that will just run a digest in the original container. By making that $rootScope part of my current container, I ensure it is the one used all of the way down the dependency chain (and for digests in my tests). If I reference $q in my tests I’ll overwrite it from the original container too.

Now my test doesn’t configure expectations but is a true integration test. I am expecting the sample service to be up, and for the service call to return “true.” Notice that I need to call this asynchronously, so I take advantage of the oh-so-easy asynchronous syntax in the latest Jasmine (“pass done, then call it when you are.”)

it("should return true to verify the service is up",
    function (done) {
    oDataService.checkEndPoint()
        .then(function (result) {
            expect(result).toEqual(true);
            done();
        }, function () {
            expect(false).toBe(true);
            done();
        });
    flush();
});

That’s it. Using this method, I can take advantage all mocks has to offer while still integrating with my live web API to ensure service calls are working. This is what I call a “beneath the surface” test. I’m testing through the data bound model, ensuring that the test flows through to the database, etc., but again I’m testing what a function does, not how it is wired to the UI.

To see the unit test and integration test in action, check out the jsFiddle. Check out your network and notice the service is really being called for the integration test.

сеть

If you see several lines, it’s because it redirects to generate a “session” for the service and then makes the final call (307 ► 302 ► 200).

If you are a fan of integration tests and struggled with this in your AngularJS tests I hope this was helpful. If you have a different approach then please share it in the comments below!