Pull to refresh

Пишем тестопригодный javascript

Reading time7 min
Views13K
Original author: Ben Cherry
[Прим. перев.]: предлагаю вашему вниманию перевод статьи Бена Черри, в прошлом разработчика Twitter. В этой статье он приводит несколько советов по написанию javascript кода, пригодного для тестирования.

Культура разработки в Twitter требует написания тестов. У меня не было опыта тестирования Javascript до работы в Twitter, поэтому мне пришлось многому научиться. В частности, некоторые шаблоны программирования, которые я привык применять, о которых я писал и призывал к их использованию, оказались непригодными для тестирования. Поэтому я подумал, что стоит поделиться некоторыми наиболее важными принципами, которые я разработал для написания тестопригодного Javascript кода. Примеры, которые я привожу, основаны на QUnit, но могут быть применены к любому фреймворку для тестирования Javascript'а.

Избегайте синглтонов


Один из моих наиболее популярных постов был о том, как использовать javascript шаблон «Модуль» для создания синглтонов в вашем приложении. Этот подход может быть простым и полезным, но он создает проблемы для тестирования по одной простой причине: синглтон загрязняет состояние объекта между тестами. Вместо синглтона в виде модуля, следует создавать его как конструируемый объект и присваивать его экземпляру глобального уровня во время инициализации вашего приложения.

Для примера рассмотрим следующий синглтон модуль (пример, конечно, выдуманный):

var dataStore = (function() {
	var data = [];
	return {
		push: function (item) {
			data.push(item);
		},
		pop: function() {
			return data.pop();
		},
		length: function() {
			return data.length;
		}
	};
}());

В этом мы модуле можем захотеть протестировать некоторые методы. Вот пример простого QUnit теста:

module("dataStore");
test("pop", function() {
	dataStore.push("foo");
	dataStore.push("bar")
	equal(dataStore.pop(), "bar", "popping returns the most-recently pushed item");
});

test("length", function() {
	dataStore.push("foo");
	equal(dataStore.length(), 1, "adding 1 item makes the length 1");
});

Во время выполнения данного набора тестов, проверка метода «length» будет провалена, но глядя на нее, не становится ясно почему. Проблема в том, что состояние объекта dataStore сохранилось после предыдущего теста. Простое изменение порядка тестов сделает так, что оба теста пройдут проверку, что является очевидным признаком того, что что-то сделано неправильно. Мы можем исправить это, возвращая состояние объекта dataStore перед каждым тестом, но это означает что нам придется постоянно поддерживать шаблон для тестирования, если мы будем вносить изменения в модуль. Лучше использовать другой подход:

function newDataStore() {
	var data = [];
	return {
		push: function (item) {
			data.push(item);
		},
		pop: function() {
			return data.pop();
		},
		length: function() {
			return data.length;
		}
	};
}

var dataStore = newDataStore();

Теперь набор тестов выглядит так:

module("dataStore");
test("pop", function() {
	var dataStore = newDataStore();
	dataStore.push("foo");
	dataStore.push("bar")
	equal(dataStore.pop(), "bar", "popping returns the most-recently pushed item");
});

test("length", function() {
	var dataStore = newDataStore();
	dataStore.push("foo");
	equal(dataStore.length(), 1, "adding 1 item makes the length 1");
});

Такой подход позволяет нашему глобальному объекту вести себя, точно так же, как он делал это ранее, при этом тесты не будут засорять друг друга. Каждый тест имеет свой собственный экземпляр объекта dataStore, который будет уничтожен сборщиком мусора после выполнения теста.

Избегайте создания приватных свойств с помощью замыканий

Другой продвигаемый мною паттерн — это создание по-настоящему приватных свойств в Javascript. Преимущество данного метода заключается в том, что вы можете сохранять глобальное пространство имен свободным от ненужных ссылок на скрытые детали реализации. Как бы то ни было, злоупотребление этим шаблоном программирования может привести к непригодности кода для тестирования. Причиной этого явления является то, что ваш набор тестов не имеет доступа к приватным функциям, спрятанным в замыкания, а значит и не может их протестировать. Рассмотрим пример:

function Templater() {
	function supplant(str, params) {
		for (var prop in params) {
			str.split("{" + prop +"}").join(params[prop]);
		}
		return str;
	}

	var templates = {};

	this.defineTemplate = function(name, template) {
		templates[name] = template;
	};

	this.render = function(name, params) {
		if (typeof templates[name] !== "string") {
			throw "Template " + name + " not found!";
		}

		return supplant(templates[name], params);
	};
}

Ключевым методом в объекте Templater является «supplant», но мы не имеем к нему доступа вне замыкания функции. Таким образом, мы не можем проверить, работает ли он так, как планировалось. Кроме того, мы не можем проверить, делает ли что-нибудь наш метод «defineTemplate», не вызывая метод «render». Мы можем добавить метод «getTemplate()», но тогда получится, что мы добавили метод в публичный интерфейс исключительно в целях тестирование, что не является хорошим подходом. В такой ситуации построение сложных объектов с важными приватными методами приведет к тому, что придется полагаться на нетестируемый код, что является довольно опасным. Ниже приведен пример тестопригодной версии данного объекта:

function Templater() {
	this._templates = {};
}

Templater.prototype = {
	_supplant: function(str, params) {
		for (var prop in params) {
			str.split("{" + prop +"}").join(params[prop]);
		}
		return str;
	},
	render: function(name, params) {
		if (typeof this._templates[name] !== "string") {
			throw "Template " + name + " not found!";
		}

		return this._supplant(this._templates[name], params);
	},
	defineTemplate: function(name, template) {
		this._templates[name] = template;
	}
};


А вот набор QUnit тестов для него:

module("Templater");
test("_supplant", function() {
	var templater = new Templater();
	equal(templater._supplant("{foo}", {foo: "bar"}), "bar"))
	equal(templater._supplant("foo {bar}", {bar: "baz"}), "foo baz"));
});

test("defineTemplate", function() {
	var templater = new Templater();
	templater.defineTemplate("foo", "{foo}");
	equal(template._templates.foo, "{foo}");
});

test("render", function() {
	var templater = new Templater();
	templater.defineTemplate("hello", "hello {world}!");
	equal(templater.render("hello", {world: "internet"}), "hello internet!");
});

Обратите внимание на то, что наш тест для метода «render» призван всего лишь удостовериться, что методы «defineTemplate» и «supplant» корректно дополняют друг друга. Мы уже протестировали их отдельно друг от друга, и это позволит нам легко понять, какой из компонентов работает неправильно, если тест не будет пройден успешно.

Пишите связанные функции

Связанные функции важны в любом языке, но Javascript имеет собственные причины на это. Многое из того, что вы делаете с помощью Javascript использует глобальные объекты, предоставляемые средой, на которые опираются ваши наборы тестов. К примеру, тестирование функции, которая изменяет URL будет затруднено, так как все методы будут связаны с window.location. Вместо этого вы должны разбить вашу систему на логические компоненты, с помощью которых будут приниматься решения, что делать дальше, а затем написать короткие функции, которые будут выполнять это. Вы сможете протестировать логические функции на разных входящих и исходящих данных, и оставить функцию, которая изменяет window.location непротестированной. При условии, что вы составили вашу систему правильно, данный подход будет безопасен.

Вот пример нетестопригодного кода:

function redirectTo(url) {
	if (url.charAt(0) === "#") {
		window.location.hash = url;
	} else if (url.charAt(0) === "/") {
		window.location.pathname = url;
	} else {
		window.location.href = url;
	}
}

Логика в данном примере довольно проста, но мы можем представить себе и более сложную ситуацию. С ростом сложности, мы уже не сможем протестировать этот метод без редиректа окна браузера.

А вот хорошая версия:

function _getRedirectPart(url) {
	if (url.charAt(0) === "#") {
		return "hash";
	} else if (url.charAt(0) === "/") {
		return "pathname";
	} else {
		return "href";
	}
}

function redirectTo(url) {
	window.location[_getRedirectPart(url)] = url;
}

А теперь мы можем написать простой набор тестов для "_getRedirectPart":

test("_getRedirectPart", function() {
	equal(_getRedirectPart("#foo"), "hash");
	equal(_getRedirectPart("/foo"), "pathname");
	equal(_getRedirectPart("http://foo.com"), "href");
});

В данном случае основная часть метода «redirectTo» будет протестирована, и мы сможем не беспокоиться о случайных редиректах.

Пишите много тестов

Это нелегкая задача, но очень важно всегда помнить об этом. Многие программисты пишут слишком мало тестов, потому что их написание является трудозатратным занятием и отнимает много времени. Я постоянно страдаю от этой проблемы, и поэтому я написал небольшой хелпер для QUnit, который сделает процесс написание тестов немного проще. Это функция «testCases()», которую вы можете вызвать в пределах тестируемогого блока, передав ей функцию, контекст выполнения и массив входных/выходных данных для сравнения. С ее помощью вы сможете легко создать набор тестов.

function testCases(fn, context, tests) {
	for (var i = 0; i < tests.length; i++) {
		same(fn.apply(context, tests[i][0]), tests[i][1],
			tests[i][2] || JSON.stringify(tests[i]));
	}
}

Пример использования:

test("foo", function() {
	testCases(foo, null, [
		[["bar", "baz"], "barbaz"],
		[["bar", "bar"], "barbar", "a passing test"]
	]);
});


Выводы

Можно еще очень много написать о тестировании Javascript, и я уверен, существует множество хороших книг на эту тему, но я надеюсь, что я дал неплохой обзор практических примеров, с которыми я сталкиваюсь в ежедневной работе. Я не являюсь экспертом в тестировании, поэтому дайте знать, если я сделал какую-то ошибку или дал плохой совет.
Tags:
Hubs:
Total votes 30: ↑22 and ↓8+14
Comments17

Articles