Несмотря на то, что три элемента тестирования Javascript - мокко, синон и чай - весьма эффективны, из-за того, что каждая библиотека à la carte, может усложнить получение надежного контроля над сильными сторонами каждой из них. Этот пост продолжает серию тестов по Javascript (часть 1, часть 2), рассматривая, как тестировать отдельные блоки кода. Обратите внимание, что предполагается понимание материала, описанного в предыдущих сообщениях.

Sinon имеет довольно много функциональных возможностей, но основные три вещи, с которыми взаимодействуют проекты, - это заглушки, шпионы и имитаторы. Прежде чем рассматривать их подробно, стоит рассмотреть проблему, которую решает Синон.

Выявление зависимостей

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

Например, служба может иметь несколько общедоступных методов, которые могут вызывать соавторы. Представьте, если бы один из этих методов службы вызвал другую службу или базу данных.

class FooService {
  getFoo(fooId) {
    return db.where({ id: fooId });
  }
}

Эти призывы к «другим вещам» - это зависимости. В этом случае db считается зависимостью от FooService.

Понимая это, Sinon.js - это в первую очередь инструмент для имитации ответа зависимостей или наблюдения за взаимодействиями с зависимостями. Например, при тестировании FooService можно было либо подделать ответ db.where, либо наблюдать взаимодействия с db из теста FooService. Давайте посмотрим, как это делается.

Изоляция зависимостей

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

Одиночные зависимости

Один из наиболее распространенных подходов - инициализировать зависимость один раз, экспортировать ее, а затем импортировать или требовать ее на протяжении всего проекта.

// db.js
const db = new DatabaseClient();
export default db;
// fooService.js
import db from './db';
class FooService {
  getFoo(fooId) {
    return db.where({ id: fooId });
  }
}

С этой структурой теперь можно подделывать или наблюдать db из тестов, просто импортировав ее. Это работает, потому что узел оцениваетdb.js только один раз, что означает, что каждый раз, когда он импортируется, const db ссылается на один и тот же экземпляр. Поскольку ссылки идентичны, любые заглушки или слежение, выполняемые db в рамках теста, будут влиять на тот же db экземпляр, который используется при запуске кода.

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

Инициализированные зависимости свойств

Другой подход к изоляции зависимостей - их инициализация в конструкторе класса.

// fooService.js
class FooService {
  constructor(someArg) {
    this.db = new DatabaseClient(someArg);
  }
getFoo(fooId) {
    return this.db.where({ id: fooId });
  }
}

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

Зависимости строительства от времени

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

// fooService.js
class FooService {
  constructor(db) {
    this._db = db;
  }
getFoo(fooId) {
    return this._db.where({ id: fooId });
  }
}

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

Наблюдение за взаимодействием со шпионами

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

Снова рассмотрим чрезмерно упрощенноеFooService:

class FooService {
  getFoo(fooId) {
    return db.where({ id: fooId });
  }
}

Вполне вероятно, что db - это надежная библиотека с открытым исходным кодом, поддерживаемая сообществом, которая ежедневно используется буквально тысячами производственных проектов. С точки зрения тестирования может быть достаточно сказать: «Если я позвоню db.where с правильными аргументами, я могу разумно предположить, что он поступит правильно». С этой точки зрения единственное, что стоит тестировать, - это то, что делает остальная часть кода, а не то, что делает db.

it('invokes the database', async function() {
  const subject = new FooService();
  sinon.spy(subject.db, 'where');
    
  await subject.getFoo(1234);
  const dbArgs = db.where.getCall(0).args[0];
  expect(dbArgs.id).to.eql(1234);
});

Первый аргумент spy - это объект, на котором существует отслеживаемый метод, а второй аргумент - это строковое имя этого метода.

Во всем приведенном выше фрагменте .spy(...) этот код говорит: «Каждый раз, когда вызывается db.where, записывайте аргументы, и я проверю их позже». Следующий код .getCall(0) извлекает вызов, в данном случае по индексу, получая первый вызов. getCall возвращает объект, описывающий вызов, из которого .args имеет аргументы, которые были переданы при вызове зависимости. Затем, используя аргументы, можно сделать ожидание на основе того, что требуется.

ПРЕДУПРЕЖДЕНИЕ: шпионы проходят вызов, позволяя запускать исходное поведение. В приведенном выше примере будет вызываться db.where и запускать фактическое поведение db.where!

Подделка ответов с заглушками

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

it('returns records from the database', async function() {
  const recordInDb = { id: 1234, name: 'bar' };
  const subject = new FooService();
  sinon.stub(subject.db, 'where')
    .returns(Promise.resolve([recordInDb]));
  const result = await subject.getFoo(1234);
  expect(result).to.deep.eql(recordInDb);
});

Подобно шпионам, заглушки вызываются с двумя аргументами: объектом зависимости и строковым именем метода, за которым следует шпионить. Однако, в отличие от шпионов, существуют дополнительные методы, которые необходимо связать при использовании заглушки вместо шпиона. В приведенном выше примере .returns определяет поведение заглушки при ее вызове.

Весь бит кода sinon.stub(...).returns(...) приведет к тому, что значение в returns всегда будет возвращаться всякий раз, когда вызывается db.where. Поскольку возвращаемое значение зависимости становится известным во время тестирования, ожидания теста могут быть конкретными.

Требование аргументов

Приведенный выше пример теста хорош тем, что он проверяет только поведение метода getFoo, однако в нем есть небольшой пробел: вызов subject.getFoo(1234) приведет к тому же ответу, что и вызов subject.getFoo(5678). Это связано с тем, что заглушка была настроена так, чтобы всегда возвращать одну и ту же запись, независимо от того, какие аргументы ей были переданы.

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

sinon.stub(subject.db, 'where')
  .withArgs({ id: 1234 })
  .returns(Promise.resolve([recordInDb]));

Вызов subject.getFoo(5678) теперь завершится ошибкой, а subject.getFoo(1234) продолжит выполнение. Это связано с тем, что аргументы, передаваемые db.where({ id: fooId }), когда он вызывается из FooService, будут соответствовать значению в withArgs.

Заглушки также поддерживают несколько пар withArgs / return.

const stub = sinon.stub(subject.db, 'where');
stub.withArgs({ id: 1234 }).returns(Promise.resolve([record1]));
stub.withArgs({ id: 5678 }).returns(Promise.resolve([record2]));

Обратите внимание, что sinon.stub вызывается только один раз. Заглушка заглушки - ошибка.

Счетчик звонков

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

sinon.stub(subject.db, 'where').onCall(3).returns(...);

Понимание заглушек

Теперь должно стать яснее, что заглушки - это просто новые функции, возвращающие заданное им значение.

const func1 = sinon.stub().returns(5);
const func2 = () => 5;

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

const func1 = sinon.stub().withArgs('a').returns(5);
const func2 = (name) => {
  if (name === 'a') {
    return 5;
  }
  return null;
};

Нечеткое соответствие

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

class UserController {
  getFriends(req, res, next) {
    const friends = friendsService.getFriendsForUser(req.user);
    res.json(friends);
  }
}

В этом фрагменте user, вероятно, является довольно сложным объектом, который может иметь дополнительные свойства, которые не являются проблемой при написании теста.

sinon.stub(friendsService, 'getFriendsForUser')
  .withArgs({ ...super complex user object... })
  .returns(Promise.resolve(friends));

Было бы плохим тоном пытаться проверить соответствие всего объекта пользователя. Если к user когда-либо было добавлено дополнительное несвязанное свойство, то наличие очень конкретного описания этого объекта привело бы к сбою этого теста. Представьте, что добавление одного свойства привело бы к сбою третьей части набора тестов. Это не идеально.

При тестировании поведения getFriends основная проблема может заключаться в том, что ожидаемым является только user.id. Для модульного теста лучше использовать только те части аргументов, которые нужны тесту, и игнорировать все остальное. Sinon предлагает match, чтобы справиться с этой ситуацией.

sinon.stub(friendsService, 'getFriendsForUser')
  .withArgs(sinon.match({ id: expectedId }))
  .returns(Promise.resolve(friends));

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

Совет: будьте готовы к обещаниям

У Sinon есть несколько маленьких хитростей, которые упрощают работу с ней. Поскольку написание .returns(Promise.resolve(value)) очень распространено при работе с обещаниями, вместо этого можно использовать сокращение .resolves(value). Это ускоряет чтение и написание коротких заметок, и это отличная привычка.

Заглушки против шпионов

Когда использовать шпиона, а когда использовать заглушку - это вопрос, который может легко озадачить новичков Синон. Хотя шпионы и заглушки кажутся разными по своему предназначению, и так оно и есть, стоит помнить, что заглушки имеют все функции, которые есть у шпионов. Однако, как правило, лучше отдавать предпочтение шпионам для наблюдения и заглушкам для манипуляций.

Используйте шпиона, когда…

  • Достаточно просто знать, что произошло взаимодействие
  • Необходима глубокая проверка или манипулирование аргументами, данными зависимости.

Используйте заглушку, когда…

  • Возврат функции является частью ожидания и зависит от зависимости
  • Базовое поведение не должно выполняться

Проверка вызовов с помощью моков

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

class FooService {
  constructor(id) {
    this.id = id;
    this.value = 0;
  }
  
  increment(persist) {
    this.value++;
    if (persist) {
      db.update({ id: this.id, value: this.value });
    }
    return this.value;
  }
}

Неправильный тест для этого может выглядеть примерно так.

it('persists the value to the db', function() {
  sinon.stub(db, 'update')
    .withArgs({ id: 1234, value: 1 })
    .resolves(true);
const subject = new CounterService();
  const result = subject.increment(1234, 5);
  expect(result).to.eql(6);
});

Этот тест должен завершиться неудачно, но это не так. Ошибка, которую db.update на самом деле никогда не называют. Это важная вещь, которую нужно знать о заглушках:

ПРИМЕЧАНИЕ. Для определения заглушки не требуется ее вызывать.

Чтобы решить эту проблему, в Sinon есть функция под названием mocks. Моки - это заглушки + сразу высказанные ожидания. Вышеупомянутый тест можно отредактировать, чтобы использовать макет.

it('persists the value to the db', function() {
  sinon.mock(db)
    .expects('update')
    .withArgs({ id: 1234, value: 1 })
    .resolves(true);
const subject = new CounterService();
  const result = subject.increment(1234, 5);
  expect(result).to.eql(6);
});

Этот новый тест завершится ошибкой, потому что db.update не вызывается. Основное различие между заглушкой и макетом заключается в том, как они настроены.

// stub
sinon.stub(db, 'update')
// mock
sinon.mock(db).expects('update')

Другие методы построения, такие как .withArgs, returns и resolves, работают одинаково между ними двумя. Основное отличие состоит в том, что макет требует, чтобы метод был вызван как часть этого тестового прогона, тогда как заглушка только позволяет это.

Советы по улучшению модульных тестов с Sinon

Сделайте нормальную заглушку по умолчанию в блоках BeforeEach

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

Избегайте насмешек перед каждым

Поскольку моки приходят с ожиданиями, это приводит к путанице в тестах, когда блок beforeEach содержит ожидания. Как правило, заглушки и шпионы только в блоке beforeEach.

Песочница для более чистых тестов

Sinon включает функцию песочницы. Вместо вызова sinon.stub или sinon.spy используются sandbox.stub и sandbox.spy. Основное преимущество здесь состоит в том, что при однократном вызове sandbox.restore все заглушенное, имитируемое и шпионское поведение будут отменены. Можно настроить тесты так, чтобы они всегда вызывали sandbox.restore в блоке afterEach, чтобы заглушки и шпионы не просачивались между тестами.

Шпионы + Chai.js

Для Chai.js есть отличный пакет, который обеспечивает синтаксис ожидания для шпионов под названием sinon-chai. Хотя официально он не является частью пакета sinon, это отличное дополнение.

Мы нанимаем!

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