
В процессе разработки современных 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 проще.