22 декабря 2015
9 августа 2012
3
Введение в unit тестирование JavaScript кода
Довольно распространенная проблема при написании модульных тестов для клиентского кода состоит в том, что его структура не подходит для тестирования. Ведь JavaScript код может быть написан для любой страницы сайта или модуля в приложении, также он может быть непосредственно связан с серверной логикой и с HTML кодом. В самом худшем случае код полностью привязан к HTML в качестве встроенных обработчиков событий.
Перевод статьи «Introduction to JavaScript Unit Testing».
Обычно такая ситуация происходит, когда разработчик для написания приложения не использует специальные JavaScript библиотеки. Еще бы, ведь написать встроенный обработчик события намного легче, чем привязать его же через DOM API. Однако большинство разработчиков все же используют специальные JavaScript библиотеки, например JQuery. JQuery позволяет помещать встроенные обработчики в отдельные сценарии или на той же странице, или в отдельном js файле. Но код, размещенный в отдельном файле, не является готовым к тестированию модулем.
Что же такое модуль? В лучшем случае это функция, которая всегда возвращает некоторый результат для некоторого параметра. Такой модуль тестировать очень легко. Конечно для выделения таких функций из существующего скрипта большую часть времени придется потратить на операции с деревом DOM. Однако такой подход поможет выделить из кода структурные единицы для создания модульных тестов.
Создание модульных тестов
Легче всего тестировать скрипт, если пишешь его с нуля. Но статья не об этом. В данной статье я постараюсь рассказать о том, как извлекать и тестировать наиболее важные части скрипта, а также выявлять и устранять ошибки.
Процесс изменения внутренней структуры программы без изменения ее поведения называется рефакторингом. Это отличный способ улучшения кода в проекте. Но любое изменение программного кода может изменить поведение программы, поэтому лучше всего запускать модульные тесты сразу после рефакторинга.
При добавление тестов к существующему коду, возможен риск нарушить его работу. Поэтому пока вы не освоились с юнит-тестами, старайтесь дополнительно проверять работу кода вручную.
Достаточно теориии! Разберу-ка я практический пример, тестируя конкретный JavaScript код, который встроен в html код страницы. Скрипт просматривает все ссылки с аттрибутом title. И использует значение этого аттрибута для преобразования текста ссылок в более человеческий вид: «n minutes ago», «n hours ago», «n weeks ago».
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Mangled date examples</title>
<script>
function prettyDate(time) {
var date = new Date(time || ""),
diff = ((new Date().getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
return;
}
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) + " minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) + " hours ago") || day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) + " weeks ago";
}
window.onload = function() {
var links = document.getElementsByTagName("a");
for (var i = 0; i < links.length; i++) {
if (links[i].title) {
var date = prettyDate(links[i].title);
if (date) {
links[i].innerHTML = date;
}
}
}
};
</script> </head> <body>
<ul>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-28T20:24:17Z">January 28th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
Если вы запустите этот скрипт, то увидите проблему: ни одна дате не заменилась. Хотя код при этом работает. Он проходит по всем ссылкам a и проверяет, есть ли у них title. Если он есть, то скрипт передает его значение функцие prettyDate. Если же она возвращает что-то, то скрипт заменяет innerHTML ссылки на этот результат.
Преобразование кода в тестируемый вид
Проблема в этом скрипте в том, что функция prettyDate для любой даты старше 31 дня возвращает undefined
(с помощью return), оставляя тескт ссылки без изменений. А текущая дата намного старше 31-го дня. Поэтому, чтобы скрипт изменил текст ссылок, я жестко указал текущую дату:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Mangled date examples</title>
<script type="text/javascript">
function prettyDate(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
return;
}
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) + " minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) + " hours ago") ||
day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) + " weeks ago";
}
window.onload = function(){
var links = document.getElementsByTagName("a");
for (var i = 0; i < links.length; i++) {
if (links[i].title) {
var date = prettyDate("2008-01-28T22:25:00Z", links[i].title);
if (date) {
links[i].innerHTML = date;
}
}
}
};
</script>
</head>
</body>
<ul>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-28T20:24:17Z">January 28th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
В результате работы скрипта, появились ссылки: «2 часа назад», «вчера» и так далее. Это уже кое-что, но до тестируемого модуля не дотягивает. Поэтому, без дальнейшего рефакторинга все, что я могу делать, это протестировать изменения в разметке.
Рефакторинг, стадия 0
По всей видимости скрипт нуждается в 2 изменениях:
- Передача даты в функцию prettyDate (что уже сделано);
- Выделение функции prettyDate в отдельный файл, чтобы его можно было подключить в страницу для модульного тестирования.
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type"content="text/html; charset=UTF-8"/>
<title>Refactored date examples</title>
<script src="prettydate.js"></script> <script>
window.onload = function() {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if (links[i].title) {
var date = prettyDate("2008-01-28T22:25:00Z", links[i].title);
if (date) {
links[i].innerHTML = date;
}
}
}
};
</script>
</head>
<body>
<ul>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-28T20:24:17Z">January 28th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
Содержимое файла prettydate.js:
function prettyDate(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
return;
}
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) + " minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) + " hours ago") ||
day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) + " weeks ago";
}
Теперь можно писать модульный тест.
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type"content="text/html; charset=UTF-8"/>
<title>Refactored date examples</title>
<script src="prettydate.js">
</script>
<script>
function test(then, expected) {
results.total++;
var result = prettyDate("2008-01-28T22:25:00Z", then);
if (result !== expected) {
results.bad++;
console.log("Expected " + expected + ", but was " + result);
}
}
var results = {
total: 0,
bad: 0
};
test("2008/01/28 22:24:30", "just now");
test("2008/01/28 22:23:30", "1 minute ago");
test("2008/01/28 21:23:30", "1 hour ago");
test("2008/01/27 22:23:30", "Yesterday");
test("2008/01/26 22:23:30", "2 days ago");
test("2007/01/26 22:23:30", undefined);
console.log("Of " + results.total + " tests, " + results.bad + " failed, "
+ (results.total - results.bad) + " passed.");
</script>
</head>
<body>
</body>
</html>
(Перед запуском убедитесь, что у вас включена консоль, например Firebug или Chrome’s Web Inspector.)
В результате получился модульный тест, который выводит результаты в консоль. Это позволяет избежать зависимости от DOM, поэтому тест можно запустить в небраузерной JavaScript среде, например Node.js или Rhino.
Скрипт выведет в консоль суммарную информацию: общее количество тестов, количество неудачных тестов, количество удачных тестов.
При успешном прохождении всех тестов, результат будет следующего вида:
Of 6 tests, 0 failed, 6 passed.
Если один из тестов не прошел, то он выведен в консоль в таком виде:
Expected 2 day ago, but was 2 days ago. Of 6 tests, 1 failed, 5 passed.
Данный подход к разработке модульных тестов интересен как доказательство концепции: гораздо более практично использовать существующий фреймворк для тестирования, который обеспечит лучшую производительность и больше возможностей для написания и организации тестов.
Среда QUnit для тестирования JavaScript кода
Выбор фреймворка для тестирования является делом вкуса. Я предпочитаю использовать QUnit, потому что он крутой)
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type"content="text/html; charset=UTF-8"/>
<title>Refactored date examples</title>
<link rel="stylesheet" href="qunit.css" />
<script src="qunit.js"></script>
<script src="prettydate.js"></script>
<script>
test("prettydate basics", function() {
var now = "2008/01/28 22:25:00";
equal(prettyDate(now, "2008/01/28 22:24:30"), "just now");
equal(prettyDate(now, "2008/01/28 22:23:30"), "1 minute ago");
equal(prettyDate(now, "2008/01/28 21:23:30"), "1 hour ago");
equal(prettyDate(now, "2008/01/27 22:23:30"), "Yesterday");
equal(prettyDate(now, "2008/01/26 22:23:30"), "2 days ago");
equal(prettyDate(now, "2007/01/26 22:23:30"), undefined);
});
</script>
<div id="qunit"></div>
</head>
<body>
</body>
</body>
</html>
Здесь стоит обратить внимание на 3 секции. Здесь подключены 3 файла: 2 файла это QUnit (qunit.css and qunit.js) и 1 файл prettydate.js.
Затем идет блок JavaScript кода с тестовыми данными. Метод test
вызывается только один раз, передавая в качестве первого аргумента имя теста, а вторым аргментом является функция, которая фактически запускает тесты. В этом блоке объявляется переменная now
, которая используется при вызове метода equal. Метод equal — это один из методов, которые предлагает QUnit. В качестве первого аргумента методу equal передается результат функции prettyDate. В качестве второго аргумента методу equal передается ожидаемый результат. Если эти два аргумента будут одинаковыми, то утверждение истинно, иначе не истинно.
При успешном тесте QUnit выведет результат следующего вида:
Если во время тестирования было не верное утверждение, то результат будет такого вида:
Поскольку на втором скриншоте один из тестов был неудачным, то QUnit не сворачивает лог тестирования и можно сразу увидеть, что пошло не так. Вместе с выводом ожидаемых и фактических результатов QUnit также показывает разницу между ними, которая очень полезна при сравнении больших строк. Здесь же довольно очевидно, что пошло не так.
Рефакторинг, шаг 1
Тестовых данных недостаточно, т.к. я не тестирую вариант n weeks ago. Но сначала я снова сделаю рефакторинг. Функция prettyDate вызывается для каждого утверждения и каждый раз передается аргумент now. Сейчас я это исправлю:
test("prettydate basics", function() {
function date(then, expected) {
equal(prettyDate("2008/01/28 22:25:00", then), expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
Теперь функция prettyDate вызывается внутри новой функции date и больше не создается переменная now, ее значение жестко прописано в качестве параметра.
Тестирование операций с DOM деревом
Я уже достаточно хорошо поработал с функцией prettyDate. Теперь обратим внимание на первоначальный скрипт. В скрипте делалась выборка некоторых DOM-элементов, и они обновлялись в зависимости от результата функции prettyDate. Применяя те же принципы как и прежде, я сделаю рефакторинг и оттестирую его как следует) Кроме того, я создам модуль для этих 2 функций, и, чтобы избежать ошибок с глобальным пространством имен, дам им более осмысленные имена.
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type"content="text/html; charset=UTF-8"/>
<title>Refactored date examples</title>
<link rel="stylesheet" href="qunit.css" />
<script src="qunit.js"></script>
<script src="prettydate.js"></script>
<script>
test("prettydate.format", function() {
function date(then, expected) {
equal(prettyDate.format("2008/01/28 22:25:00", then), expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
test("prettyDate.update", function() {
var links = document.getElementById("qunit-fixture").getElementsByTagName("a");
equal(links[0].innerHTML, "January 28th, 2008");
equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update("2008-01-28T22:25:00Z");
equal(links[0].innerHTML, "2 hours ago");
equal(links[2].innerHTML, "Yesterday");
});
test("prettyDate.update, one day later", function() {
var links = document.getElementById("qunit-fixture").getElementsByTagName("a");
equal(links[0].innerHTML, "January 28th, 2008");
equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update("2008/01/29 22:25:00");
equal(links[0].innerHTML, "Yesterday");
equal(links[2].innerHTML, "2 days ago");
});
</script>
</head>
<body>
<div id="qunit"></div> <div id="qunit-fixture"> <ul>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-28T20:24:17Z">January 28th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-27T22:24:17Z">January 27th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
</ul>
</div></body>
</html>
Содержание prettydate2.js:
var prettyDate = {
format: function(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
return;
}
return day_diff === 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) + " minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) + " hours ago") ||
day_diff === 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) + " weeks ago";
},
update: function(now) {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if (links[i].title) {
var date = prettyDate.format(now, links[i].title);
if (date) {
links[i].innerHTML = date;
}
}
}
}
};
Из базового скрипта я выделил новую функцию prettyDate.update, однако аргумент now
все еще передается в prettyDate.format. Базовый QUnit-тест для этой функции начинается с отбора всех элементов a из блока div с идентификатором #qunit-fixture. В HTML коде появился новый элемент <div id="qunit-fixture">…</div>. В нем содержится извлеченная разметка из начального примера, необходимая для тестирования. Помести её в #qunit-fixture можно не беспокоиться о влиянии одного теста на результат другого, т.к. QUnit автоматически сбрасывает разметку после каждого теста.
Что происходит при первом тестировании prettyDate.update.
Сначала выбираются ссылки из <div id="qunit-fixture">…</div>, далее они проверяются на содержание дат. Затем вызывается prettyDate.update, передавая фиксированную дату (такую же как и в предыдущих тестах). Потом происходит проверка текста этих же ссылок на желаемые значения «2 hours ago» и «Yesterday», т.к. prettyDate.update должен был заменить этот текст.
Рефакторинг, шаг 2
Следующий тест prettyDate.update,
one day later делает почти тоже самое, за исключением того, что передает другую дату в prettyDate.update и потому возвращает другой результат для 2 ссылок.
Настало время для очередного рефакторинга! Необходимо убрать дублирование!
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type"content="text/html; charset=UTF-8"/>
<link rel="stylesheet" href="qunit.css" />
<script src="qunit.js"></script>
<script src="prettydate2.js"></script>
<script>
test("prettydate.format", function() {
function date(then, expected) {
equal(prettyDate.format("2008/01/28 22:25:00", then), expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
function domtest(name, now, first, second) {
test(name, function() {
var links = document.getElementById("qunit-fixture").getElementsByTagName("a");
equal(links[0].innerHTML, "January 28th, 2008");
equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update(now);
equal(links[0].innerHTML, first);
equal(links[2].innerHTML, second);
});
}
domtest("prettyDate.update", "2008-01-28T22:25:00Z:00", "2 hours ago", "Yesterday");
domtest("prettyDate.update, one day later", "2008-01-29T22:25:00Z:00", "Yesterday", "2 days ago");
</script>
</head>
<body>
<div id="qunit"></div> <div id="qunit-fixture"> <ul>
<ul>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-28T20:24:17Z">January 28th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-27T22:24:17Z">January 27th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
</ul>
</div></body>
</html>
В этом примере я создал новую функцию domtest
, которая инкапсулирует логику двух предыдущих вызовов для проверки и передает такие аргументы как имя теста, дату в строковом формате и две ожидаемые на выходе строки. Эта функция вызывается дважды.
Что произошло?
Надо бы посмотреть, что получилось в итоге)
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type"content="text/html; charset=UTF-8"/>
<title>Final date examples</title>
<script src="prettydate2.js"></script> <script> window.onload = function() {
prettyDate.update("2008-01-28T22:25:00Z");
};
</script>
</head>
<body>
<ul>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-28T20:24:17Z">January 28th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-27T20:24:17Z">January 27th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-26T20:24:17Z">January 26th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-25T20:24:17Z">January 25th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-24T20:24:17Z">January 24th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-14T22:24:17Z">January 14th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2008-01-04T22:24:17Z">January 4th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
<li class="entry" id="post57">
<p>blah blah blah…</p>
<small class="extra">
Posted <a href="/2008/01/blah/57/" title="2007-12-15T22:24:17Z">December 15th, 2008</a>
by <a href="/john/">John Resig</a>
</small>
</li>
</ul>
</body>
</html>
В результате рефакторинга было сделано множество усовершенствований по сравнению с первым примером. И благодаря модулю prettyDate я могу добавить еще больше функциональности без конфликтов в глобальном пространстве имен.
Заключение
При тестировании JavaScript кода возникает вопрос не только в том, какой выбрать фреймворк или написать модуль самостоятельно. Обычно приходится делать большие структурные изменения для их применения к скрипту, который прежде проверялся вручную. Я очень надеюсь, что вы поняли хоть что-нибудь. Например, как изменять структуру существующего модуля для тестирования и как использоваеть более функциональный фреймворк, для получения более полезных результатов.
У фреймворк QUnit намного больше возможностей, чем я описал. Например, есть поддержка тестирования асинхронного кода: AJAX и события, тайм-ауты. Визуализация результатов помогает отлаживать код, облегчает повторный запуск специфичных тестов и предусматривает стек вызовов для неудачных утверждений и захваченных исключений. Для дальнейшего изучения QUnit фреймворка пройдите по ссылке QUnit Cookbook.