Тестирование 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/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 проще.

Комментарии (1)

  • 31 января 2017 в 23:45

    0

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

    Почему на этом месте нельзя перестать писать лапшу, и сделать рефакторинг?

© Habrahabr.ru