Тесты JavaScript и их автоматизация

This post is also available in: Английский

Создание эффективных тест-кейсов может  быть крайне важным для крупных проектов, в случае, если поведение частей приложения может меняться по различным причинам. Пожалуй, наиболее частой является проблема, когда большая группа разработчиков работают над одним и тем же, либо смежными модулями. Это может приводить к незапланированному изменению поведения функций, написанных другими программистами. Либо  работа в сжатые сроки приводит к нечаянному изменению критичных частей приложения.

Тестирование веб-приложения как правило заключается в визуальной оценке элементов страницы и эмпирической оценке работоспособности функционала. Другими словами в переходе по разделам и совершении действий над динамическими элементами.

Со временем проект наполняется новыми функциональными возможностями, что удлиняет и усложняет процесс проверки его работы. Для автоматизации используются модульное (unit) тестирование.

Существуют 2 подхода к построению тестовых сценариев:

  • Whitebox Testing – написание тестов основывается на реализации функционала. Т.е. на мы проверяем по тем же алгоритмам, на которых строиться работы модулей нашей системы. Такой подход не гарантирует корректность работы системы в целом.
  • Blackbox Testing – создание сценариев базируется на спецификациях и требованиях к системе. Так можно проверить правильность результатов работы всего приложения, однако подобный подход не позволяет отловить мелкие и редкие ошибки.

Что тестировать

Может показаться, что стоит подвергать  проведению тестов  каждую функцию, которую вы реализовали. Это не совсем так. Написание тестов занимает время разработчика, поэтому для оптимизации процесса работы над созданием приложения стоит подготавливать тесты лишь сложных, критичных, либо тех функций, которые зависят от результатов работы других модулей системы.  Покрывайте тестами неоднозначную логику, в которой потенциально могут возникать ошибки. Также стоит создавать тесты для тех участков кода, которые в будущем планируется оптимизировать, чтобы уже после процесса оптимизации можно было убедиться в корректности их выполнения.

Вообще крайне важно оценивать затраты на проведения тестирования относительно сжатости сроков по разработке. Конечно, если вы не ограничены во времени, то можете позволить покрыть тестами каждую функцию.  Но как правило разработка ведется в жестком цейтноте, так что задача аналитика или опытного разработчика понять, где проведения тестирования необходимо. Кроме того, написание тестов увеличивает стоимость проекта.

Таким образом можно сформулировать 3 случая, когда использование модульного тестирования оправдано:

1)      Если тесты дают возможность быстрее выявить ошибки, чем при обычном их поиске.

2)      Снижают время на отладку

3)      Позволяют тестировать часто изменяемый код.

Из 3х основных компонент фронтэнда (HTML, CSS, JavaScript) тестировать нужно, пожалуй лишь JavaScript-код. CSS проверяется исключительно визуальным методом, когда разработчик/тестировщик/заказчик просматривает графический интерфейс в различных браузерах. HTML – разметка проверяется тем же методом.

Как тестировать

При построении сценариев проведения тестов стоит руководствоваться следующими принципами:

  • Ваши тесты должны быть максимально простыми. Тогда будет больше вероятность того, что на результаты его проведения будет влиять именно тот баг, который вы и пытаетесь повторить.
  • Декомпозируйте тесты больших модулей. Роще найти конкретное место ошибки.
  • Делайте тесты независимыми. Результат одного теста ни в коем случае не должен зависеть от результатов другого.
  • Результаты проведения тестов должны быть полностью повторяемыми и ожидаемыми. Каждый раз, когда вы снова запускаете тест, его результат должен быть тем же самым, что и в прошлый раз.
  • Для любой ошибки в выполнении приложения должен создан сценарий тестирования. Таким образом вы будете уверены, что баг действительно исправлен и не проявляется у пользователей.

Чем тестировать

Для unit-тестирования js-кода существуют несколько библиотек. Пожалуй самой распространённой является QUnit. Для проведения модульных тестов с помощью этой библиотеки нам потребуется создать «песочницу» — простую html-страницу, в которой будут подключена библиотека для тестирования, код, которой нужно подвергнуть тестам, и собственно сами тесты.

Функции для тестов :

(function() {
    window.stepen = function(int) {
        var result = 2;

        for (var i = 1; i< int; i ++) {
            result = result * 2;
        }

        return result;
    }

    window.returnFunc = function() {
        return 'ok';
    }
})();

Листинг тестов:

test('stepen()', function() {
    equal(stepen(2), 4, '2^2 - equal method');
    ok(stepen(3) === 8, '2^3 - ok method');
    deepEqual(stepen(5), 32, '2^5 - deepEqual method');
});

asyncTest('returnFunc()', function() {
    setTimeout(function() {
        equal(returnFunc(), 'ok', 'Async Func Test');
        start();
    }, 1000);
});

Как видно, QUnit поддерживает 3 функции для сравнения результатов выполнения кода с ожидаемым:

  • ok() – считает тест успешным, если возвращаемый результат = true
  • equal() – сравнивает результат с ожидаемым
  • deepEqual() – сравнивает результат с ожидаемым, проверяя его тип

Результат выполнения:

Как видно, библиотека QUnit проводит тестирование кода сразу для нескольких браузеров.

Существует ряд других библиотек для модульных тестов. Однако концепция построения сценариев тестирования в них та же самая, так что разобравшись с одной – у вас не составит труда перейти на другую.

Важно помнить

Особенностью современного js-кода является асинхронность его выполнения. Библиотеки для тестирования как правило имеют возможность проведения асинхронных тестов. Но к примеру если вы пытаетесь протестировать функцию, которая, скажем, посылает get-запрос на бэкэнд и возвращает ответ от него, то для проведения тестов придется останавливать поток функцией stop(), запускать тестируемую функцию, а затем заново запускать поток методом start(), «обернув его» в setTimeout().  Т.е. вы должны заложить какой-то промежуток времени, в течении которого должно завершиться выполнение функции.  Нужно тщательно выбирает длительность этого отрезка, .к. с одной стороны долгая работа метода может быть как особенность или даже необходимостью конкретной реализации функционала приложения, так и некорректным поведением.

Тестирование Backbone приложений

Для примера тестирования приложений, написанных с использованием Backbone.js воспользуемся проектом, описанным в статье «Разработка крупных JavaScript приложений» .

Модульными тестами можно проверить:

  • Корректность создания моделей и контроллеров
  • Правильность данных в моделях
  • Исполнение методов контроллеров (для этого они должны возвращать результат)
  • Успешность загрузки представлений

Код тестов:

test('Backbone.js', function() {
    ok(sample, 'Namespace check');

    ok(sample.routers.app, 'Router check');
    ok(sample.core.pageManager.open('chat'), 'Page opening test
                 (Controller method call)')

    ok(sample.core.state, 'Model check');
    equal(sample.core.state.get('content'), 'sintel', 'Model data get test');

    stop();
    ok(function() {
        $.ajax({
            url: 'app/templates/about.tpl',
            dataType: 'text'
        }).done(function(data) {
                self.$el.html(data);
                return data;
            })
    }, 'Template loading check');

    setTimeout(function() {
        start();
    }, 1000);
});

Результат работы с ошибками тестирования:

Автоматизация запуска тестов

Как правило разворачивание приложения является задачей, которую приходится выполнять достаточно часто при интенсивной разработке. Поэтому эту операцию как правило автоматизируют. Мы в своей работе используем Jenkins – инструмент для непрерывной интеграции. Идея заключается в совмещении деплоя через Jenkins с проведением автоматических тестов.

QUnit тесты запускаются в браузере. Обойти эту особенность нам поможет phantomjs – ПО, эмулирующее работу браузера. Разработчики phantomjs уже предоставили скрипт для выполнения QUnit тестов, однако для корректной работы пришлось немного его доработать.

Test.js:

/**
 * Wait until the test condition is true or a timeout occurs.
 * Useful for waiting
 * on a server response or for a ui change (fadeIn, etc.) to occur.
 *
 * @param testFx javascript condition that evaluates to a boolean,
 * it can be passed in as a string (e.g.: "1 == 1" or
 * "$('#bar').is(':visible')" or
 * as a callback function.
 * @param onReady what to do when testFx condition is fulfilled,
 * it can be passed in as a string (e.g.: "1 == 1" or
 * "$('#bar').is(':visible')" or
 * as a callback function.
 * @param timeOutMillis the max amount of time to wait. If not
 * specified, 3 sec is used.
 */
function waitFor(testFx, onReady, timeOutMillis) {
    var maxtimeOutMillis = timeOutMillis ? timeOutMillis : 3001,
            //< Default Max Timout is 3s
        start = new Date().getTime(),
        condition = false,
        interval = setInterval(function() {
            if ( (new Date().getTime() - start < maxtimeOutMillis) &&
                   !condition ) {
                // If not time-out yet and condition not yet fulfilled
                condition = (typeof(testFx) === "string" ? eval(testFx)
                           : testFx());
                   //< defensive code
            } else {
                if(!condition) {
                    // If condition still not fulfilled
                    // (timeout but condition is 'false')
                    console.log("'waitFor()' timeout");
                    phantom.exit(1);
                } else {
                    // Condition fulfilled (timeout and/or condition is
                    //'true')
                    console.log("'waitFor()' finished in " +
                         (new Date().getTime() - start) + "ms.");
                    typeof(onReady) === "string" ? eval(onReady)
                         : onReady();
                      //< Do what it's supposed to do once the
                      // condition is fulfilled
                    clearInterval(interval); //< Stop this interval
                }
            }
        }, 100); // repeat check every 250ms };
};

if (phantom.args.length === 0 || phantom.args.length > 2)
    console.log('Usage: run-qunit.js URL');
    phantom.exit();
}

var page = new WebPage();

// Route "console.log()" calls from within the Page
// context to the main Phantom context (i.e. current "this")
page.onConsoleMessage = function(msg) {
    console.log(msg);
};

page.open(phantom.args[0], function(status){
    if (status !== "success") {
        console.log("Unable to access network");
        phantom.exit();
    } else {
        waitFor(function(){
            return page.evaluate(function(){
                var el = document.getElementById('qunit-testresult');
                if (el && el.innerText.match('completed')) {
                    return true;
                }
                return false;
            });
        }, function(){
            var failedNum = page.evaluate(function(){
                var el = document.getElementById('qunit-testresult');
                console.log(el.innerText);
                try {
                    return document.getElementsByClassName('fail')[0].
                                    innerHTML.length;
                } catch (e) { return 0; }
                return 10000;
            });
            phantom.exit((parseInt(failedNum, 10) > 0) ? 1 : 0);
        });
    }
});

Для вывода в консоль сообщений о результатах в скрипт с тестами нужно добавить функцию логирования:

QUnit.log = function(test){
            if (!test.result) {
                console.log('- ' + test.message +
                 ' (expected:' + test.expected + '; actual: ' +test.actual);
                console.log('    ' + test.source);
            } else {
                console.log('+ ' + test.message);
            }
        }

Теперь заходим в Jenkins, создаем новый проект, а затем в нем создаем новую задачу:

Добавляем новый шаг сборки – выполнение команды shell:

И вводим команду:

phantomjs test.js test.html

Теперь осталось назначить сборочную директорию (ту, в которой лежит файл-песочница) и можно запускать сборку:

Пример результатов неудачного тестирования:

Полезные ссылки