29 серпня 2024 р.

Автоматичне тестування за допомогою Mocha

Автоматичне тестування буде використовуватися у наступних завданнях, і воно широко використовується у реальних проєктах.

Навіщо нам потрібні тести?

Коли ми пишемо функцію, ми можемо уявити, як її будуть використовувати – які параметри дають який результат.

Під час розробки ми можемо перевірити функцію, запустивши її та перевіряючи фактичний результат з очікуваним. Наприклад, ми можемо робити це у консолі.

Якщо результат не вірний – ми можемо підправити код, запустити її знову, перевірити результат знову, і так до тих пір, поки вона не працюватиме вірно.

Але такі ручні “повторні запуски” недосконалі.

Тестуючи код вручну, можна легко щось упустити.

Наприклад, ми створили функцію f. Перевірили деякий код, тестуємо: f(1) працює, але f(2) не працює. Ми підправляємо код і тепер f(2) працює. Здається, що справу зроблено? Але ми забули перевірити чи f(1) досі працює. Це може призвести до помилки.

Це дуже типово. Коли ми щось розробляємо, ми пам’ятаємо про багато можливих випадків використання. Але не треба очікувати, що програміст перевірятиме їх усі вручну після кожної зміни. Так стає легко виправити щось одне і зламати інше.

Автоматизоване тестування означає, що тести пишуться окремо від основного коду, доповнюючи його. Вони запускають наші функції різними способами та порівнюють результати з очікуваними.

Керована поведінкою розробка (BDD)

Розпочнімо з техніки під назвою Керована поведінкою розробка або коротко, BDD (від англ. behavior-driven development).

BDD це три в одному: і тести, і документація, і приклади використання.

Щоб зрозуміти BDD, ми розглянемо реальний приклад розробки.

Розробка функції піднесення до степеня – “pow”: специфікація

Припустимо, ми хочемо зробити функцію 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. Ми розглянемо це далі.

Процес розробки

Зазвичай, процес розробки має наступний вигляд:

  1. Пишуть первинну специфікацію з тестами основного функціонала.
  2. Створюється початкова реалізація.
  3. Щоб перевірити, чи вона працює, ми використовуємо тестовий фреймворк Mocha (більш детально нижче), який виконує специфікацію. Якщо функціонал не завершено – виводяться повідомлення про помилки. Ми робимо виправлення до тих пір, поки не матимемо повністю робочий код.
  4. Тепер ми маємо початкову реалізацію з тестами.
  5. Ми додаємо більше способів використання до специфікації, навіть таких, що поки що не підтримуються реалізацією. Виконання тестів знову завершиться невдачею.
  6. Переходимо на 3-й пункт, змінюємо реалізацію, щоб вона відповідала тестам і вони не повертали повідомлення про помилку.
  7. Повторюємо процес, описаний у пунктах з 3-го по 6-ий, поки функціонал не буде повністю готовий.

Тобто, процес розробки є ітеративним. Ми пишемо специфікацію, реалізуємо її, переконуємось, що тести проходять, потім пишемо ще тести, переконуємось, що вони також проходять і т.д. Завершивши цей процес, ми маємо реалізований робочий функціонал і тести до нього.

Розгляньмо цей процес розробки на нашому прикладі.

Перший пункт вже виконано – ми маємо первинну специфікацію для функції pow. Тепер, перед початком написання коду, використаймо декілька бібліотек JavaScript для запуску тестів, щоб перевірити, що вони працюють (звичайно, без коду функції, вони всі завершаться невдачею).

Специфікація в дії

Тут у посібнику ми будемо використовувати такі бібліотеки JavaScript для тестів:

  • Mocha – базовий фреймворк: він забезпечує нас загальними функціями для тестування, в тому числі describe та it, а також головною функцією, що виконує тести.
  • Chai – бібліотека для порівняння і оцінки роботи коду. Вона дозволяє використовувати безліч різних порівнянь, але поки що нам потрібне лише функція порівняння assert.equal.
  • Sinon – бібліотека для “шпигування” за функціями, емуляції вбудованих функцій тощо, нам це знадобиться набагато пізніше.

Ці бібліотеки підходять як для тестування в браузері, так і на стороні сервера. Тут ми розглянемо варіант тестування в браузері.

Повна HTML-сторінка з цими бібліотеками та специфікацією функції pow:

<!DOCTYPE html>
<html>
<head>
  <!-- додаємо css стилі для mocha, щоб вивести результати -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.css">
  <!-- додаємо фреймворк mocha до коду -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.js"></script>
  <script>
    mocha.setup('bdd'); // вмикаємо тестування у bdd стилі
  </script>
  <!-- додаємо chai -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/chai/3.5.0/chai.js"></script>
  <script>
    // chai має багато всього, давайте зробимо assert глобальним
    let assert = chai.assert;
  </script>
</head>

<body>

  <script>
    function pow(x, n) {
      /* код функції треба написати, поки що цей блок пустий */
    }
  </script>

  <!-- скрипт з тестами (describe, it...) -->
  <script src="test.js"></script>

  <!-- елемент з id="mocha" буде містити результати тестів -->
  <div id="mocha"></div>

  <!-- запускаємо тести! -->
  <script>
    mocha.run();
  </script>
</body>

</html>

Сторінку можна розділити на п’ять частин:

  1. <head> містить сторонні бібліотеки та стилі для тестів.
  2. <script> містить код функції, яку треба тестувати, в нашому випадку – код функції pow.
  3. Тести – в нашому випадку зовнішній скрипт test.js, який містить специфікацію describe("pow", ...), описану вище.
  4. HTML елемент <div id="mocha"> буде використаний фреймворком Mocha для виведення результатів.
  5. Тести запускаються командою mocha.run().

Результати:

Поки що тест провалюється, є помилка. Це логічно – код функції pow пустий, тобто pow(2,3) повертає undefined замість 8.

На майбутнє зазначимо, що є більш високорівневі засоби для запуску тестів, наприклад karma та інші, які полегшують автоматичний запуск різних тестів.

Первинна реалізація

Розробімо первинну реалізацію функції pow, щоб тести проходили:

function pow(x, n) {
  return 8; // :) це обман!
}

Вау, тепер воно працює!

Вдосконалення специфікації

Те, що ми зробили, це, безумовно, обман. Функція не працює: спроба обчислити `pow (3,4) 'дала б неправильний результат, але тести проходять.

… Але ситуація досить типова, це відбувається на практиці. Тести проходять, але функція працює неправильно. Наша специфіка недосконала. Нам потрібно додати більше випадків використання.

Додамо ще один тест, щоб перевірити наступне: pow(3, 4) = 81.

Тут можна вибрати один з двох способів організувати тест:

  1. Перший спосіб – додати assert до того ж самого it:

    describe("pow", function() {
    
      it("підносить до n-нного степеня", function() {
        assert.equal(pow(2, 3), 8);
        assert.equal(pow(3, 4), 81);
      });
    
    });
  2. Другий – написати два тести:

    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.

Створення тестів окремо корисно, щоб отримати більше інформації про те, що відбувається, так що другий варіант краще.

Окрім цього, є ще одне правило, якого варто дотримуватися.

Один тест перевіряє щось одне.

Якщо ми подивимось на тест і побачимо в ньому дві незалежні перевірки, то краще розділити його на два простіші.

Тож продовжимо з другим варіантом.

Результат:

Як ми могли б очікувати, другий тест не пройдений. Звичайно, наша функція завжди повертає 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);
  }

});

Результат:

Вкладені інструкції “describe”

Ми додамо ще більше тестів. Але перед цим зазначимо, що допоміжна функція 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 описує нову “підгрупу” тестів. У вихідних даних ми можемо побачити в заголовку відступи:

В майбутньому ми можемо додати ще it та describe на верхньому рівні з власними допоміжними функціями, в яких не буде доступу до makeTest.

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)
Відкрити приклад в пісочниці.

Як правило, beforeEach/afterEach і before/after використовуються для виконання ініціалізації, скидання лічильників або ще для чогось між тестами (або групами тестів).

Розширення специфікації

Основна функціональність pow завершена. Перша ітерація розробки завершена. Відсвяткувавши та випивши шампанське, продовжимо вдосконалювати її.

Як було сказано, функція pow(x, n) має працювати з додатними цілими значеннями n.

Щоб вказати на математичну помилку, функції JavaScript зазвичай повертають NaN. Зробимо те ж саме для недійсних значень n.

Давайте спочатку додамо опис цієї поведінки до специфікації(!):

describe("pow", function() {

  // ...

  it("для недійсних n результатом є NaN", function() {
    assert.isNaN(pow(2, -1));
  });

  it("для не цілих n результатом є NaN", function() {
    assert.isNaN(pow(2, 1.5));
  });

});

Результат з новими тестами:

Нещодавно додані тести не проходять, оскільки наша реалізація їх не підтримує. Так і робиться в BDD: спочатку ми пишемо невдалі тести, а потім пишемо для них реалізацію.

Інші припущення

Зверніть увагу на припущення assert.isNaN: воно перевіряє на NaN.

Є також інші функції порівняння у Chai, наприклад:

  • 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
  • …повний список знаходиться в документації

Таким чином, ми повинні додати пару рядків до функції pow:

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;
}

Тепер вона працює, всі тести проходять:

Відкрити повний код остаточного прикладу в пісочниці.

Підсумки

В BDD спочатку пишуть специфікацію, потім реалізацію. В результаті ми маємо і специфікацію, і код реалізації.

Специфікацію можна використовувати трьома способами:

  1. Як Тести – вони гарантують, що код працює правильно.
  2. Як Документацію – назви describe та it описують, що робить функція.
  3. Як Приклади – тести – це фактично робочі приклади, що показують, як можна використовувати функцію.

За допомогою специфікації ми можемо безпечно вдосконалювати, змінювати, навіть переписувати функцію з нуля і переконатись, що вона досі працює правильно.

Це особливо важливо для великих проєктів, коли функція використовується в багатьох місцях. Коли ми змінюємо таку функцію, просто неможливо вручну перевірити, чи кожне місце, яке її використовує, все ще працює правильно.

Без тестів люди мають два варіанти:

  1. Здійснити зміну, незважаючи ні на що. І тоді наші користувачі зустрічаються з помилками, оскільки ми, мабуть, не перевірили щось вручну.
  2. Або, якщо покарання за помилки суворе, оскільки немає тестів, люди бояться змінювати такі функції, і тоді код застаріває, ніхто не хоче його правити. Це не добре для розробки.

Автоматичне тестування допомагає уникнути цих проблем!

Якщо проєкт покритий тестами, такої проблеми просто немає. Після будь-яких змін ми можемо запустити тести й побачити безліч перевірок, зроблених за лічені секунди.

Крім того, добре перевірений код має кращу архітектуру.

Звісно, це тому, що автоматично перевірений код легше змінювати та вдосконалювати. Але є й інша причина.

Для написання тестів код повинен бути організований таким чином, щоб кожна функція мала чітко описане завдання, чітко визначені вхідні дані та вихідний результат. Це означає хорошу архітектуру з самого початку.

У реальному житті це часом не так просто. Іноді складно написати специфікацію перед фактичним кодом, тому що ще не зрозуміло, як він повинен поводитись. Але в цілому написання тестів робить розробку швидшою і стабільнішою.

Пізніше в підручнику ви зустрінете багато завдань з тестами. Тож ви побачите більше практичних прикладів.

Написання тестів вимагає хороших знань JavaScript. Але ми тільки починаємо це вивчати. Отже, не хвилюйтесь, поки що вам не потрібно писати тести, але ви вже маєте можливість їх прочитати, навіть якщо вони будуть трохи складніші, ніж ті, що наведені у цій главі.

Завдання

важливість: 5

Що не так з тестом функціій pow, вказаним нижче?

it("Підносить x до n-нного степеня", function() {
  let x = 5;

  let result = x;
  assert.equal(pow(x, 1), result);

  result *= x;
  assert.equal(pow(x, 2), result);

  result *= x;
  assert.equal(pow(x, 3), result);
});

P.S. Синтаксичних помилок не має і тести проходять.

Тест демонструє одну зі спокус, з якою стикається розробник, коли пише тести.

Що ми маємо тут, це насправді 3 тести, але вони були описані однією функцією з 3 припущеннями.

Іноді простіше написати таким чином, але якщо трапляється помилка, стає не очевидно, що пішло не так.

Якщо помилка трапляється посеред складного потоку виконання, то нам доведеться з’ясувати які були дані на той момент. Тобто, нам доведеться налагоджувати тест.

Було б набагато краще розбити тест на кілька блоків it із чітко прописаними вхідними даними та результатами.

Наприклад:

describe("Підносить до n-нного степеня", function() {
  it("5 піднесене до степеня 1 дорівнює 5", function() {
    assert.equal(pow(5, 1), 5);
  });

  it("5 піднесене до степеня 2 дорівнює 25", function() {
    assert.equal(pow(5, 2), 25);
  });

  it("5 піднесене до степеня 3 дорівнює 125", function() {
    assert.equal(pow(5, 3), 125);
  });
});

Ми замінили один блок it на describe і групу блоків it. Тепер, якщо виникає помилка, ми чітко бачимо, з якими даними вона виникає.

Також ми можемо виділити один тест і запустити його в автономному режимі, написавши it.only замістьit:

describe("Підносить x до n-нного степеня", function() {
  it("5 піднесене до степеня 1 дорівнює 5", function() {
    assert.equal(pow(5, 1), 5);
  });

  // Mocha запустить лише цей блок
  it.only("5 піднесене до степеня 2 дорівнює 25", function() {
    assert.equal(pow(5, 2), 25);
  });

  it("5 піднесене до степеня 3 дорівнює 125", function() {
    assert.equal(pow(5, 3), 125);
  });
});
Навчальна карта