В процессе разработки современных 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 умеет парcить любой JS и дает API с помощью которого можно трансформировать JS во что угодно у меня появилась идея написать плагин который облегчил бы процесс написания подобных тестов и дал бы возможность делать простой monkey patching несмотря на изолированность функций вызовы которых мы хотели бы подменить.
Работа такого плагина проста и ее можно разложить на три шага:
- Найти обращение к нашему маленькому фреймворку в коде тестов.
- Найти модуль и функцию в котором мы хотим перехватить что-либо.
- Изменить код тестов и тестируемого модуля подставив заглушки вместо соответтвующих вызовов.
В итоге получился плагин для 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 проще.
Автор: poluyanov