programming mentor

ти живий, поки вчишся

Модульне тестування JavaScript з Jest

2020-06-06 programming mentorJavaScript

Що являють собою і навіщо потрібні модульні тести?

В програмуванні є різні типи тестів - як автоматизованих, так і ручних. Узагальнено їх можна представити за допомогою такої піраміди:

Піраміда тестування
Піраміда тестування

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

Далі йдуть автоматизовані e2e (end-to-end) чи наскрізні тести. Іноді ще їх називають тестом інтерфейсу, хоча тестують вони не лише інтерфейс, а всю аплікацію.

Посередині піраміди розмістилися інтеграційні тести, вони перевіряють взаємодію окремих частин аплікації. І в її фундаменті знаходяться модульні (unit) тести, які перевіряють її частини.

Навіщо нам взагалі потрібні різни типи тестів і чому між ними має бути таке співвідношення можна пояснити на прикладі автомобіля.

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

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

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

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

Відповідно в програмуванні модульних тестів має бути найбільше, вони мають покривати максимальний обсяг коду, в ідеалі - 100%. І зазвичай відповідальність за їх написання лежить на розробнику. Є навіть такий підхід TDD (Test Driven Development), це коли спочатку пишуться тести, а потім вже код під них.

Чому Jest?

Для модульного тестування JavaScript існує велика кількість різних фреймворків, крім Jest - Mocha, Jasmine, Tape та інші. Окремо можна назвати інструменти для запуску, які використовуються з тими чи іншими фреймворками, а також дозволяють проводити e2e тести, наприклад, Karma, Protractor чи Puppeteer, також є Selenium, Cypress і т.п.

Кожен має свої особливості, у Jest вони наступні:

  • розроблений в facebook і по замовчуванню є інструментом модульного тестування в React, але підтримує всі інші фреймворки теж, у тому числі Angular та React;
  • виконується в NodeJs;
  • завдяки попередньому пункту дуже швидко працює на відміну, наприклад, від Karma, і запускає тести паралельно;
  • має цікаву функціональність - використання снепшотів (snapshot testing);
  • дуже популярний останнім часом, часто можна почути, що на нього переносять проекти з інших фреймворків.

Ми познайомимося з ключовими особливостями Jest без прив’язки до фреймворків чи поділу на фронтенд/бекенд, сфокусуємося виключно на тестуванні JavaScript.

Офіційний сайт Jest тут.

Інсталяція та налаштування

Зробимо пусту папку, наприклад jest-demo та

npm init -y

Встановимо jest

npm i --save-dev jest

Задамо скріпт в package.json

  "scripts": {
    "test": "jest"
  },

Зробимо файл для тестування sum.js:

export default sum;

function sum(a, b) {
    return a + b;
}

Та файл з тестом sum.test.js:

const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

Запустимо на виконання:

npm t

Пояснення:

  • test() - функція, яку jest розпізнає як тест, вона містить опис тесту та іншу функцію, що власне виконує тест
  • expect() - теж функція, яка очікує певне значення і повертає об’єкт, для якого можна виконати функції-матчери

Запуск babel з сучасним JavaScript

Встановимо та сконфігуруємо babel:

npm i --save-dev babel-jest @babel/core @babel/cli @babel/preset-env

.babelrc

{
    "presets": ["@babel/preset-env"]
}

Модифікуємо код для використання модулів:

export default sum;

function sum(a, b) {
    return a + b;
}
import sum from './sum';

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

Запустимо тест:

npm t

Додаємо перевірку покриття коду тестами

  "scripts": {
    "test": "jest --coverage"
  },

Запускаємо, бачимо звіт

npm t

> jest-sample@1.0.0 test C:\Projects.tmp\jest-sample
> jest --coverage

 PASS  ./sum.test.js
  √ adds 1 + 2 to equal 3 (3 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 | 
 sum.js   |     100 |      100 |     100 |     100 | 
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.712 s, estimated 2 s
Ran all test suites.

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

Варто внести її в .gitignore, щоб не потрапляла до репозиторію з кодом.

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

Відповідно модифікуємо package.json:

  "jest": {
    "testEnvironment": "node",
    "collectCoverageFrom": [
      "src/*.js"
    ]

Тепер запуск jest буде враховувати всі JS-файли, а не лише ті, для яких створені тести, сам код JS-файлів та тестів перемістимо в папку src.

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

Додамо в розділ jest файлу package.json наступні рядки:

"coverageThreshold": {
      "global": {
        "statements": 100,
        "branches": 100,
        "functions": 100,
        "lines": 100
      }
    }

Оскільки ми зазначили для всіх параметрів рівень 100%, то тепер тести не будуть проходити, якщо хоч один з критеріїв не буде досягнутий.

Також варто не забути додати папку coverage/ до .gitignore, щоб не включати її до репозиторію коду.

Для того, щоб запустити jest у watch-режимі, коли він постійно моніторить код, треба додати прапорець watch, модифікуємо package.json:

"scripts": {
    "test": "jest --coverage --watch"
  },

Запустимо тести в режимі постійного моніторингу

npm t

Матчери

Матчери (matchers) - це функції, які використовуються в тестах для перевірки результатів.

Перелік поширених матчерів:

  • toBe compares strict equality, using ===
  • toEqual compares the values of two variables. If it’s an object or array, it checks the equality of all the properties or elements
  • toBeNull is true when passing a null value
  • toBeDefined is true when passing a defined value (opposite to the above)
  • toBeCloseTo is used to compare floating values, avoiding rounding errors
  • toBeUndefined is true when passing an undefined value
  • toBeTruthy true if the value is considered true (like an if does)
  • toBeFalsy true if the value is considered false (like an if does)
  • toBeGreaterThan true if the result of expect() is higher than the argument
  • toBeGreaterThanOrEqual true if the result of expect() is equal to the argument, or higher than the argument
  • toBeLessThan true if the result of expect() is lower than the argument
  • toBeLessThanOrEqual true if the result of expect() is equal to the argument, or lower than the argument
  • toMatch is used to compare strings with regular expression pattern matching
  • toContain is used in arrays, true if the expected array contains the argument in its elements set
  • toHaveLength(number): checks the length of an array
  • toHaveProperty(keyundefined value): checks if an object has a property, and optionally checks its value
  • toThrow checks if a function you pass throws an exception (in general) or a specific exception
  • toBeInstanceOf(): checks if an object is an instance of a class

Всі матчери можна перевіряти у зворотному варіанті, для цього використовуємо .not..

Розглянемо приклад - нам потрібно протестувати функцію, яка клонує масив. Як ми це зробимо? Треба викликати функцію, передати масив і перевірити, чи в результаті буде ідентичний масив?

То наш тест може виглядати наступним чином, вірно?

test('if array is cloned', () => {
    const array = [1, 2, 3, 4, 5];
    expect(cloneArray(array)).toBe(array);
})

Однак його пройде така функція:

export default cloneArray;

function cloneArray(array) {
    return array;
}

Чи є тут проблема - так, і вона полягає в тому, що масив не клонується, а повертається існуючий.

Пофіксимо тест:

import cloneArray from './clone-array';

test('if array is cloned', () => {
    const array = [1, 2, 3, 4, 5];
    const clonedArray = cloneArray(array);
    expect(clonedArray).toEqual(array);
    expect(clonedArray).not.toBe(array);
})

Тепер пофіксимо код функції, і переконаємося, що тест її проходить:

export default cloneArray;

function cloneArray(array) {
    return [...array];
}

Використання snapshots

Одна із цікавих особливостей jest - це можливість використання снепшотів для зіставлення результату.

Допустимо, ми маємо функцію, що обрамляє якийсь текст в теги, файл:

wrap-tag.js

export default (text, tag) => `<${tag}>${text}</${tag}>`;

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

wrap-tag.test.js

import wrapTag from './wrap-tag.js';

test('can wrap tag', () => {
    const wrappedCode = wrapTag('Hello World', 'h1');
    expect(wrappedCode).toMatchSnapshot();
})

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

Допустимо, ми маємо файл products.json

[
    {
        "id": "1",
        "title": "Large drone",
        "image": "drone-large.jpg",
        "description": "Large drone for most critical missions. Can carry load.",
        "price": 499.99
    },
    {
        "id": "2",
        "title": "Small drone",
        "image": "drone-small.jpg",
        "description": "Small drone. Can fly undetected.",
        "price": 199.99
    },
    {
        "id": "3",
        "title": "Blue drone",
        "image": "drone-blue.jpg",
        "description": "Nice-looking drone in blue color. Has built-in HD camera",
        "price": 249.99
    },
    {
        "id": "4",
        "title": "Red drone",
        "image": "drone-red.jpg",
        "description": "Nice-looking drone in red color",
        "price": 229.99
    },
    {
        "id": "5",
        "title": "Black gyroboard",
        "image": "gyroboard-black.jpg",
        "description": "Black gyroboard to match your style",
        "price": 729.99
    },
    {
        "id": "6",
        "title": "White gyroboard",
        "image": "gyroboard-white.jpg",
        "description": "White gyroboard with blue lights",
        "price": 829.99
    },
    {
        "id": "7",
        "title": "Tesla Model X",
        "image": "tesla-x.jpg",
        "description": "Best crossover in the World",
        "price": 99999.99
    },
    {
        "id": "8",
        "title": "Tesla Roadster",
        "image": "tesla-roadster.jpg",
        "description": "Best sports car in the World",
        "price": 249999.99
    }
]

За допомогою jest переконаємося в тому, що цей файл коректний.

import products from './products.json';

test('products data is correct', () => {
    expect(products).toMatchSnapshot();
});

Моки та шпигуни

Правильний модульний тест повинен тестувати лише ізольований модуль. Для ізоляції модуля в нагоді стануть моки (mocks), підтримка яких вбудована в Jest.

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

Наприклад, розглянемо як можна потестувати використання Math.random().

Допустимо, маємо таку функцію:

export default random;

function random(min, max) {
    return Math.floor(Math.random() * (max - min + 1) + min);
}
import random from './random';

test('should return random number', () => {
    const mathRandomSpy = jest.spyOn(Math, 'random');
    mathRandomSpy.mockImplementation(() => 0.5);
    const randNum = random(0, 10);
    expect(randNum).toEqual(5);
    expect(mathRandomSpy).toHaveBeenCalled();
});

Асинхронність

Розглянемо цей код:

test("this shouldn't pass", () => {
    setTimeout( () => expect(false).toBe(true) );
});

Розглянемо асинхронну функцію:

export default function addAsync(a, b, callback) {
  setTimeout(() => {
    const result = a + b;
    callback(result);
  }, 500)
}

Тест:

import addAsync from './add-async';

test('add numbers async', done => {
    addAsync(10, 5, result => {
      expect(result).toBe(15);
      done();
    })
})

Щоб перевірити Promise:

add-promise.js:

export default function addPromise(a, b) {
    return Promise.resolve(a + b);
}

add-promise.test.js:

import addPromise from './add-promise';

test('should check promise', () => {
    const sum = addPromise(2, 2);
    return expect(sum).resolves.toBe(4);
});

З використанням async/await цей код можна переписати елегантніше (але з поточною версією babel виникає помилка):

import addPromise from './add-promise';

test('should check promise', async () => {
    const sum = await addPromise(2, 2);
    expect(sum).toBe(4);
});

Групування тестів та конфігурація

Якщо ми працюємо з великими наборами тестів, то є можливість групувати їх за допомогою describe(), а також виконувати певний код перед тестами чи по завершенню з beforeEach() та afterAll()

describe('first set', () => {
  beforeEach(() => {
    //do something
  })
  afterAll(() => {
    //do something
  })
  test(/*...*/)
  test(/*...*/)
})

describe('second set', () => {
  beforeEach(() => {
    //do something
  })
  beforeAll(() => {
    //do something
  })
  test(/*...*/)
  test(/*...*/)
})

Корисні лінки

Репозиторій з кодом: https://github.com/programmingmentor/jest-intro

Офіційний сайт Jest: https://jestjs.io/

Кілька корисних матеріалів:

https://silvenon.com/blog/mocking-with-jest/functions https://www.pluralsight.com/guides/test-asynchronous-code-jest https://jestjs.io/blog/2016/03/11/javascript-unit-testing-performance.html https://www.youtube.com/watch?v=NHMIn723hQY https://www.youtube.com/watch?v=r9HdJ8P6GQI

P.S. Якщо бажаєте вивчити сучасний JavaScript, і в тому числі попрактикуватися в TDD, запрошую на курс ScriptJedi42, який я проводжу кілька разів на рік невеликими групами.