Глубинное погружение в test-driven JavaScript

в 8:30, , рубрики: jasmine, javascript, qunit, tdd, метки: , , ,

Многие JavaScript-фреймворки предлагают свое представление о том, как должен выглядеть код. Более того, речь идет не просто о стиле, речь идет о способе написания сценариев. Это обусловлено практически абсолютной демократичностью JavaScript, да-да, именно таким является мультипарадигменный язык с С-подобным синтаксисом, прототипным наследованием, динамической типизацией и реализацией разнящейся от браузера к браузеру. Поэтому, когда речь идет о test-driven JavaScript я понимаю, что речь идет не просто об особом стиле программирования, но об особых технических принципах для особого фреймворка позволяющего тестировать JS приложения.

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

Внимание: длиннопост.

Далее я буду предполагать, что читатель немного знаком с qUnit и/или Jasmine. Слегка пройтись по верхушкам можно за 10 минут, для этой статьи этого достаточно. Как заведено, на философию и обобщения уходит куда больше времени и энергии. На самом деле, для себя я ответил на все вопросы поставленные в аннотации к этой статье стоило только прочитать это и вспомнить что вообще такое юнит-тесты. Дело в том, что никогда не приходилось заниматься юнит-тестами front-end'a, поэтому я скептически отнесся к такой технологии. Постараюсь ниже изложить основные затруднения. Прошу также не воспринимать статью как манифест за или против юнит-тестирования клиентского кода.

Кроме того, надо помнить, что фреймворки для тестирования отличаются друг от друга. Я постарался избавиться от главных разногласий, но несомненно, что, например, Jasmine дает большую свободу в написании тестопригодного кода, чем qUnit. Тем не менее, описанные в статье затруднения возникают и там, и там.

И так поехали.

Анонимные функции

Первый вопрос был: «Ок, а как тестировать анонимные функции? Что мне делать если мой код сплошные анонимные коллбэки и прочая лямбда-магия?». Официальная документация вполне доступна объяснила что же делать с асинхронными вызовами, но ни одна их них не была анонимна.

var callBack = function(x){	... }
someAsyncFunction(someOptions, callBack);

и

someAsyncFunction(someOptions, function(x){ ... }); 

Несколько разные вещи, согласитесь. А ведь JavaScript приложения полнятся такими конструкциями. Традиционная для jQuery конструкция

$(document).ready(function(){
	// zomg teh lambda application
})

ставящее перед собой цель не допустить выполнение приложения до того, как не будет готов DOM. Или даже вполне себе обычное оборачивание приложения в анонимную функцию, чтобы не захламлять глобальное пространство имен:

(function(){ ... })()

Не очень понятно, как тестировать эти функции, если к ним нет доступа по идентификатору. Если вся их суть заключается в том, чтобы к ним не было доступа. Выходит к ним нужно предоставить доступ.

$(document).ready(Application)

function Application () {
	// application
}

И теперь приложение можно тестировать в qUnit так:

test('Application Constructor is loaded', function () {
  ok(window.Application !== undefined , 'Приложение найдено');  
});

Но есть одна проблема. А что если я не хочу засорять глобальное пространство имен? К сожалению хотя-бы на одно имя придется раскошелиться. Как минимум есть такая практика как Backdoor Manipulation. Нечто похожее в JS можно реализовать через глобальный объект window, в которой можно создать свойство в котором и хранить ссылку на приложение, например:

(function(){
	function Application(){
		//вот тут у нас будет приложение
	}

	window.testBackdoor = { Application : new Application() }; // или просто Application(), ежели это не конструктор
})(window)

//А тестировать это приложение мы будем вот так.
var app = window.testBackdoor.Application;

test('Application Constructor is loaded', function () {
  ok(app !== undefined , 'Приложение загрузилося');  
});

Вот так. Но мы вводим лишнюю конструкцию и это все равно не избавляет нас от лишних имен в объекте window. Это печально. На этой стадии мой внутренний спорщик задает резонный вопрос: собственно, а почему вы тестируете приложение целиком, где же юнит-тесты? Справедливо. Я тестирую приложение целиком. Но ведь именно таковы приложения на JS чаще всего, они (почти) всегда суть одна целая. Никто не станет выделять логику приложения от той части, которая обрабатывает данные, подключать эти модули разными путями, если это и правда не гигантское приложение, которому нужен именно такой подход. Не каждое приложение нуждается в таких вещах как require.JS.

Аргумент #1: Надо делить приложение на разные кусочки-модельки и тестировать их по-отдельности, ведь в этом смысл юнит-тестов. В каждом юните можно иметь свое пространство имен (реализовав весь юнит в отдельной функции) и проблема решается.

Контраргумент #1: JavaScript не ООП язык в классическом понимании, чтобы была возможность тестировать методы класса. Реализация ООП, предоставляющего возможность тестировать отдельные объекты в JavaScipt чревата проблемами чрезмерного кода. (Утверждение, коему я не приведу строгих доказательств, лишь напомню, что в JS отсутствуют public и private ключи. К тому же достаточно вспомнить то во что превращаются милые сердцу классы и их наследования в CoffeeScript при трансляции в JavaScript).

Но раз существует test-driven JavaScript, то существует такой стиль организовать объектную модель приложения так, чтобы ее можно было бы протестировать…

Наследование

Вообще, ООП, прототипное наследование и прочее в JavaScript — это благодатная почва для жаркой полемики, коей не место в этой статье. Но перед тем, как начать исследовать наследование в test-driven контексте, рекомендую почитать Дугласа Крокфорда и Николаса Закаса в «Оптимизаци JavaScript приложений», чтобы быть в курсе положительных и отрицательных сторон тех или иных способов организации наследования в JavaScript.

Тестопригодное наследование пожалуй одна из тех частей, которые приятно удивили меня тем, что не вызвали лишних затруднений, когда я придерживался канонического прототипного наследования на JavaScript. Действительно, если у нас есть доступ к объекту, то мы тотчас же можем получить доступ к его методам и немедленно протестировать их вдоль и поперек. Например я добавляю в прототип какого-то объекта функцию вычисления абсолютного значения числа:

myObject.prototype.abs = function(x){return x>0 ? x : -x;} 
//теперь могу тестировать метод обратившись к нему как myObject.abs

Вся эта простота выходит за счет одной детали, которую многие считают значительным недостатком JavaScript'a. Это то, что у объекта нет private и public свойств. Они все доступны. Таким образом мы опять приходим к тому, что тестировать можно только те функции и методы, к которым осуществлен доступ. Стоит только начать имитировать публичные и приватные свойства объекта, например так:

function myClass (options){
	var privateMethod = function(){ ... }

	var that = {
		publicMethod : function(){ ... }
	}

	return that;
}

Доступ к замыканию publicMethod осуществим, так как возвращается объект, который содержит его в качестве свойства. Проблема в том, что доступ к функции privateMethod извне уже никак не получить, хоть она и занимает в scope функции myClass место, и внутри всех замыканий myClass к нему можно обратиться, но ведь для любых других других scope уровнем выше доступа к функции нет. Возвращаемся к параграфу 1.
Это особо значимо, когда мы пишем приложения где используется традиционный ООП-шаблон, в котором несколько объектов со множеством методов, внутренней логики и прочего, где нет factory-метода плодящего много экземпляров, и их наследованных экземпляров. Короче говоря…

Аргумент #2: Если использовать традиционное для JavaScript прототипное наследование, то вся объектная модель приложения автоматически доступна для тестов без лишних усилий.

Контраргумент #2: Есть тысяча и один способов реализовать наследование (многие из которых весьма популярны) в JavaScript, которые лишают разработчика доступа к методам экземпляра класса.

Проблема реализации наследования и объектного программирования в test-driven JavaScript в том, что одни методы влияют на состояние экземпляра в целом. Как же тестировать, что при определенном событии экземпляр изменил какое-то внутреннее свойство, которое не зависит от других функций, например, фигурка при наведении курсора сменила свой цвет? При условии, что мы доверяем тем фреймворкам с помощью которых реализуем это отображение. А ведь очень многое, что мы делаем с помощью разных фреймворков меняет состояния и только состояния.

К сожалению, идеи юнит-тестирования в одном расходятся с реалиями. Никто (почти никто) не разрабатывает модель, потом пишет JavaScript — сценарий, а потом только делает под это дело GUI, чаще всего все происходит совсем наоборот, программист получает интерфейс приложения и слышит «сделай так, чтобы работало». Я не говорю, что первый случай невозможен или избыточен, напротив — это лучший путь, но увы, front-end — это то, что воспринимается как этап разработки, служащий для отображения работы back-end'а.

Возвращаясь к теме состояний системы, я прихожу к главной проблеме. Предположим, что мы абсолютно доверяем всяким jQuery и уверены, что функция получившая в аргумент строку вида "#000000", будет красить фигурку именно в этот цвет и сделает это без ошибок. Предположим, что фреймворки для работы с DOM, и занимающиеся прочим отображением не столь важно тестировать, как функции, где происходит обработка данных в приложении. Но как отделить одно от другого? Как написать чистые функции, которые и имеет смысл тестировать, не вписывая туда побочные действия?

var a = 10;
function f(x) {
	a = x;
}

Эта функция не чиста, увы. Она принимает данные и меняет состояние системы (значение переменной a).

Чистые функции

Чистые функции — это хорошо. Хорошо, когда функции не меняют состояния системы. Когда функции получают данные, что-то с ними делают и возвращают данные. И больше ничего не делают.
Но в JavaScript есть два нюанса.
Первый: В JavaScript функции — это объекты, а системы — это в общем-то тоже объекты, то есть состояния систем конструируются функциями. Состояния систем — это состояния функций. Нет четкого разделения понятия объект и метод. Все в конечном счете сливается в дзен объектов или функций, если угодно. Иначе говоря, функциями мы конструируем объекты, функциями мы вычисляем данные и передаем их в функции, которые меняют изначальные объекты. (здесь должна быть картинка с Xzibitом и подписью 'Yo dawg')
Второй: Разве суть клиентского JavaScripta не в сайд-эффектах? То есть в отображении, в динамизации. Когда вы в последний раз брали какой-нибудь зверский интеграл Римана чтобы результирующие данные вернуть пользователю в консоль браузера? Я сильно удивлюсь, если кому-то хоть раз приходилось решать такие задачи на стороне клиента, да еще и без отображения. Если в приложении нет сколько-нибудь важной логики, а лишь одно отображение, то по-сути и тестировать здесь нечего.

Вот довольно хрестоматийный пример:
Дан инпут, надо по событию keydown инициировать валидацию содержимого инпута по регулярному выражению. Если введенная строка валидна, то красим инпут в зеленый, иначе в красный.

$("#myPrettyInput").on("keydown", function(){
	if(проводим_здесь_валидацию_инпута){
		... красим инпут в зеленый
	}else{
		... красим инпут в красный
	}
});

Чтобы отделить обработку данных (валидация) от сайд-эффектов (изменение CSS) можно проделать следующий кунштюк:

$("#myPrettyInput").on("keydown", function(){
	var val = $(this).val();
	var regex = ... какое-нибудь регулярное выражение;
	if(validate(val, regex){
		// красим инпут в зеленый
	}else{
		// красим инпут в красный
	}
});

var validate = function(str, rgx){
        // кристально чистой души функция. Возвращает true или false 	
};

С одной стороны, а что плохого? Подумаешь лишнюю функцию написали? Ведь в первом случае, скорее всего в if стояло бы какое-нибудь уродливое выражение типа $(this).val().match(regex).length, а теперь у нас вполне себе опрятная функция. И неважно, что в теле функции то же самое выражение, главное ее можно тестировать! Действительно, на таком упрощенном примере все выглядит вполне оправдано и прилично. Кажется, что мы добились тестопригодности выделением одной лишь функции, но…

Аргумент #3: Мы получили тестопригодный код и в любой момент можем проверить, работает ли наша валидация или нет написав на нее сколь угодно много автоматизированных тестов. Кроме того, мы избавились от гигантских выражений в обработчике событий.

Контраргумент #3: Мы ввели еще одну переменную. А что если валидация не столь проста, а включает в себя ajax вызов? Например, проверка не занят ли юзернейм. И чтобы реализовать разделение сайд-эффектов от строгой обработки данных нужно нечто большее, чем вывести функцию из обработчика событий.

Контрпример:

var validate = function(str, regex){
	if(проводим_здесь_валидацию_инпута_по_regexp){
		$.ajax({
			...
			succsess : function(data){
				// лямбда-функция, да еще и обладающая побочными эффектами 
				// здесь происходит валидация data
			}
		})
	}
	...
}

К сожалению, одной идентификацией коллбэка не обойтись.

var validate = function(str, regex){
	function ajax_validate(data){
		// здесь происходит валидация data
	}

	if(проводим_здесь_валидацию_инпута_по_regexp){
		$.ajax({
			...
			success : function(data){
				if(ajax_validate(data)){
					... какие-то сайд-эффекты
				}
			}
		})
	}
	...
}

Конечно в предыдущем листинге, функция ajax_validate чиста (возвращает результат валидации, а это всегда или true или false), но тем не менее ее никак не протестируешь, потому что — это переменная внутри validate. Сделать ее замыканием validate? Дурацкое решение — от этого validate превращается в класс, перестает быть чистой. Может тогда превратить все строгие проверки, которые возвращают true или false в замыкания какого-нибудь отдельного модуля, который будет занят исключительно всякими валидациями в приложении? С точки зрения тестопригодности — это вполне удовлетворительное решение, с точки зрения модульности тоже. Но надеюсь вы проследили как увеличился объем работы. И я ведь по-сути ничего особого в приложение не добавил. Я спрашиваю себя, где золотая середина?

Кажется логичным ответ на вопрос, что надо ставить обработчики данных в одну кучу, обработчики событий в другую. Так я отказываюсь тестировать ту часть которая красит инпут в нужный цвет, а концентрируюсь на том, чтобы сделать возможным протестировать ту часть программы, где вычисляется в какой цвет красить инпут. Но опять же, с оговоркой, что если приложение не слишком большое/сложное/запутанное. Если уж и правда оно таково, то лучше да, ввести лишний модуль, потратить время, но добиться тестопригодности и удобства дальнейшего использования. А ежели приложение не требует особых ухищрений, я бы поступил так:

$("#myPrettyInput").on("keydown", function(){
	var val = $(this).val();

	if(validate(val,regex)){
		var ajaxValidationResult = false;

		$.ajax({
			...
			success : function(data){
				if(ajax_validate(data)){
					ajaxValidationResult = true; //вот вам и сайд-эффект
				}
			}
			...
		});
		
		setTimeout(function(){
			if(ajaxValidationResult){
				// красим инпут в зеленый
			}else{
				// красим инпут в красный или не красим вовсе, кому как хочется
				setTimeout(arguments.callee, 75);
			}
		})		
	}else{
		//красим инпут в красный
	}
});

//а вот тут я бережно храню свои чистые функции
var validate = function(str, regex){ ... }
var ajax_validate = function(data){ ... }

Для меня это вполне приемлемое отношение лишней работы к пользе от тестопригодности. Но, золотой серединой я это назвать не могу. Какие мои решения? На самом деле никаких универсальных. Внутрь JavaScript встроено много возможностей делать его настолько тестопригодным, насколько вы того пожелаете. Здесь всплывает функциональная природа JavaScript'a. Где можно передавать функции в функции и возвращать тоже функции. Это удобно в том смысле, что логику приложения можно выполнить в довольно свободном декларативном стиле (это так часть, которая не имеет отношения к юнит-тестам), в то время как те части программы, которые отвечают за обработку данных оставить тестопригодными. Я же чаще всего пользуюсь следующей схемой:

function App(){

	function Vehicle(){
		//здесь я храню не тестируемые переменные и методы
		var somePrivateMethod = function(){ ... }

		var that = {
			//здесь я храню тестируемые функции и переменные
			somePublicMethod : function(){ ... } 
		}

		return that;
	}	

	function Car(){
		//так я реализую наследование и взаимосвязь между объектами
		var that = Father();
		//так я расширяю методы класса
		var that.anotherPublicMethod = function(){ ... }

		return that;
	}

	function IndependentModule(){ //независимый модуль, который не используется в других классах
		vat that = {
			independentFunction : function() { ... }
		}
		return that;
	}

	function Factory(){
		var that = {
			car : Car(),
			bicycle : Vehicle(),
		}
		return that;
	}

	window.testBackdoor =  { Factory : Factory(), Module : IndependentModule() };
        //или
	return { Factory : Factory(), Module : IndependentModule() };
}

Таким образом можно получить доступ к замыканиям любого класса. В конце же я могу выбрать способ получения доступа либо через бэкдор либо просто возвращая нужный объект. Однако, такая имитация public/private методов в целом не выполняет саму идею public/private методов, а служит лишь разграничителем между функциями доступными из других scope или нет, что является довольно уродливой надстройкой для того, чтобы обеспечить тестопригодность, а никак не для разделения public и private методов классического ООП. В большей степени таким образом имеет смысл разделять чистые функции и функции с побочными эффектами. К тому же еще надо помнить, что такая организация структуры приложения далеко не самая производительная и больше подходит для больших многосвязных приложений с несколькими модулями, чем для приложений, где используются множественные экземпляры классов.

Возвращаясь к разделению обработки данных и отображения, скажу что некоторые библиотеки принуждают к разделению чистых функций от сайд-эффектов (в основном это функциональные и/или декларативные). Другие же к сожалению не особо поддерживают эту идею. Хотя за то время, что я знаком с JavaScript я убежден, что нет ничего невозможного. Вопрос совсем в другом. Ценой каких усилий?
Например, в этой статье, я тестирую довольно простое приложение (игрушку) и потому как я использую bacon.js — декларативную библиотеку, суть которой избавить разработчика от ада вложенных коллбэков, повешенных на слушатели событий, то я в целом смог без лишних потерь привести приложение к тестопригодному виду.

Резюме:
К сожалению, ничего более вразумительного, чем довольно банальное «каждый инструмент уместен в своем месте» я не могу сказать. Я лишь несколько раз использовал qUnit и Jasmine в тестировании клиентского кода для более или менее крупных приложений. Но это были те случаи, когда я еще не написав ни строчки кода уже предчувствовал, как именно в этом приложении я смогу разделить сайд-эффекты от обработки данных, и что если я буду писать тестопригодный код, то приложение не превратиться в невразумительный фарш.

Напоследок, хотел бы сказать, что сама идея юнит-тестирования хорошо сходится с модульной сущностью NodeJS. Пихать в эту статью еще и test-driven программирование на платформе NodeJS было бы лишним, хотя тема определенно волнующая. Поэтому если кому-нибудь будет интересно, я напишу об этом в следующей статье.

Автор: wombtromb

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js