понедельник, 29 июля 2013 г.

Дружим TeamCity, PhantomJS и QUnit (тестирование JavaScript на билд-сервере)

Давно хотелось тестировать JavaScript в Teamcity автоматически. В свое время пробовал jsTestDriver, но он мне не подошел, тк имеет очень ограниченные возможности работы с DOM. Например нельзя работать со страницей целиком, только с маленьким кусочком HTML, что для modules.js например мало подходит. Тесты в моих JavaScript проектах написаны на QUnit. Соответственно задача встала: как подружить Teamcity и QUnit?

Сперва взглянем на типичный простейший тест на QUnit:

//noinspection JSUnresolvedFunction
module("Modules.DOM", {
    setup: function() {
        //Definition of Setup module
        //noinspection JSUnresolvedVariable
        window.exports = window.exports || (window.exports = {});
        (function (Setup) {
            function isHTMLModule () {
                var moduleItemType = "module";
                var anotherItemType = "template";
                var body = document.getElementsByTagName("body")[0];
                var divHTMLModule = document.createElement('div');
                divHTMLModule.className = "HTMLModule";
                divHTMLModule.setAttribute("data-" + "modulesjs_item_type", moduleItemType);
                var testModuleInHTMLModule = document.createElement("div");
                testModuleInHTMLModule.className = "testModuleInHTMLModule";
                divHTMLModule.appendChild(testModuleInHTMLModule);
                body.appendChild(divHTMLModule);

                var divNotHTMLModule = document.createElement("div");
                divNotHTMLModule.className = "NotHTMLModule";
                divNotHTMLModule.setAttribute("data-" + "modulesjs_item_type", anotherItemType);
                var testModuleInNotHTMLModule = document.createElement("div");
                testModuleInNotHTMLModule.className = "testModuleInNotHTMLModule";
                divNotHTMLModule.appendChild(testModuleInNotHTMLModule);
                body.appendChild(divNotHTMLModule);
            }
            Setup.isHTMLModule = isHTMLModule;
        }(window.exports.Setup || (window.exports.Setup = {})));
        //noinspection JSUnresolvedVariable
        var Setup = window.exports.Setup;

        //Setup excecution
        Setup.isHTMLModule();
    },
    teardown: function() {
        //Definition of Teardown module
        //noinspection JSUnresolvedVariable
        window.exports = window.exports || (window.exports = {});
        (function (Teardown) {
            function isHTMLModule () {
                var body = document.getElementsByTagName("body")[0];

                var divHTMLModule = document.getElementsByClassName("HTMLModule")[0];
                body.removeChild(divHTMLModule);

                var divNotHTMLModule = document.getElementsByClassName("NotHTMLModule")[0];
                body.removeChild(divNotHTMLModule);
            }
            Teardown.isHTMLModule = isHTMLModule;
        }(window.exports.Teardown || (window.exports.Teardown = {})));
        //noinspection JSUnresolvedVariable
        var Teardown = window.exports.Teardown;

        //Teardown execution
        Teardown.isHTMLModule();
    }
});
test("isHTMLModule", function() {
    //noinspection JSUnresolvedFunction
    expect(3);
    var testModuleInHTMLModule = document.getElementsByClassName("testModuleInHTMLModule")[0];
    var expected = true;
    var actual = Modules.DOM.isHTMLModule(testModuleInHTMLModule);
    equal(actual, expected, "testModule is a html module");

    var testModuleInNotHTMLModule = document.getElementsByClassName("testModuleInNotHTMLModule")[0];
    var expected = false;
    var actual = Modules.DOM.isHTMLModule(testModuleInNotHTMLModule);
    equal(actual, expected, "testModule is not a html module (another module)");

    var expected = false;
    var actual = Modules.DOM.isHTMLModule(window);
    equal(actual, expected, "window is not a html module");
});

В этом примере базовая структура QUnit теста: создаем модуль Modules.DOM (в моем случае Modules.DOM - модуль JS фреймворка modules.js с использованием module pattern).

В нем в Setup динамически создаем необходимые компоненты html, нужные для тестирования фреймворка, а в Teardown – убиваем. Setup и Teardown вызываются перед началом каждого модуля (а не теста, об этом следует помнить).

Далее простейший тест функции, определяющей, является ли модулем переданный htmlElement (с точки зрения modules.js).

Это довольно стандартный способ использования QUnit, мы получаем красивую html страницу с результатами тестов. Не забываем к ней в правильном порядке подключить QUnit, фреймворк для тестирования и файл с тестами:

<!DOCTYPE html>
<html>
<head>
    <metahttp-equiv="Content-Type"content="text/html; charset=utf-8"/>
    <title>Modules.js Tests Result</title>
    <linkrel="stylesheet"type="text/css"href="libs/qunit.css">
    <script src="libs/qunit.js" type="text/javascript"></script>
    <script src="modules.js" type="text/javascript"></script>
    <script src="modulesTests.js" type="text/javascript"></script>
</head>
<body>
<divid="qunit"></div>
</body>
</html>

Собственно страница с результатами тестов:

✔ Modules js Tests Result 2013 07 29 18 10 57

Для того, чтобы прикрутить TeamCity, необходима технология, которая позволит запускать тесты в консоли без gui (на моем alm сервере linux в digitalocean нет gui). Такая технология уже есть и называется PhantomJS.

Поставить его на OS X на клиенте элементарно с помощью brew  (вообще очень полезная тула, похожая на подход из gentoo): 

brew update && brew install phantomjs

PhantomJS скачается и соберется у вас на OS X.

На сервере Linux не сильно сложнее - качаем отсюда версию для X64 или для X86 (для arm теоретически можно собрать из исходников, но честно говоря не пробовал):

http://phantomjs.org/download.html

Далее распаковываем, например я положил папку с phantomjs в /usr/share/phantom_jsv1. … Сделал симлинк туда же, чтобы при обновлении версии менять только его.

ln -s /usr/share/phantom_jsv1… /usr/share/phantomjs

Я использую cent os 6.4, в ней прописал путь к phantomjs в systemwide:

$ sudo vi /etc/profile.d/phantomjs.sh
export PHANTOMJS_HOME=/usr/share/phantomjs

export PATH=${PHANTOMJS_HOME}/bin:${PATH}

Ребутаемся, проверяем, что phantomjs доступен: 

$ phantomjs
phantomjs>

Теперь нам нужно добавить в проект рядом с JavaScript тестами раннер для PhantomJS, код его (run-qunit.js):

/**
 * Created by trukhinyuri on 29/07/13.
 */
/**
 * 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(1);
}

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(1);
    } 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 tests = document.getElementById("qunit-tests").childNodes;
                console.log("\nTest name (failed, passed, total)\n");
                for(var i in tests){
                    var text = tests[i].innerText;
                    if(text !== undefined){
                        if(/Rerun$/.test(text)) text = text.substring(0, text.length - 5);
                        console.log(text + "\n");
                    }
                }

                var el = document.getElementById('qunit-testresult');
                console.log(el.innerText);
                try {
                    return el.getElementsByClassName('failed')[0].innerHTML;
                } catch (e) { }
                return 10000;
            });
            phantom.exit((parseInt(failedNum, 10) > 0) ? 1 : 0);
        });
    }
});

Можно проверить, что тесты QUnit запускаются в консоли:

phantomjs run-qunit.js index.html

Tests  bash  80×24 2013 07 29 18 33 17

Можно в этом месте подумать, как сделать Continuous Testing, а пока научим Teamcity отображать результат:

для этого заюзаем service messages . Наш скрипт будет писать сообщения в стандартный вывод, который и будет читать Teamcity.

Добавим в начало нашего файла с тестами JavaScript код, который будет писать сообщения по мере прохождения тестов в стандартный вывод: 

//QUnit.moduleStart({ name })
QUnit.moduleStart = function(settings){
    console.log("##teamcity[testSuiteStarted name='" + settings.name + "']");
};

//QUnit.moduleDone({ name, failed, passed, total })
QUnit.moduleDone = function(settings){
    console.log("##teamcity[testSuiteFinished name='" + settings.name + "']");
};

//QUnit.testStart({ name })
QUnit.testStart = function (settings){
    console.log("##teamcity[testStarted name='" + settings.name + "']");
};

//QUnit.testDone({ name, failed, passed, total })
QUnit.testDone = function(settings){
    if(settings.failed > 0){
        console.log("##teamcity[testFailed name='" + settings.name + "'"
                     + " message='Assertions failed: " + settings.failed + "'"
                     + " details='Assertions failed: " + settings.failed + "']");
    }
    console.log("##teamcity[testFinished name='" + settings.name + "']");
};

Ну все, можно коммитить в репозиторий, настраивать билд на TeamCity, причем BuildStep мы берем Command Line, выбираем рабочей директорией ту, где лежат наши тесты, custom script:

#!/bin/bash
/usr/share/phantomjs/bin/phantomjs run-qunit.js index.html

где сначала идет путь к phantomjs на сервере, далее раннер phantomjs, далее страница с результатами тестов QUnit.

Ура ура, наш демо JavaScript проект успешно протестировался на TeamCity:

Modules js  qunit >  4  29 Jul 13 13 41 > Overview  TeamCity 2013 07 29 18 43 11

Modules js  qunit >  4  29 Jul 13 13 41 > Tests  TeamCity 2013 07 29 18 43 58

Комментариев нет:

Отправить комментарий