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 проще.
Поделиться с друзьями
-->

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


  1. funca
    31.01.2017 23:45
    +4

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

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


    1. Dair_Targ
      01.02.2017 05:37
      +3

      А как гарантировать, что новый код будет вести себя хотя бы отдалённо похожим образом?


  1. princed
    01.02.2017 00:00

    Интересно, попробую.

    Возможно вам будет любопытно поглядеть на похожие проекты:
    https://github.com/speedskater/babel-plugin-rewire
    https://github.com/asapach/babel-plugin-rewire-exports
    У них есть один общий минус: они ломают sinon.js в некоторых случаях (в подробностях не было времени разобраться).


  1. RubaXa
    01.02.2017 11:16
    +2

    Ваня, но ведь у тебя тесты ничего не тестируют, ты вызываешь и проверяешь какую-то левую переменную, в этом и проблема. Покрывай результат от входных параметров, а как там внутри реализовано, тебя должно мало волновать, максимум для создания моков.


    В итоге использовав Snare, получился тест, который развалится при рефакторинге createPostWithTags из-за жесткой зависимости от его реализации.


    P.S. Я понимаю, что есть разные подходы реализации тестов, но всё же советовал рассматривать код/методы как «черный ящик» (конечно критически оценивая каждый случай).


    1. poluyanov
      01.02.2017 12:14

      > максимум для создания моков
      Никак не противоречит моему подходу

      >В итоге использовав Snare, получился тест, который развалится при рефакторинге createPostWithTags из-за жесткой зависимости от его реализации.
      Костя, в моем кейсе это не работает, к сожалению.


      1. RubaXa
        02.02.2017 14:12

        Кстати, «в моем кейсе это не работает» — это проблема многих статей, в итоге пример только сбивают с толку и вызывают недоумение, надо как-то показать пример, например от простого, к сложному, мол «смотрите, а тут всё, никак, только Snare»


    1. poluyanov
      01.02.2017 12:19

      Такой тест в этом примере для того чтобы показать что вообще можно делать


  1. JSmitty
    01.02.2017 11:46

    С использованием babel-rewire единственное, что нельзя оттестировать (на нашем проекте) — методы, которые пишут в window.location — phantom.js например не дает тесту возможности заглушить такое, и исполнение теста рушится.


  1. acvetkov
    01.02.2017 12:32
    +3

    Ваня, привет. Спасибо за статью. Хочу высказать свое мнение.

    но всё же советовал рассматривать код/методы как «черный ящик»

    RubaXa подписываюсь под каждым словом.

    Считаю исключительным заблуждением тестирование работоспособности модулей вышеописанным способом.

    Тесты являются потребителем вашего кода, им важен контракт, соответствие заявленных способностей, описанных в интерфейсе (сигнатуре) реальным действиям.

    Тестироваться должны исключительно публичные методы, никаких приватных методов, а тем более то, что спрятано в замыканиях (привет, rewire). Почему так? Все просто, мы тестируем работоспособность модуля, а не его реализацию, тестирование приватных данных чревато серьезной завязкой на реализацию, при малейшем изменении все упадет. Такие тесты ничего не тестируют, они проверяют, что код не поменялся. Тестирование исключительно публичных методов дает серьезный маневр для рефакторинга или полного изменения реализации, достаточно оставить сигнатуру публичных методов нетронутой, а тесты проверят, что все по-прежнему работает ожидаемо.

    Теперь конкретно про ваш случай.

    Поскольку createPostWithTags() изпользует createPost() и addTagToPost() и зависит от результата выполнения этих функций

    Когда вы пишете тест — такие рассуждения не совсем корректны. На мой взгляд, правильно рассуждать так:

    Когда мы вызываем createPostWithTags у нас должно отправиться 2 post-запроса, один на создание поста, другой на добавления тега. Все, больше никаких рассуждений, тем более о внутреннем устройстве модуля.

    Любой тест должен быть определен по правилу: даю данные на вход, получаю ожидаемый результат (сайд-эффект) или ожидаемые данные на выходе.

    Чтобы это сделать нам приходится дублировать тест из самого createPost()

    Кстати, вот еще интересный момент. Я глубоко убежден, что к написанию тестов нужно подходить совсем иначе, нежели к написанию кода продукта.

    Код тестов должен быть максимально декларативным, должен содержать минимум логики, и копипаста из других тестов — это вполне ок. Цена ошибки в логике теста очень высока, потому ее надо сводить к минимуму.


    1. amakhrov
      06.02.2017 05:43

      Когда мы вызываем createPostWithTags у нас должно отправиться 2 post-запроса, один на создание поста, другой на добавления тега.

      Вот этого мы как раз и не знаем. Мы знаем, что api() должен вызываться 2 раза с разными параметрами. Может, api там внутри выполняет кэширование (и не делает лишних запросов). Может, он делает повтор при ошибке. Может, вообще преобразует данные из транспортного формата в формат клиентского кода.


      Поэтому все равно придется что-то мокать. Не createPost() — так api().


  1. amakhrov
    06.02.2017 05:42

    дубль