...

воскресенье, 29 декабря 2013 г.

Jasmine DRY: а ты правильно пишешь тесты?

В промежутке времени между переквалификацией с Back-end программиста на Front-end, мне пришлось иногда код для RoR приложения (да-да и тесты были). Интересным для меня показалась своеобразная атмосфера сообщества рубистов, которые очень строго относятся к написанию кода и если ты пишешь плохой код, то тебе могут поломать пальцы не простить. Ведь код должен быть максимально простым и читабельным.

Это же правило применимо и к тестам (как по мне то, они должны быть на порядок проще чем сам код). В дополнение, в тестах есть свое золотое правило — One Expectation per Test. Не нужно писать кучу expect/assert/should вызовов в одном тесте, просто перестаньте это делать! И не забывайте, что тесты это тоже код, а copy-paste — плохая практика.



Что такое плохой тест




Разбираясь в 3.0 версии Knockout.js, я решил посмотреть тесты в надежде разобраться как найти хоть какое-то упоминание о новом свойстве after внутри байндингов. Честно говоря, меня возмутила сложность написанных тестов.
Плохой тест


describe('Binding: Checked', function() {
beforeEach(jasmine.prepareTestNode);

it('Triggering a click should toggle a checkbox\'s checked state before the event handler fires', function() {
testNode.innerHTML = "<input type='checkbox' />";
var clickHandlerFireCount = 0, expectedCheckedStateInHandler;
ko.utils.registerEventHandler(testNode.childNodes[0], "click", function() {
clickHandlerFireCount++;
expect(testNode.childNodes[0].checked).toEqual(expectedCheckedStateInHandler);
})
expect(testNode.childNodes[0].checked).toEqual(false);
expectedCheckedStateInHandler = true;
ko.utils.triggerEvent(testNode.childNodes[0], "click");
expect(testNode.childNodes[0].checked).toEqual(true);
expect(clickHandlerFireCount).toEqual(1);

expectedCheckedStateInHandler = false;
ko.utils.triggerEvent(testNode.childNodes[0], "click");
expect(testNode.childNodes[0].checked).toEqual(false);
expect(clickHandlerFireCount).toEqual(2);
});
});







Если не учитывать, что все директивы (describe и it) являются частью спеки, то потом невозможно понять смысл теста из заголовка (it triggering a click should...). Получается ведь бред, как в заголовке так и в самом тесте.

Вот список вопросов, которые помогают мне создавать понятные и простые тесты:



  1. Какие тестовые данные?

  2. Какой контекст тестирования?

  3. Какие кейсы нужно покрыть?

  4. Как можно сгруппировать эти кейсы?




Для выше приведенного примера:


  1. Поле ввода checkbox

  2. Пользователь жмет на checkbox

  3. Кейсы:

    1. Состояние меняется до вызова обработчика клика

    2. Состояние меняется в отмеченный, если checkbox был не отмечен

    3. Состояние меняется в не отмеченный, если checkbox был отмечен




Теперь все то же самое только на английском jasmine-ском:


Просто читаемый тест


describe('Binding: Checked', function() {
beforeEach(jasmine.prepareTestNode);

describe("when user clicks on checkbox", function () {
beforeEach(function () {
testNode.innerHTML = "<input type='checkbox' />";
this.checkbox = testNode.childNodes[0];
this.stateHandler = jasmine.createSpy("checked handler");

this.checkbox.checked = false;
ko.utils.registerEventHandler(this.checkbox, "click", function() {
this.stateHandler(this.checkbox.checked);
}.bind(this));
ko.utils.triggerEvent(this.checkbox, "click");
})

it ("changes state before event handler is triggered", function () {
expect(this.stateHandler).toHaveBeenCalledWith(true);
})

it ("marks checkbox if it's not marked", function () {
expect(this.checkbox.checked).toBe(true)
})

it ("unmarks checkbox if it's marked", function () {
this.checkbox.checked = true;
ko.utils.triggerEvent(this.checkbox, "click");
expect(this.checkbox.checked).toBe(false);
})
})
})







Setup — сложный, тесты — простые. Идеальный вариант — это тест в котором находится один вызов ф-ции expect.

Меньше кода, больше тестов




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

Давайте представим, что существует JavaScript++, в котором есть 2 класса (Array и Set) с общим интерфейсом (size и contains). Теперь нужно покрыть их тестами, без дублирования кода! Определим общие тесты для наших коллекций:



sharedExamplesFor("collection", function () {
beforeEach(function () {
this.sourceItems = [1,2,3];
this.collection = new this.describedClass(this.sourceItems);
})

it ("returns proper size", function () {
expect(this.collection.size()).toBe(this.sourceItems.length);
})

// another specs

it ("returns true if contains item", function () {
expect(this.collection.contains(this.sourceItems[0])).toBe(true);
})
})




По аналогии к Rspec, хотелось бы иметь возможность подключать спеки при помощи одного из методов:


  • itBehavesLike — выполняет тесты во вложенном контексте

  • itShouldBehaveLike — выполняет тесты во вложенном контексте

  • includeExamples — выполняет тесты в текущем контексте

  • includeExamplesFor — выполняет тесты в текущем контексте




Note: itShouldBehaveLike и includeExamplesFor — существуют только для улучшения читаемость тестов

// array_spec.js
describe("Array", function () {
beforeEach(function () {
this.describedClass = Array;
})

itBehavesLike("collection");
//another specs
})

// set_spec.js
describe("Set", function () {
beforeEach(function () {
this.describedClass = Set;
})

itBehavesLike("collection");
//another specs
});




Еще я себе обычно создаю ф-ция context (элиас для describe) для улучшения читабельности спек.
Исходный код реализации shared spec


// spec_helper.js
var sharedExamples = {};

window.sharedExamplesFor = function (name, executor) {
sharedExamples[name] = executor;
};

window.itBehavesLike = function (sharedExampleName) {
jasmine.getEnv().describe("behaves like " + sharedExampleName, sharedExamples[sharedExampleName]);
};

window.includeExamplesFor = function (sharedExampleName) {
var suite = jasmine.getEnv().currentSuite;
sharedExamples[sharedExampleName].call(suite);
};

window.context = window.describe;
window.includeExamples = window.includeExamplesFor;
window.itShouldBehaveLike = window.itBehavesLike;





This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers.


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

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