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

в 13:19, , рубрики: best practices, jasmine, javascript, метки: , ,

В промежутке времени между переквалификацией с 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;

Автор: serjoga

Источник

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


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