Автоматичне тестування буде використовуватися у наступних завданнях, і воно широко використовується у реальних проєктах.
Коли ми пишемо функцію, ми можемо уявити як її будуть використовувати - які параметри даватимуть який результат.
Під час розробки ми можемо перевірити функцію запустивши її та звіривши фактичний результат з очікуваним. Наприклад, ми можемо робити це у консолі.
Якщо фактичний результат не відповідає очікуваному -- ми можемо підправити код, запустити її знову, перевірити результат знову, і так до тих пір, поки вона не працюватиме вірно.
Але такі ручні "повторні запуски" недосконалі.
Тестуючи код вручну, можна легко щось упустити.
Наприклад, ми створили функцію f
. Перевірили деякий код. Тестуємо: f(1)
працює, але f(2)
не працює. Ми підправляємо код і тепер f(2)
працює. Здається ніби справу зроблено. Але ми забули перевірити чи f(1)
досі працює. Це може призвести до помилки.
Це дуже типово. Коли ми щось розробляємо, ми пам’ятаємо про багато можливих випадків використання. Але не треба очікувати, що програміст перевірятиме їх усі вручну після кожної зміни. Так стає легко виправити щось одне і зламати інше.
Автоматизоване тестування означає, що тести пишуться окремо від основного коду, доповнюючи його. Вони запускають наші функції різними способами та порівнюють результати з очікуваними.
Розпочнімо з техніки під назвою Керована поведінкою розробка або коротко, BDD (від англ. behavior-driven development).
BDD -- це три в одному: і тести, і документація, і приклади використання.
Щоб зрозуміти BDD, ми розглянемо реальний приклад розробки.
Припустимо, ми хочемо зробити функцію pow(x, n)
, яка піднесе x
до степеня n
. Ми припускаємо, що n≥0
.
Це завдання є просто прикладом. В JavaScript це саме може зробити оператор **
, але ми використаємо цей приклад щоб зосередитись на процесі розробки, який потім можна також застосовувати й для складніших завдань.
Перш ніж створити код для функції pow
, ми можемо уявити, що вона повинна виконувати, і описати її.
Такий опис називається специфікацією, і він описує приклади використання функції разом з тестами, наприклад:
describe("pow", function() {
it("підносить до n-нного степеня", function() {
assert.equal(pow(2, 3), 8);
});
});
Як ви помітили, специфікація має три основні блоки:
describe("title", function() { ... })
: Яку функціональність ми описуємо. В нашому випадку, ми описуємо функцію pow
. Використовується для групування блоків it
, які "виконують роботу".
it("підносить до n-нного степеня", function() { ... })
: У першому аргументі (назві) it
ми описуємо людською мовою конкретний спосіб використання функції, а у другому аргументі пишемо функцію, яка тестуватиме цей спосіб.
assert.equal(value1, value2)
: Код всередині блоку it
має виконуватись без помилок, якщо реалізація правильна.
Функції `assert.*` використовуються для перевірки того, що функція `pow` працює, як ми очікуємо. В нашому випадку, ми використовуємо одну з них -- `assert.equal`, вона порівнює аргументи і сповіщає про помилку, якщо вони відрізняються. Тут вона перевіряє чи результат `pow(2, 3)` дорівнює `8`. Є також інші способи порівняння та перевірки, які ми розглянемо пізніше.
Специфікацію можна виконати, і вона автоматично виконає тести, вказані у блоках it
. Ми розглянемо це далі.
Зазвичай, процес розробки має наступний вигляд:
- Пишуть первинну специфікацію з тестами основного функціоналу.
- Створюється початкова реалізація.
- Щоб перевірити, чи вона працює, ми запускаємо фреймворк для тестів Mocha (більш детально нижче), який виконує специфікацію. Якщо функціонал не завершено -- виводяться повідомлення про помилки. Ми робимо виправлення до тих пір, поки наш код не почне працювати як слід.
- Тепер ми маємо початкову реалізацію з тестами.
- Ми додаємо більше способів використання до специфікації, навіть таких, що поки що не підтримуються реалізацією. Виконання тестів знову завершиться невдачею.
- Переходимо на 3-й пункт, змінюємо реалізацію, щоб вона відповідала тестам і вони не повертали повідомлення про помилку.
- Повторюємо процес, описаний у пунктах з 3-го по 6-ий, поки функціонал не буде повністю готовий.
Тобто, процес розробки є ітеративним. Ми пишемо специфікацію, реалізуємо її, переконуємось, що тести проходять, потім пишемо ще тести, переконуємось, що вони також проходять і т.д. Завершивши цей процес, ми маємо реалізований робочий функціонал і тести до нього.
Розгляньмо цей процес розробки на нашому прикладі.
Перший пункт вже виконано -- ми маємо первинну специфікацію для функції pow
. Тепер, перед початком написання коду, використаймо декілька бібліотек JavaScript для запуску тестів, щоб перевірити, що вони працюють (звичайно, без коду функції, вони всі завершаться невдачею).
Тут у посібнику ми будемо використовувати такі бібліотеки JavaScript для тестів:
- Mocha -- базовий фреймворк: він забезпечує нас загальними функціями для тестування, в тому числі
describe
таit
, а також головною функцією, що виконує тести. - Chai -- бібліотека для порівняння і оцінки роботи коду. Вона дозволяє використовувати безліч різних порівнянь, але поки що нам потрібна лише функція порівняння
assert.equal
. - Sinon -- бібліотека для "шпигування" за функціями, емуляції вбудованих функцій тощо, нам це знадобиться набагато пізніше.
Ці бібліотеки підходять як для тестування в браузері, так і на стороні сервера. Тут ми розглянемо варіант тестування в браузері.
Повна HTML-сторінка з цими бібліотеками та специфікацією функції pow
:
Сторінку можна розділити на п’ять частин:
<head>
містить сторонні бібліотеки та стилі для тестів.<script>
з функцією, яку треба тестувати, в нашому випадку функцієюpow
.- Тести - в нашому випадку зовнішній скрипт
test.js
, який містить специфікаціюdescribe("pow", ...)
, описану вище. - HTML елемент
<div id="mocha">
буде використаний фреймворком Mocha для виведення результатів. - Тести запускаються командою
mocha.run()
.
Результати:
[iframe height=250 src="pow-1" border=1 edit]
Поки що тест провалюється, є помилка. Це логічно - код функції pow
пустий, тобто pow(2,3)
повертає undefined
замість 8
.
На майбутнє зазначимо, що є більш високорівневі засоби для запуску тестів, наприклад karma та інші, які полегшують автоматичний запуск різних тестів.
Розробімо первинну реалізацію функції pow
, щоб тести проходили:
function pow(x, n) {
return 8; // :) це обман!
}
Вау, тепер воно працює!
[iframe height=250 src="pow-min" border=1 edit]
Те, що ми зробили, це, безумовно, обман. Функція не працює: спроба обчислити pow (3,4)
дала б неправильний результат, але тести проходять.
... Але ситуація досить типова, це відбувається на практиці. Тести проходять, але функція працює неправильно. Наша специфіка недосконала. Нам потрібно додати більше випадків використання.
Додамо ще один тест, щоб перевірити наступне: pow(3, 4) = 81
.
Тут можна вибрати один з двох способів організувати тест:
-
Перший спосіб -- додати
assert
до того ж самогоit
:describe("pow", function() { it("підносить до n-нного степеня", function() { assert.equal(pow(2, 3), 8); *!* assert.equal(pow(3, 4), 81); */!* }); });
-
Другий -- написати два тести:
describe("pow", function() { it("2 піднесене до степеня 3 дорівнює 8", function() { assert.equal(pow(2, 3), 8); }); it("3 піднесене до степеня 4 дорівнює 81", function() { assert.equal(pow(3, 4), 81); }); });
Принципова відмінність полягає в тому, що коли assert
повертає помилку, блок it
негайно припиняється. Отже, у першому варіанті, якщо перший assert
не вдасться, ми ніколи не отримаємо результат другого assert
.
Створення тестів окремо корисно, щоб отримати більше інформації про те, що відбувається, так що другий варіант краще.
Окрім цього, є ще одне правило, якого варто дотримуватися.
Один тест перевіряє щось одне.
Якщо ми подивимось на тест і побачимо в ньому дві незалежні перевірки, то краще розділити його на два простіші.
Тож продовжимо з другим варіантом.
Результат:
[iframe height=250 src="pow-2" edit border="1"]
Як ми могли б очікувати, другий тест не пройдений. Звичайно, наша функція завжди повертає 8
, хоча assert
очікує 81
.
Напишімо щось більш реальне для проходження тестів:
function pow(x, n) {
let result = 1;
for (let i = 0; i < n; i++) {
result *= x;
}
return result;
}
Щоб переконатися, що функція працює правильно, перевірмо більше значень. Замість того, щоб писати блоки it
вручну, ми можемо генерувати їх в циклі for
:
describe("pow", function() {
function makeTest(x) {
let expected = x * x * x;
it(`${x} піднесене до степеня 3 дорівнює ${expected}`, function() {
assert.equal(pow(x, 3), expected);
});
}
for (let x = 1; x <= 5; x++) {
makeTest(x);
}
});
Результат:
[iframe height=250 src="pow-3" edit border="1"]
Ми додамо ще більше тестів. Але перед цим зазначимо, що допоміжна функція makeTest
та цикл for
повинні бути згруповані разом. Нам не знадобиться makeTest
в інших тестах - вона потрібна лише для циклу for
- їх спільне завдання перевіряти pow
, звівши число до заданого степеня.
Групування проводиться за допомогою вкладеної інструкції describe
:
describe("pow", function() {
*!*
describe("підносить x до степеня 3", function() {
*/!*
function makeTest(x) {
let expected = x * x * x;
it(`${x} піднесене до степеня 3 дорівнює ${expected}`, function() {
assert.equal(pow(x, 3), expected);
});
}
for (let x = 1; x <= 5; x++) {
makeTest(x);
}
*!*
});
*/!*
// ... тут можна додати більше тестів з "describe" та "it"
});
Вкладена інструкція describe
описує нову "підгрупу" тестів. У вихідних даних ми можемо побачити в заголовку відступи:
[iframe height=250 src="pow-4" edit border="1"]
В майбутньому ми можемо додати ще it
та describe
на верхньому рівні з власними допоміжними функціями, в яких не буде доступу до makeTest
.
````smart header="before
/`after` та `beforeEach`/`afterEach`"
Ми можемо налаштувати `before`/`after` функції, які виконуються перед/після запуску тестів, а також функції `beforeEach`/`afterEach`, які виконуються перед/після кожного `it`.
Наприклад:
describe("test", function() {
before(() => alert("Тестування розпочато – перед усіма тестами"));
after(() => alert("Тестування завершено – після всіх тестів"));
beforeEach(() => alert("Перед тестом – початок тесту"));
afterEach(() => alert("Після тесту – вихід з тесту"));
it('test 1', () => alert(1));
it('test 2', () => alert(2));
});
Послідовність запуску буде наступною:
Тестування розпочато – перед усіма тестами (before)
Перед тестом – початок тесту (beforeEach)
1
Після тесту – вихід з тесту (afterEach)
Перед тестом – початок тесту (beforeEach)
2
Після тесту – вихід з тесту (afterEach)
Тестування завершено – після всіх тестів (after)
[edit src="beforeafter" title="Відкрити приклад в пісочниці."]
Як правило, beforeEach
/afterEach
і before
/after
використовуються для виконання ініціалізації, скидання лічильників або ще для чогось між тестами (або групами тестів).
## Розширення специфікації
Основна функціональність `pow` завершена. Перша ітерація розробки завершена. Відсвяткувавши та випивши шампанське, продовжимо вдосконалювати її.
Як було сказано, функція `pow(x, n)` має працювати з додатними цілими значеннями `n`.
Щоб вказати на математичну помилку, функції JavaScript зазвичай повертають `NaN`. Зробимо те ж саме для недійсних значень `n`.
Давайте спочатку додамо опис цієї поведінки до специфікації(!):
```js
describe("pow", function() {
// ...
it("для недійсних n результатом є NaN", function() {
*!*
assert.isNaN(pow(2, -1));
*/!*
});
it("для не цілих n результатом є NaN", function() {
*!*
assert.isNaN(pow(2, 1.5));
*/!*
});
});
```
Результат з новими тестами:
[iframe height=530 src="pow-nan" edit border="1"]
Нещодавно додані тести не проходять, оскільки наша реалізація їх не підтримує. Так і робиться в BDD: спочатку ми пишемо невдалі тести, а потім пишемо для них реалізацію.
```smart header="Інші припущення"
Зверніть увагу на припущення `assert.isNaN`: воно перевіряє на `NaN`.
Є також інші функції порівняння у [Chai](https://chaijs.com), наприклад:
- `assert.equal(value1, value2)` -- перевіряє рівність `value1 == value2`.
- `assert.strictEqual(value1, value2)` -- перевіряє сувору рівність `value1 === value2`.
- `assert.notEqual`, `assert.notStrictEqual` -- інвертована перевірка (`!=`/`!==`).
- `assert.isTrue(value)` -- перевіряє, що `value === true`
- `assert.isFalse(value)` -- перевіряє, що `value === false`
- ...повний список знаходиться в [документації](https://chaijs.com/api/assert/)
```
Таким чином, ми повинні додати пару рядків до функції `pow`:
```js
function pow(x, n) {
*!*
if (n < 0) return NaN;
if (Math.round(n) != n) return NaN;
*/!*
let result = 1;
for (let i = 0; i < n; i++) {
result *= x;
}
return result;
}
```
Тепер вона працює, всі тести проходять:
[iframe height=300 src="pow-full" edit border="1"]
[edit src="pow-full" title="Відкрити повний код остаточного прикладу в пісочниці."]
## Підсумки
В BDD спочатку пишуть специфікацію, потім реалізацію. В результаті ми маємо і специфікацію, і код реалізації.
Специфікацію можна використовувати трьома способами:
1. Як **Тести** - вони гарантують, що код працює правильно.
2. Як **Документацію** -- назви `describe` та `it` описують, що робить функція.
3. Як **Приклади** -- тести - це фактично робочі приклади, що показують, як можна використовувати функцію.
За допомогою специфікації ми можемо безпечно вдосконалювати, змінювати, навіть переписувати функцію з нуля і переконатись, що вона досі працює правильно.
Це особливо важливо для великих проєктів, коли функція використовується в багатьох місцях. Коли ми змінюємо таку функцію, просто неможливо вручну перевірити, чи кожне місце, яке її використовує, все ще працює правильно.
Без тестів люди мають два варіанти:
1. Здійснити зміну, незважаючи ні на що. І тоді наші користувачі зустрічаються з помилками, оскільки ми, мабуть, не перевірили щось вручну.
2. Або, якщо покарання за помилки суворе, оскільки немає тестів, люди бояться змінювати такі функції, і тоді код застаріває, ніхто не хоче його правити. Це не добре для розробки.
**Автоматичне тестування допомагає уникнути цих проблем!**
Якщо проєкт покритий тестами, такої проблеми просто немає. Після будь-яких змін ми можемо запустити тести й побачити безліч перевірок, зроблених за лічені секунди.
**Крім того, добре перевірений код має кращу архітектуру.**
Звісно, це тому, що автоматично перевірений код легше змінювати та вдосконалювати. Але є й інша причина.
Для написання тестів код повинен бути організований таким чином, щоб кожна функція мала чітко описане завдання, чітко визначені вхідні дані та вихідний результат. Це означає хорошу архітектуру з самого початку.
У реальному житті це часом не так просто. Іноді складно написати специфікацію перед фактичним кодом, тому що ще не зрозуміло, як він повинен поводитись. Але в цілому написання тестів робить розробку швидшою і стабільнішою.
Пізніше в підручнику ви зустрінете багато завдань з тестами. Тож ви побачите більше практичних прикладів.
Написання тестів вимагає хороших знань JavaScript. Але ми тільки починаємо це вивчати. Отже, не хвилюйтесь, поки що вам не потрібно писати тести, але ви вже маєте можливість їх прочитати, навіть якщо вони будуть трохи складніші, ніж ті, що наведені у цій главі.