test.it — тестирование JavaScript или мой велосипед с вложенностью и подробным выводом

в 9:31, , рубрики: javascript, tdd, unit test, unit-testing, велосипед, велосипедостроение, модульное тестирование, тестирование, юнит-тестирование, юнит-тесты, метки: , , , , , , , , ,

Картинка для привлечения внимания:
test.it habrahabr
Я — начинающий веб-разработчик. И не так давно мне захотелось научиться работать так, как это делают настоящие программисты.
Под этим я понимал 3 основных элемента:

  1. Использование системы контроля версий.
  2. Грамотное комментирование кода.
  3. TDD или хотя бы простое юнит-тестирование кода.

Для первого пришлось освоить азы git, и создать свой первый репозиторий на github. Для второго выбрал JsDoc, из-за которого пришлось перебраться с notepad++ на sublime text (только там был соответствующий плагин).
А вот с третьим, неожиданно для меня, возникли серьёзные трудности.

Так как я очень уважаю jQuery (кстати первый свой репозиторий я как раз и открыл для написания плагина для jq) выбор фреймворка для юнит-тестирования пал на Qunit что оказалось роковой ошибкой. И столкнулся со следующим:

  • Сбивается основная вёрстка из-за того что для работы Qunit надо вставлять немаленький кусок HTML кода на страницу. И все результаты тестов отображаются там же. Да это можно исправить костылями с position:absolute, opacity:.5 и подобными, но я не хочу бороться с фреймворком, я хочу им пользоваться
  • Тесты неоднозначны. Я несколько раз натыкался на случай, когда, нажимая F5, получал разное количество пройденных и проваленных тестов. А у меня в коде ни асинхронности, ни работы с куки, ни аякс-запросов. Если я правильно понимаю то это из-за киллер-фичи изменения последовательности тестов, в зависимости от предыдущего результата — проваленные тесты проверяются в первую очередь.
  • Fail тестов чрезвычайно ненагляден. qUnit практически всегда выводит toString() одного из переданных аргументов, игнорируя второй. Изредка выводит и второй, а бывают ситуации, когда правильно сработает diff. Я понимаю что diff — очень сложная функция, и стоит радоваться тому что она хоть иногда срабатывает, но что мешает Qunit выводить аргументы при каждом fail — для меня загадка.

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

А теперь о нём поподробнее

test.it — фреймворк для тестирования JavaScript кода.
Для тех, кому текст не интересен — ссылка на репозиторий на github: test.it
Основные особенности:

  • Для отображения результата используется консоль.
  • Последовательность тестов не изменяется.
  • В результаты всех тестов включён массив полученных аргументов.
  • Поддерживается группировка тестов без ограничений на уровень вложенности.
  • При возникновении ошибки внутри группы (например, в одном из тестов), она отлавливается и отображается в результате, не нарушив работу остального кода.

Последнее я подглядел у Qunit, в этом они, конечно, молодцы.

На текущий момент реализованы тесты на:

  • Равенство двух аргументов. Аналог ok из Qunit.
  • Не-ложный результат аргумента (не NaN,Null,undefined,0,false,[],'') другими словами — прохождение if(). Близкий аналог equal из Qunit.

Простое начало

Для того что бы подключить фреймворк — достаточно всего лишь добавить строку

<script src='path/to/testit.js'></script>

куда вам вздумается в конец тега <body>.

А начать использование можно, очевидно, с первого теста:

test.it('first test');

Тест — функция (метод объекта test) которая проверяет выполнение какого-либо условия.
Функция test.it(entity) проверяет существование entity и не-ложность его значения.
Открыв консоль (Firebug или Chrome, как обладающие максимальной поддержкой данного API) мы увидим следующее:
пустая консоль
Не густо (:
А всё потому, что мы не завершили тест вызовом test.done(). Сделаем это. Теперь наш код выглядит так:

test.it('first test');
test.done();

А в консоли соответственно:
root - pass
Что означает, что все тесты пройдены.

root — корневой элемент, или группа тестов нулевого уровня. Он обладает теми же свойствами что и любая другая группа, но о них будет подробнее чуть позже.
Если раскрыть элемент root мы увидим статистику прохождения всех тестов внутри него, и время их выполнения.
root
Очевидно что:

  • pass — количество пройденных
  • fail — количество проваленных
  • error — количество завершившихся ошибкой выполнения

Статистика разделена на тесты и группы. Но наверняка в ближайших релизах она упроститься, и это разделение пропадёт.

Последняя строка: pass: no comment — наш тест. Развернём его и посмотрим подробнее:
first test
Первым делом идёт метка о том, что тест пройден. Вообще они бывают трёх видов:

  • pass — пройден
  • fail — провален
  • error — завершился ошибкой, которая не повлекла за собой сбоя работы остального кода
    (например было передано 0 или >2 аргументов)

Далее no comment — комментарий, который мы ещё не задали. Чуть ниже рассмотрим, как это можно сделать.
Следующая строка «argument exist and not false» — описание того, что необходимо для прохождения теста.
И напоследок массив полученных аргументов, в нашем случае из одного элемента ″first test″

Что бы не видеть больше этого неприятного no comment, добавим к нашему тесту комментарий.

test.it('first test');
    test.comment('Простая проверка');
test.done();

Теперь результат выглядит так:
простая проверка

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

test.it('first test');
    test.comment('Простая проверка');
var Me = {name:'Titulus',lastName:'Desiderio'};
test.it(Me);
    test.comment('Я существую?');
test.done();

Теперь в консоли:
2 теста

Можем рассмотреть второй тест поподробнее. Развернём его, и Object — единственный аргумент, в массиве аргументов.
2 тест
Как вы видите, тест проверяет всего лишь существование, и не-ложность значения единственного переданного ему аргумента. Но это не мешает иногда подглядывать в этот объект, что бы лишний раз напомнить себе, что именно мы передали, и получить дополнительную, полезную в некоторых случаях, информацию.

Предыдущие тесты были успешно пройдены, давайте поставим задачу посложнее.

test.it('first test');
    test.comment('Простая проверка');
var Me = {name:'Titulus',lastName:'Desiderio'};
test.it(Me);
    test.comment('Я существую?');
test.it(Me.habr);
    test.comment('Хабр?');
test.done();

fail
Как вы заметили, проваленный тест, и соответствующая проваленная группа (root) были раскрыты по умолчанию.
Кстати, благодаря массиву аргументов, мы сразу видим, почему тест был провален — переданный аргумент не определён.

Теперь осталось исправить ситуацию:

test.it('first test');
    test.comment('Простая проверка');
var Me = {name:'Titulus',lastName:'Desiderio'};
test.it(Me);
    test.comment('Я существую?');
Me.habr = 'Хабрахабр!';
test.it(Me.habr);
    test.comment('Хабр?');
test.done();

И вновь старая картина!
root - pass

Давайте ещё усложним задачу. Вместо проверки на существование и не-ложность, проверим на правильность значения.

test.it('first test');
    test.comment('Простая проверка');
var Me = {name:'Titulus',lastName:'Desiderio'};
test.it(Me);
    test.comment('Я существую?');
Me.habr = 'Хабрахабр!';
test.it(Me.habr);
    test.comment('Хабр?');
test.it(Me.habr,'habrahabr.ru');
    test.comment('адрес хабра');
test.done();

функция test.it(entity1, entity2) — проверяет равенство между entity1 и entity2.
Посмотрим в консоль:
Хабрахабр! !=habrahabr.ru
Тест провален, чего и следовало ожидать. Глядя на результат, причина провала вполне очевидна. Исправив

Me.habr = 'Хабрахабр!';

на

Me.habr = 'habrahabr.ru';

Мы опять получаем
root - pass

Need to go deeper

Разберёмся в упомянутых ранее группах.
Группа

  • набор тестов (или групп);
  • метод объекта test который этот набор создаёт;
  • собственно сам этот набор в виде JavaScript-объекта.
    Помимо массива тестов и групп он несёт в себе статистику пройденных, проваленных и вызвавших ошибку тестов (или групп), а так же время, потраченное на их выполнение.

Рассмотрим следующий код:

test.it(2>1);
    test.comment('Работают ли тут законы математики?');
test.group('первая группа',function(){
    test.it(2>1);
        test.comment('А тут?');
});
test.done();

С test.it(2>1); и так всё понятно, но что делает test.group?
Функция test.group(groupname, fun) — создаёт новую подгруппу для тестов, других групп и прочего кода. Имя берётся из аргумента groupname. А функция fun будет пытаться выполниться, и если внутри неё произойдёт ошибка — это не прервёт работу остального кода. Ошибка будет помещена в поле error данной группы.

Раскроем root:
группа
Вот она наша группа первая группа, оформлена так же как root, только имя то — что мы задали.
Раскроем и её.
первая группа
Никаких особых отличий от root нет и быть не должно.
Тут стоит только обратить внимание на статистику в root и в нашей группе.
! Важный момент: статистика отображает только результаты на данном уровне. Пусть у нас и написано 2 тест, но у root в статистике тестов виден только один пройденный.

Добавим туда ещё пару-тройку тестов.

test.it(2>1);
    test.comment('Работают ли тут законы математики?');
test.group('первая группа',function(){
    test.it(2>1);
        test.comment('А тут?');
    test.it(1, Number(1));
        test.comment('равна ли еденица самой себе');
    test.it(h.a.b.r);
        test.comment('А что ты сделаешь с несуществующим аргументом?');
    test.it(2+2,4);
        test.comment('проверим на знание школьного курса');
});
test.it(1<2);
    test.comment('А теперь?');
test.done();

И увидим
ошибка
что тест на 2+2=4 даже не был запущен, потому что предыдущий с h.a.b.r не был выполнен из-за ошибки ReferenceError, которая была аккуратно выведена под статистикой, до тестов. Но при этом последний тест на 1<2 — проходит, потому что он был вне группы, с ошибкой.

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

И последний момент касательно групп. Многоуровневая вложенность!
need to go deeper

test.group('need',function(){
    test.group('to',function(){
        test.group('go',function(){
            test.group('deeper',function(){
                test.it('bye habr');
                    test.comment('bye bye');
            });
        });
    });
});
test.done();

многоуровневая вложенность

Под капотом

Весь код доступен на github, так что можете его читать, комментировать, форкать, предлагать пуллреквесты и т.п. Лицензия MIT, хотя подумываю о переходе на WTFPL.

Хотел остановиться подробнее на test.root — объект соответствующий группе нулевого уровня. Именно его заполняют тесты, а потом уже _printConsole() его парсит и наводит всю эту красоту.

test.root для первого примера с группами:

{
    "type": "group",
    "name": "root",
    "status": "pass",
    "time": 7,
    "result": {
        "tests": {
            "passed": 1,
            "failed": 0,
            "error": 0,
            "total": 1
        },
        "groups": {
            "passed": 1,
            "failed": 0,
            "error": 0,
            "total": 1
        }
    },
    "stack": [
        {
            "type": "test",
            "status": "pass",
            "comment": "Работают ли тут законы математики?",
            "description": "argument exist and not false",
            "time": 0,
            "entity": [
                true
            ]
        },
        {
            "type": "group",
            "name": "первая группа",
            "status": "pass",
            "time": 1,
            "result": {
                "tests": {
                    "passed": 1,
                    "failed": 0,
                    "error": 0,
                    "total": 1
                },
                "groups": {
                    "passed": 0,
                    "failed": 0,
                    "error": 0,
                    "total": 0
                }
            },
            "stack": [
                {
                    "type": "test",
                    "status": "pass",
                    "comment": "А тут?",
                    "description": "argument exist and not false",
                    "time": 0,
                    "entity": [
                        true
                    ]
                }
            ]
        }
    ]
}

Кстати да. мне очень стыдно, но есть часть стороннего кода — функция deepCompare() не доступная извне. Она сравнивает два аргумента любых типов. Взял её тут.

И большой спасибо mkharitonov за подсказку в реализации многоуровневой вложенности.

Обратная сторона медали

Конечно, есть недостатки. Некоторые серьёзные, некоторые не очень. Надеюсь, раз код opensource, сообщество поможет мне их минимизировать, или превратить в достоинства.
Ключевые:

  • Не кроссбраузерно. Console API толком поддерживают только Google Chrome (и прочие на основе chromium) и плагин Firebug под firefox. Говорят ещё Safari, но у меня нету устройств Apple для проверки.
  • Нет возможности запустить отдельно одну группу тестов.
    Есть костыль — ставить test.done() не в конце всего кода, а в конце той группы, которую надо протестировать. Но это костыль, и он не обладает всем необходимым функционалом.
    решаемо
  • На текущий момент нету diff, и это решаемо.
  • Нет тестов на ajax, и это опять же решаемо.

Что дальше?

А дальше будут названные выше изменения вывода статистики.
Улучшения обработки и вывода ошибок.
Улучшения вывода тестов — добавятся нумерация и подсказки для проваленных тестов.
Добавятся новые тесты test.them, test.type, test.types, test.time. О них подробнее можете прочитать в README.
Будут исправляться недостатки, названные в предыдущем разделе.

Ещё раз ссылка на репозиторий на github: test.it

P.S. Пост будет перенесён в хаб «Я Пиарюсь», как только наберу достаточно кармы.

Автор: titulusdesiderio

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js