Тестування untestable JS c допомогою Babel snarejs

image

У процесі розробки сучасних JS додатків особливе місце приділяється тестуванню.
Test Coverage на сьогодні є чи не основним показником якості JS коду.
Останнім часом з'явилася величезна кількість фреймворків які вирішують задачі тестування: jest, mocha, sinon, chai, jasmine, список можна довго продовжувати довго, але навіть маючи таку свободу вибору інструментів для написання тестів залишаються кейси які складно перевірити.

Про те як протестувати те що в загальному може бути untestable піде мова далі.


Проблема

Погляньте на простий модуль для роботи з блог постами який робить XHR запити.

export function createPost (text) {
return api('/rest/blog/').post(text);
}

export function addTagToPost (postId, tag) {
return api(`/rest/blog/${postId}/`).post(tag);
}

export function createPostWithTags (text, tags = []) {
createPost(text).then( ({ postId }) => 
Promise.all(tags.map( tag =>
addTagToPost(postId, tag)
))
})
}


Функція api породжує xhr запит.
createPost — створює блозі пост.
addTagToPost — позначає існуючий блогпост.
createPostWithTags — створює блогпост і позначає його відразу ж.

Тести до функцій createPost і addTagToPost зводяться до перехоплення XHR запиту, перевірки переданого URI і payload (що можна зробити з допомогою, наприклад, useFakeXMLHttpRequest() з пакету sinon) і перевірки що функція повертає promise з тим значенням, яке ми повернули з xhr stub'а.

const fakeXHR = sinon.useFakeXMLHttpRequest();
const reqs = [];

fakeXHR.onCreate = function (req) {
reqs.push(req);
};

describe('createPost()', () => {
it('URI', () => {
createPost('TEST TEXT')
assert(reqs[0].url === '/rest/blog/'); 
});

it('blogpost text', () => {
createPost('TEST TEXT')
assert(reqs[1].data === 'TEST TEXT');
});

it('should return promise with postId', () => {
const p = createPost('TEST TEXT');
assert(p instanceof Promise);

reqs[3].respond(200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
postId: 333
})
);

return p.then( ({ postId }) => {
assert(postId === 333);
})
});
})


Код тесту для addTagToPost схожий тому я його тут не наводжу.

Але як має виглядати тест для createPostWithTags?

Оскільки createPostWithTags() изпользует createPost() і addTagToPost() і залежить від результату виконання цих функцій нам необхідно продублювати в тесті для createPostWithTags() код тесту для createPost() і addTagToPost (), який повертає дані в xhr об'єкт щоб забезпечити працездатність функції createPostWithTags()

it('should create post', () => {
createPostWithTags('TEXT', ['tag1', 'tag2'])

// перевірка виклику createPost(text)
assert(reqs[0].requestBody === 'TEXT');

reqs[0].respond(200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
postId: 333
})
);

});


Відчуваєте, що щось не так?

Щоб протестувати функцію createPostWithTags нам треба перевірити що вона покликала функцію createPost() з аргументом 'TEXT'. Щоб це зробити нам доводиться дублювати тест з самого createPost():

assert(reqs[0].requestBody === 'TEXT');


Щоб наша функція продовжила виконання нам також потрібно відповісти на запит надісланий createPost що теж є copy paste з коду тесту.

reqs[0].respond(200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
postId: 333
})
);


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

Нагадую про те, що крім забезпечення роботи функції createPost() нам доведеться ловити XHR запити з addTagToPost який викликається в циклі і стежити за тим щоб addTagToPost повернув promise саме з тим tagId який ми передали за допомогою reqs[i].respond():

it('should create post', () => {
createPostWithTags('TEXT', ['tag1', 'tag2'])

assert(reqs[0].requestBody === 'TEXT');

// Response for createPost()
reqs[0].respond(200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
postId: 333
})
);

// Response for first call of addTagToPost()
reqs[1].respond(200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
tagId: 1
})
);

// Response for second call of addTagToPost()
reqs[2].respond(200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
tagId: 2
})
);
});


inb4: Можна замокати модуль api.
Приклад спеціально спрощений для розуміння проблеми і мій код сильно заплутанішими цього.
Але навіть якщо замокати модуль api — це не позбавить нас від перевірки переданих аргументів всередину.

В моєму коді багато асинхронних запитів до API, окремо вони всі покриваються тестами, але є функції зі складною логікою які викликають ці запити — і тести для них перетворюється на щось середнє між spaghetti code і callback hell.

Якщо функції складніше, або банально знаходяться в одному файлі(як це прийнято робити в flux/redux архітектурах) то ваші тести розпухнуть на стільки що складність їх роботи буде сильно вище ніж складність роботи вашого коду, що і сталося у мене.

Формулювання завдання
Ми не повинні перевіряти роботу createPost і addTagToPost всередині тіста createPostWithTags.

Завдання тестування функцій подібних createPostWithTags() зводиться до підміни викликів функцій всередині, перевірки аргументів і викликом заглушки замість оригінальних функцій яка буде повертати потрібне в конкретному тесті значення. Це називається monkey patching.

Проблема в тому що JS не дає нам можливості заглянути всередину scope модуля/функції і перевизначити виклики addTagToPost і createPost всередині createPostWithTags.

Якби createPost і addTagToPost лежали в сторонньому модулі то ми могли використовувати що-небудь на зразок jest для того щоб перехопити звернення до них, але в нашому випадку це не рішення задачі оскільки функції, виклики яких ми хотіли б перехопити, можуть бути приховані глибоко всередині scope досліджуваної функції і не експортовані назовні.

Рішення
Як і багато з вас, на нашому проекті ми так само активно використовуємо Babel.
Оскільки Babel вміє парсити будь-JS і дає API за допомогою якого можна трансформувати JS у що завгодно у мене з'явилася ідея написати плагін який полегшив би процес написання подібних тестів і дав би можливість робити простий monkey patching незважаючи на ізольованість функцій виклики яких ми хотіли б підмінити.

Робота такого плагіна проста і її можна розкласти на три кроки:
  1. Знайти звернення до нашого маленького фреймворку в коді тестів.
  2. Знайти модуль і функцію в якому ми хочемо перехопити що-небудь.
  3. Змінити код тестів і тестування модуля підставивши заглушки замість соответтвующих викликів.
В результаті вийшов плагін для Babel під назвою snare(пастка)js який можна підключити до проекту і він зробить ці три пункти за вас.

Snare.js
Для початку потрібно встановити і підключити snare до вашого проекту.

npm install snarejs


Та додати його в ваш .babelrc

{
"presets": ["es2015", "react"],
"plugins": [
"snarejs/lib/plugin"
]
}


Щоб пояснити як snarejs працює давайте відразу напишемо тест для нашого createPostWithTags():

import snarejs from 'snarejs';
import {spy} from 'sinon';

import createPostWithTags from '../actions';

describe('createPostWithTags()', function () {
const TXT = 'TXT';
const POST_ID = 346;
const TAGS = ['tag1', 'tag2', 'tag3'];

const snare = snarejs(createPostWithTags);

const createPost = spy(() => Promise.resolve({
postId: POST_ID
}));

const addTagToPost = spy((addTagToPost, postId, tag) =>
Promise.resolve({
tag,
id: TAGS.indexOf(tag)
})
);

snare.catchOnce('createPost()', createPost);

snare.catchAll('addTagToPost()', addTagToPost);

const result = snare(TXT);

it('should call createPost with text', () => {
assert(createPost.calledWith(TXT));
});

it('should call addTagToPost with postId and tag name', () => {
TAGS.forEach( (tagName, i) => {
// First argument is post id
assert(addTagToPost.args[i][1] == POST_ID);
// Second argument
assert(addTagToPost.args[i][2] == tagName);
});
});

it('result should be promise with tags', () => {
TAGS.forEach( (tagName, i) => {
assert(result[i].tag == tagName);
assert(result[i].id == TAGS.indexOf(tagName));
});
})
})


const snare = snarejs(createPostWithTags);

Тут знаходиться ініціалізація, наткнувшись на неї Babel плагін дізнається де знаходиться метод createPostWithTags (у нашому прикладі це модуль "../actions") і саме в ньому він буде перехоплювати відповідні виклики.
У змінній snare лежить об'єкт функції createPostWithTags з прототипом містить методами snarejs.

const createPost = spy(() => Promise.resolve({
postId: POST_ID
}));

sinon заглушка для createPost повертає promise.
Замість sinon можна користуватися звичайними функціями якщо вам не потрібно нічого з того що sinon дає.

const addTagToPost = spy((addTagToPost, postId, tag) =>

Зверніть увагу на перший аргумент заглушки, в ньому snarejs передає оригінальну функцію на випадок якщо вона раптом знадобиться. Слідом йдуть аргументи postId і tag — це оригінальні аргументи виклику функції, яку ми перехватываем.

snare.catchOnce('createPost()', createPost);

Тут ми вказуємо що потрібно перехопити виклик createPost() один раз і викликати нашу заглушку.

snare.catchAll('addTagToPost()', addTagToPost);

Тут ми вказуємо що потрібно перехопити всі виклики addTagToPost

const result = snare(TXT, TAGS);

Викликаємо нашу функцію createPostWithTags і результат записуємо в result для перевірки.

it('should call createPost with text', () => {
assert(createPost.args[0][1] == TXT);
});

Тут перевіряємо що другий аргумент виклику нашої заглушки дорівнює «TXT».
Перший аргумент — це оригінальна функція, не забули? :)

it('should call addTagToPost with postId and tag name', () => {
TAGS.forEach( (tagName, i) => {
assert(addTagToPost.args[i][1] == POST_ID);
assert(addTagToPost.args[i][2] == tagName);
});
});

З тегами теж все просто: оскільки ми знаємо набір переданих тегів, нам треба перевірити що кожен тег був переданий на виклик addTagToPost() разом з POST_ID.

it('result should be promise with tags', () => {
assert(result instanceof Promise);
});

Остання перевірка на тип результату.

Як я вже сказав вище, snare просто знаходить потрібні вам виклики при складанні ваших тестів і замінює його своїми.

Напрмер виклик addTagToPost(postId, tags) перетвориться у щось на зразок:
__g__.__SNARE__.handleCall({
fn: createPost,
context: null,
path: '/path/to/module/module.js/addTagToPost()'
}, postId, tags)


Як бачите, ніякої магії.

API
API дуже проста і складається з 4х методів.

var snareFn = snare(fn);

Як аргумент передається посилання на функцію всередину якої плагін буде шукати інші виклики.
Babel плагін, зустрічаючи ініціалізацію snarejs, ресолвит переданий аргумент.
Посилання може бути будь-яким ідентифікатором отриманим і з ES6 import або з commonJS require:

let fn = require('./module');
let {fn} = require('./module');
let {anotherName: fn} = require('./module');
let fn = require('./module').anotherName;
import fn from './module';
import {fn} from './module';
import {anotherName as fn} from './module';

У всіх випадках плагін знайде потрібний export у конкретному модулі і підмінить потрібні виклики в ньому. Сам export теж може бути або в стилі common.js або ES6.

snareFn.catchOnce('fnName()', function(fnName, ...args){});
snareFn.catchAll('fnName()', function(fnName, ...args){});

Першим аргументом передається рядок з CallExpression, другим функція-перехоплювач.
catchOnce перехоплює відповідний виклик один раз, catchAll відповідно перехоплює всі виклики.

snareFn.reset('fnName()');

Скасовує перехоплення виклику відповідної функції.

Пару нюансів:
У разі ви скористалися .catchOnce() і виклик в коді був перехоплений — то подальші виклики будуть працювати з оригінальною функцією поки ви не покличете catchOnce()/catchAll ().

Якщо вам необхідно перехопити виклик методу об'єкта, то this функції перехоплювача буде сам об'єкт:
snare.catchOnce('obj.api.helpers.myLazyMethod()', function(myLazyMethod, ...args){
// this === obj.api.helpers
// myLazyMethod - оригінальна функція
// args - оригінальні аргументи виклику 
})


.catchOnce() може бути кілька:
snare.catchOnce('fnName()', function(fnName, ...args){
console.log('first call of fnName()');
});

snare.catchOnce('fnName()', function(fnName, ...args){
console.log('second call of fnName()');
});

snare.catchOnce('fnName()', function(fnName, ...args){
console.log('third call of fnName()');
});


Замість висновку
Поки snare вміє працювати тільки з функціями, але в планах зробити підтримку класів.
Сучасний JS дуже різноманітний а плагін всередині працює з ast деревом — отже можливі баги в кейсах які я не врахував (всі пишуть по різному :), тому якщо наступіть на що-то не полінуйтеся створити issue github або напишіть мені(ip AT nginx.com).

Сподіваюся, що цей інструмент буде корисний вам так само як і мені і ваші тести стануть мякгимиишелк^W простіше.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.