Практическое руководство по тестированию пар с Sinon, Mocha и Chai

Как написать тест, в котором вы не можете (или решили не использовать) реальные зависимости того, что вы тестируете? Какие у вас есть возможности в JavaScript? А что дает вам библиотека тестовых двойников такого, чего не может дать простой старый js? Хотите узнать? Читать дальше!

Тестируемая система

Предположим, у нас есть ситуация, аналогичная описанной дядей Бобом Мартином в Маленьком насмешнике [1].

У нас есть `Система`:

‘use strict’;
/**
 * @constructor
 * @param {Authoriser} authoriser
 */
function System(authoriser) {
    
    /** @private */
    var login_count = 0;
    /** @public */
    this.login = function(username, password) {
        if (authoriser.authorise(username, password)) {
            ++login_count;
        }
    };
    /** @public */
    this.loginCount: function() {
        return login_count;
    };
}

который делегирует процесс входа пользователей в систему `Авторизатору`, реализация которого может выглядеть примерно так:

‘use strict’;
/** @constructor */
function Authoriser() { }
/**
 * @public
 *
 * @param {String} username
 * @param {String} password
 *
 * @returns {Boolean}
 */
Authoriser.prototype.authorise = function(username, password) {
    // connects to LDAP or some database, verifies user credentials
    // returns true if the username/password pair is valid,
    // false otherwise
    // ...
};

Манекен

Как мы можем доказать, что вновь созданная `Система` не имеет зарегистрированных пользователей? И нужно ли нам вообще для этого создавать настоящий объект `Authoriser`?

Поскольку мы знаем, что при вызове system.loginCount даже не затрагивается объект authoriser, мы можем заменить authoriser на пустышка:

describe(‘A newly created System’, function() {
    var dummy_authoriser = { };
    it(‘has no logged in users’, function() {
        var system = new System(dummy_authoriser);
        expect(system.loginCount()).to.equal(0);
    });
});

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

Заглушка

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

Конечно, вы можете сказать, что можете просто войти, но вы уже проверили, что вход работает, и, поскольку процесс входа в систему требует времени, зачем делать это дважды? Кроме того, если есть ошибка при входе в систему, ваш тест тоже не пройдет! Это ненужное связывание, которого можно легко избежать с помощью заглушки:

describe(‘A System with a logged in user’, function() {
    var accepting_authoriser = { authorise: function() { 
      return true; 
    } };
    it(‘has a loginCount of 1’, function() {
        var system = new System(accepting_authoriser);
        system.login(‘bob’, ‘SecretPassword’);
        expect(system.loginCount()).to.equal(1);
    });
});

Так что же делает этот заглушенный `accept_authoriser`? Он возвращает "true", поэтому принимает любые пары имя пользователя / пароль.

Если вы теперь хотите протестировать другую часть вашей системы, которая обрабатывает неавторизованных пользователей, вы можете создать rejecting_authoriser следующим образом:

var rejecting_authoriser = { authorise: function() { 
  return false; 
} };

Так что это заглушка, она просто возвращает значение и не имеет никакой другой логики.

Заглушки предоставляют стандартные ответы на звонки, сделанные во время теста, обычно не отвечая ни на что, кроме того, что запрограммировано для теста - Мартин Фаулер, Test Double

Красивый! Так красиво и просто, не правда ли? Всего одна строчка кода! Разве утка печатает не блестяще? Представьте, сколько еще кода вам пришлось бы написать, если бы вы использовали Java или C #! Вам, вероятно, придется сначала создать интерфейс `Authoriser`, затем убедиться, что и реальная реализация, и ваша заглушка` реализуют` этот интерфейс, а затем ...

Ах да, `interface` ... У нас нет этой конструкции в JavaScript, не так ли?

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

Каковы могут быть последствия этого, спросите вы? Что ж, я видел, как проходили огромные наборы тестов, хотя приложение, которое они должны были тестировать, было в корне сломано, так как половина кода вообще больше не существовала ... Поверьте мне, вы не хотите быть там [2]: -)

Да, это не круто, но можем ли мы сделать что-то лучше? Да мы можем! Войдите в sinon.js! [3]

Как тогда будет выглядеть `acceptpting_authoriser` с помощью sinon?

describe(‘A System with a logged in user’, function() {
    
    var accepting_authoriser = sinon.createStubInstance(Authoriser);
    accepting_authoriser.authorise.returns(true);
    it(‘has a loginCount of 1’, function() {
        var system = new System(accepting_authoriser);
        system.login(‘bob’, ‘SecretPassword’);
        expect(system.loginCount()).to.equal(1);
    });
});

А что отличает его? Вызов sinon.createStubInstance (Authoriser) создает объект-заглушку, который позволяет исключить только те методы, которые существуют в прототипе исходного Authoriser.

Это важно, потому что это означает, что если метод `# authorise` с радостью решит изменить свое имя на #isAuthorised` однажды, вы получите TypeError в вашем тесте, именно там, где вы пытаетесь заглушить уже не существующий `# authorise`. Круто, правда?

Шпион

Вы помните, как я сказал в начале, что Система делегирует процесс входа пользователей в систему Авторизатору? Как мы можем проверить, действительно ли имеет место это взаимодействие? Что нам нужно сделать, так это шпионить за вызывающим, чтобы увидеть изнутри работу алгоритма, который мы тестируем - и именно для этого нужны шпионы [4 ]:

describe(‘A System’, function() {
    var accepting_authoriser_spy = {
        authorise_was_called: false,
        authorise: function() {
            this.authorise_was_called = true;
            return true;
        }
    }
    afterEach(function() {
        accepting_authoriser_spy.authorise_was_called = false
    };
    it(‘delegates logging users in to the authoriser’, function() {
        var system = new System(accepting_authoriser_spy);
        system.login(‘bob’, ‘SecretPassword’);
        expect(accepting_authoriser_spy.authorise_was_called).
          to.equal(true);
    });
});

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

Шпионы - это заглушки, которые также записывают некоторую информацию в зависимости от того, как их называли. - Мартин Фаулер, Тестовый дубль »

Также следует отметить, что поскольку шпион поддерживает состояние (в данном примере флаг «authorise_was_called`), его необходимо сбрасывать между тестами, чтобы один тестовый пример не влиял на другой - это делается в теге afterEach block.

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

describe(‘A System’, function() {
    var accepting_authoriser = sinon.createStubInstance(Authoriser);
    accepting_authoriser.authorise.returns(true);
    afterEach(function() {
        accepting_authoriser.authorise.restore();
    });
    it(‘delegates logging users in to the authoriser’, function() {
        var system = new System(accepting_authoriser);
        system.login(‘bob’, ‘SecretPassword’);
        expect(accepting_authoriser.authorise).to.have.been.called;
    });
});

Как и в примере с заглушкой, здесь я использую sinon.createStubInstance.

Однако есть одно существенное различие между нашей ручной реализацией шпиона и приведенной выше: sinon spy сам по себе не является основным объектом, который вы вводите, это оболочка вокруг метода объекта. Вот почему я ввожу `accept_authoriser`, но выполняю утверждение для шпиона ` accept_authoriser.authorise` и использую `accept_authoriser.authorise.restore ()` сбросить его.

Кроме того, тот факт, что sinon spy является оболочкой, означает, что если вы хотите шпионить за настоящим методом авторизации, вы можете сделать это следующим образом:

var authoriser    = new Authoriser(),
    system        = new System(authoriser),
    authorise_spy = sinon.spy(authoriser, ‘authorise’);
system.login(‘bob’, ‘SecretPassword’);
expect(authorise_spy).to.have.been.called;

Издевательство

Мок не так заинтересован в возвращаемых значениях функций.
Его больше интересует, какая функция была вызвана, с какими аргументами, когда и как часто.
- Дядя Боб Роберт 'Мартин, Маленький пересмешник »

Еще одна вещь, которая отличает mock [5] от заглушки, - это метод `verify`, который группирует все утверждения и выполняет их в конце теста, проверяя поведение нашей Системы:

describe(‘A System’, function() {
    var accepting_authoriser_mock = {
        
        authorise_was_called: false,
        authorise: function() {
            this.authorise_was_called = true;
            return true;
        },
        verify: function() {
            expect(this.authorise_was_called).to.equal(true);
        }
    };
    it(‘delegates logging users in to the authoriser’, function() {
        var system = new System(accepting_authoriser_mock);
        system.login(‘bob’, ‘SecretPassword’);
        // note: assertions moved to #verify
        accepting_authoriser_mock.verify(); 
    });
});

Теперь, когда мы понимаем, как работают mocks, и знаем, как их создавать сами, давайте посмотрим, как sinon может упростить задачу. Однако есть важное различие, которое нам нужно сделать, прежде чем погрузиться в моки sinon:

«В Sinon основная единица подделки - это функции, всегда функции. Таким образом, «объект-заглушка» в литературе по Java-типу переводится как «функция / метод-заглушка» в мире Sinon / JavaScript ».
- Кристиан Йохансен, «Test Spies, Stubs and Mocks, Part 1.5»

… И то же самое касается издевательств sinon. Хорошо, но что это значит для вас? Давайте сначала посмотрим на различия в реализации:

describe(‘A System’, function() {
    var authoriser = new Authoriser(),
    mock = sinon.mock(authoriser);
    afterEach(function() {
        mock.restore();
    });
    it(‘delegates logging users in to the authoriser’, function() {
        // prepare
        var system = new System(authoriser);
        // expect
        mock.expects(‘authorise’).once().returns(true);
        // act
        system.login(‘bob’, ‘SecretPassword’);
        // assert
        mock.verify();
    });
});

Здесь важно отметить, что mock sinon создается как оболочка вокруг экземпляра реального Authoriser, но вместо макета мы внедряем уже упомянутый реальный экземпляр в System.

Эта стратегия может быть проблематичной, если создание вашего зависимого объекта (Authoriser в нашем примере) стоит дорого; В этом случае вы можете не вызывать конструктор напрямую, используя Object.create [6]:

var authoriser = Object.create(Authoriser.prototype),
    mock = sinon.mock(authoriser);

Примечание. На момент написания sinon.js (версия 1.10.3) не предоставляет никакого эквивалента `createStubInstance` для моков.

Подделка

У фейка есть деловое поведение. Вы можете заставить подделку вести себя по-разному, передавая ей разные данные. - Роберт Дядя Боб Мартин, Маленький пересмешник »

Предположим, мы хотим определить несколько персонажей, на которых наша «Система» по-разному реагирует. Допустим, «боб» знает свой пароль, а «алиса» его забыла; Я лично рекомендую использовать две отдельные заглушки, но давайте поговорим о подделках, так как некоторые могут предпочесть их использовать:

describe(‘A System’, function() {
    var fake_authoriser = {
            authorise: function(username, password) {
                return (username === ‘bob’ && 
                        password === ‘SecretPassword’);
            }
        },
        system = new System(fake_authoriser);
    it(‘allows Bob in’, function() {
        system.login(‘bob’, ‘SecretPassword’);
        expect(system.loginCount()).to.equal(1);
    });
    it(‘does not allow Alice in’, function() {
        system.login(‘alice’, ‘password’);
        expect(system.loginCount()).to.equal(0);
    });
});

… И вы также можете добиться того же результата с помощью sinon:

describe(‘A System’, function () {
    var fake_authoriser = sinon.createStubInstance(Authoriser);
        fake_authoriser.authorise
            .returns(false)
            .withArgs(‘bob’, ‘SecretPassword’).returns(true),
    var system = new System(fake_authoriser);
    it(‘allows Bob in’, function () {
        system.login(‘bob’, ‘SecretPassword’);
        expect(system.loginCount()).to.equal(1);
    });
    it(‘does not allow Alice in’, function () {
        system.login(‘alice’, ‘password’);
        expect(system.loginCount()).to.equal(0);
    });
});

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

Инструменты

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

  • Mocha - популярный фреймворк для тестирования JavaScript.
  • Sinon.js - библиотека тестовых пар
  • Chai.js и его библиотека утверждений bdd-style
  • Sinon-chai - утверждения sinon.js для chai

Заключительные мысли и предупреждение

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

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

Сноски

  1. Маленький пересмешник - это сообщение в блоге дяди Боба Мартина. Как вы уже могли заметить, сообщение дяди Боба очень вдохновило на создание этой статьи!
  2. С другой стороны, если вы уже находитесь в этой ситуации выходите на связь, я могу помочь вам выйти из нее! Хорошо, больше никакого дерзкого маркетинга. Вернуться к статье, быстро, быстро:
  3. Sinon.js, написанный Кристианом Йохансеном, автором этой прекрасной книги
  4. Чтобы лучше понять, что такое шпионы и другие тестовые двойники и как ими пользоваться, настоятельно рекомендую прочитать эту книгу
  5. Концепция фиктивных объектов была первоначально представлена ​​Тимом Маккинноном, Стивом Фриманом и Филипом Крейгом в их статье Эндотестирование: модульное тестирование с использованием имитирующих объектов. Стив Фриман - соавтор книги GOOSE. Опять же, очень рекомендуется.
  6. Object.create хорошо документирован на MDN

Ян Молак выступает на конференциях, проводит учебные курсы и семинары, а также помогает организациям часто и надежно предоставлять ценное высококачественное программное обеспечение, внедряя эффективные инженерные практики. Свяжитесь с нами чтобы узнать больше!

Понравилась статья? Возможно, вам понравятся мои другие статьи и руководства!

Также нажмите ниже, чтобы другие люди увидели эту статью здесь, на Medium.