...

вторник, 31 января 2017 г.

Тестирование 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 умеет парcить любой 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/http://ift.tt/2jQxKAY'
}, 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)

    Let's block ads! (Why?)

    Комментариев нет:

    Отправить комментарий