Моки — JS: Продвинутое тестирование
В тестировании очень популярен мокинг. Технически он похож на стабинг, и из-за этого их часто путают (специально или ненамеренно). Но все же они служат разным целям и используются в разных ситуациях. Разберемся, что это такое, и когда он нужен.
До этого момента мы рассматривали побочные эффекты как помеху тестирования нашей логики. Для их изоляции использовались стабы или прямое выключение логики в тестовой среде. После этого можно было спокойно проверять правильность работы функции.
В некоторых ситуациях требуется кое-что другое. Не результат работы функции, а то, что она выполняет нужное нам действие, например, шлет правильный HTTP-запрос с правильными параметрами. Для этого понадобятся моки. Моки проверяют, как выполняется код.
HTTP
import nock from 'nock'; import getPrivateForkNames > from '../src.js'; // Предотвращение случайных запросов nock.disableNetConnect(); test('getPrivateForkNames', async () => // Полное название домена const scope = nock('https://api.github.com') // Полный путь .get('/orgs/hexlet/repos/?private=true') .reply(200, [ fork: true, name: 'one' >, fork: false, name: 'two' >]); await getPrivateForkNames('hexlet'); // Метод `scope.isDone()` возвращает `true` только тогда, // когда соответствующий запрос был выполнен внутри `getPrivateForkNames` expect(scope.isDone()).toBe(true); >);
Это и называется мокинг. Мок проверяет, что какой-то код выполнился определенным образом. Это может быть вызов функции, HTTP-запрос и тому подобное. Задача мока убедиться в том, что это произошло, и в том, как конкретно это произошло, например, что в функцию были переданы конкретные данные.
Что дает нам такая проверка? В данном случае — мало что. Да, мы убеждаемся, что вызов был, но само по себе это еще ни о чем не говорит. Так когда же полезны моки?
Представьте, что мы бы разрабатывали библиотеку @octokit/rest, ту самую, что выполняет запросы к GitHub API. Вся суть этой библиотеки в том, чтобы выполнить правильные запросы с правильными параметрами. Поэтому там нужно обязательно проверять выполнение запросов с указанием точных URL-адресов. Только в таком случае можно быть уверенными, что она выполняет верные запросы.
В этом ключевое отличие мока от стаба. Стаб устраняет побочный эффект, чтобы не мешать проверке результата работы кода, например, возврату данных из функции. Мок фокусируется на том, как конкретно работает код, что он делает внутри. При этом чисто технически мок и стаб создаются одинаково, за исключением того, что на мок вешают ожидания, проверяющие вызовы. Это приводит к путанице, потому что часто моками называют стабы. С этим ничего уже не поделать, но для себя всегда пытайтесь понять, о чем идет речь. Это важно, от этого зависит фокус тестов.
Функции
Моки довольно часто используют с функциями (методами). К примеру, они могут проверять:
- Что функция была вызвана
- Сколько раз она была вызвана
- Какие аргументы мы использовали
- Сколько аргументов было передано в функцию
- Что вернула функция
Предположим, что мы хотим протестировать функцию forEach . Она вызывает колбек для каждого элемента коллекции:
[1, 2, 3].forEach((v) => console.log(v)); // или проще [1, 2, 3].forEach(console.log)
Эта функция ничего не возвращает, поэтому напрямую ее не протестировать. Можно попробовать сделать это с помощью моков. Проверим, что она вызывает переданный колбек и передает туда нужные значения.
Так как мы изучаем Jest, то для создания моков воспользуемся встроенным механизмом Jest. В других фреймворках могут быть свои встроенные механизмы. Кроме того, как мы убедились выше, существуют специализированные библиотеки для моков и стабов.
test('forEach', () => // Моки функций в Jest создаются с помощью функции jest.fn // Она возвращает функцию, которая запоминает все свои вызовы и переданные аргументы // Потом это используется для проверок const callback = jest.fn(); [1, 2, 3].forEach(callback); // Теперь проверяем, что она была вызвана с правильными аргументами нужное количество раз expect(callback.mock.calls).toHaveLength(3); // Первый аргумент первого вызова expect(callback.mock.calls[0][0]).toBe(1); // Первый аргумент второго вызова expect(callback.mock.calls[1][0]).toBe(2); // Первый аргумент третьего вызова expect(callback.mock.calls[2][0]).toBe(3); >);
С помощью моков мы проверили, что функция была вызвана ровно три раза, и ей, последовательно для каждого вызова, передавался новый элемент коллекции. В принципе, можно сказать, что этот тест действительно проверяет работоспособность функции forEach() . Но можно ли сделать это проще, без мока и без завязки на внутреннее поведение? Оказывается, можно. Для этого достаточно использовать замыкание:
test('forEach', () => const result = []; const numbers = [1, 2, 3]; numbers.forEach((x) => result.push(x)); expect(result).toEqual(numbers); >);
Объекты
Jest позволяет создавать моки и для объектов. Они создаются с помощью функции jest.spyOn() , напоминающей уже известную нам jest.fn() . Эта функция принимает на вход объект и имя метода в этом объекте, и отслеживает вызовы этого метода. Отследим, в качестве примера, вызов console.log() :
test('logging something', () => const spy = jest.spyOn(console, 'log'); console.log(12); expect(spy).toHaveBeenCalled(); // => true, т.к. метод log был вызван expect(spy).toHaveBeenCalledTimes(1); // => true, т.к. метод был вызван 1 раз expect(spy).toHaveBeenCalledWith(12); // true, т.к. log был вызван с аргументом 12 expect(spy.mock.calls[0][0]).toBe(12); // проверка, идентичная предыдущей >);
Кроме того, Jest позволяет создавать свою реализацию сбора данных о вызовах отслеживаемой функции при помощи метода mockImplementation(fn) . Колбэком этого метода будет функция, которая выполняется после каждого вызова отслеживаемой функции. Возьмем предыдущий пример, но теперь соберем аргументы каждого вызова console.log() в отдельный массив:
test('logging something', () => const logCalls = []; // Здесь функция внутри mockImplementation принимает на вход аргумент(ы), // с которым был вызван console.log, и сохраняет его в заранее созданный массив const spy = jest.spyOn(console, 'log').mockImplementation((. args) => logCalls.push(args)); console.log('one'); console.log('two'); expect(logCalls.join(' ')).toBe('one two'); >);
Преимущества и недостатки
Несмотря на то, что существуют ситуации, в которых моки нужны, в большинстве ситуаций их нужно избегать. Моки слишком много знают о том, как работает код. Любой тест с моками из черного ящика превращается в белый ящик. Повсеместное использование моков приводит к двум вещам:
- После рефакторинга приходится переписывать тесты (много тестов!), даже если код работает правильно. Происходит это из-за завязки на то, как конкретно работает код
Открыть доступ
Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно
- 130 курсов, 2000+ часов теории
- 1000 практических заданий в браузере
- 360 000 студентов
Наши выпускники работают в компаниях:
Когда использовать mocks в юнит-тестировании
Использование моков в модульном тестировании является спорной темой. Автор оригинала заметил, что на протяжении всей своей карьеры в программировании он сначала перешел от моков почти над каждой зависимостью к политике «без моков», а затем к «только моки для внешних зависимостей».
Ни одна из этих практик не является достаточно хорошей. В этой статье Владимир Хориков покажет, какие зависимости следует мокать, а какие использовать как есть в тестах.
Что такое mock (мок, от англ. «пародия», «имитация»)?
Прежде чем перейти к теме того, когда использовать моки, давайте обсудим, что такое мок. Люди часто используют термины тестовый двойник (test double) и мок (mock) как синонимы, но технически это не так:
- Тестовый двойник — это всеобъемлющий термин, который описывает все виды фальшивых (fake) зависимостей, непригодных к использованию в конечном продукте (non-production-ready), в тестах. Такая зависимость выглядит и ведет себя как ее аналог, предназначенный для production, но на самом деле является упрощенной версией, которая снижает сложность и облегчает тестирование. Этот термин был введен Джерардом Месаросом в его книге «xUnit Test Patterns: Refactoring Test Code». Само название происходит от понятия дублера в кино.
- Мок – это лишь один из видов таких зависимостей.
Согласно Жерару Месарошу, существует 5 видов тестовых двойников:
- Пустышка (dummy)
- Стаб (stub)
- Шпион (spy)
- Мок (mock)
- Фейк (fake)
Такое разнообразие может показаться пугающим, но на самом деле все они могут быть сгруппированы всего в два типа: моки и стабы.

Разница между этими двумя типами сводится к следующему:
- Моки помогают имитировать и изучать исходящие (outcoming) взаимодействия. То есть вызовы, совершаемые тестируемой системой (SUT) к ее зависимостям для изменения их состояния.
- Стабы помогают имитировать входящие (incoming) взаимодействия. То есть вызовы, совершаемые SUT к ее зависимостям для получения входных данных.
Например, отправка электронной почты является исходящим (outcoming) взаимодействием: это взаимодействие приводит к побочному эффекту на SMTP-сервере. Тестовый двойник, имитирующий такое взаимодействие, — это мок.
Извлечение данных из БД является входящим (incoming) взаимодействием — оно не приводит к побочному эффекту. Соответствующий тестовый двойник является стабом.

Все остальные различия между пятью типами тестовых двойников являются незначительными деталями реализации:
- Spies (шпионы) выполняют ту же роль, что и моки. Отличие в том, что spies пишутся вручную, а моки создаются с помощью готовых инструментов. Иногда spies называют рукописными моками.
С другой стороны, разница между стабами, dummy (пустышками) и фейками заключается в том, насколько они умны:
- Dummy — это простое, жестко закодированное значение, такое как null значение или выдуманная строка. Он используется для удовлетворения сигнатуры метода SUT и не участвует в получении конечного результата.
- Стаб посложнее. Это полноценная зависимость, которую вы настраиваете для возврата разных значений для разных сценариев.
- Фейк — это то же самое, что и стаб для большинства целей. Разница заключается в причинах его создания: фейк обычно используется для замены еще не существующей зависимости.
Обратите внимание на разницу между моками и стабами (помимо исходящих и входящих взаимодействий). Моки помогают эмулировать и изучать взаимодействия между SUT и его зависимостями, в то время как стабы помогают только эмулировать эти взаимодействия. Это важное различие.
Мок-как-инструмент vs. мок-как-тестовый-двойник
Термин мок перегружен и может означать разные вещи в разных обстоятельствах. В статье уже упоминалось, что люди часто используют этот термин для обозначения любого тестового двойника, тогда как моки — это всего лишь подмножество тестовых двойников.
Но у термина мок есть и другое значение. Вы также можете ссылаться на классы из библиотек (для создания моков) как на моки. Эти классы помогают вам создавать настоящие моки, но сами по себе они не являются моками как таковыми:
[Fact] public void Sending_a_greetings_email() < // Using a mock-the-tool to create a mock-the-test-double var mock = new Mock(); var sut = new Controller(mock.Object); sut.GreetUser("user@email.com"); // Examining the call from the SUT to the test double mock.Verify( x => x.SendGreetingsEmail("user@email.com"), Times.Once); >
В этом тесте используется класс Mock из библиотеки Moq. Этот класс представляет собой инструмент, который позволяет вам создавать тестовый двойник — мок. Другими словами, класс Mock (или Mock ) является мок-как-инструмент, в то время как экземпляр этого класса является мок-как-тестовый-двойник.
Важно не смешивать мок-как-инструмент с мок-как-тестовый-двойник, потому что вы можете использовать мок-как-инструмент для создания обоих типов тестовых двойников: моков и стабов.
Вот еще один пример теста, в котором используется класс Mock. Экземпляр этого класса — стаб, а не мок:
[Fact] public void Creating_a_report() < // Using a mock-the-tool to create a stub var stub = new Mock(); // Setting up a canned answer stub.Setup(x => x.GetNumberOfUsers()).Returns(10); var sut = new Controller(stub.Object); Report report = sut.CreateReport(); Assert.Equal(10, report.NumberOfUsers); >
Этот тестовый двойник имитирует входящее взаимодействие – вызов, который предоставляет SUT входные данные. С другой стороны, в предыдущем примере вызов SendGreetingsEmail() является исходящим взаимодействием. Его единственная цель — вызвать побочный эффект — отправить электронное письмо.
Не проверяйте взаимодействия со стабами
Как уже упоминал выше, моки помогают эмулировать и изучать исходящие взаимодействия между SUT и его зависимостями, в то время как стабы помогают только эмулировать входящие взаимодействия, а не изучать их.
Из этого следует, что вы никогда не должны проверять взаимодействие со стабами. Вызов от SUT к стабу не является частью конечного результата, который выдает SUT. Такой вызов — это всего лишь средство для получения конечного результата; это деталь реализации. Проверка взаимодействий со стабами является распространенным анти-паттерном, который приводит к хрупким тестам.
Единственный способ избежать хрупкости тестов — это заставить эти тесты проверять конечный результат (который в идеале должен иметь значение для непрограммиста), а не детали реализации.
В приведенных выше примерах проверка
mock.Verify(x => x.SendGreetingsEmail("user@email.com"))
соответствует фактическому результату, и этот результат имеет значение для специалиста в предметной области: отправка приветственного электронного письма — это то, что такой специалист хотели бы, чтобы система делала.
В то же время вызов GetNumberOfUsers() вообще не является результатом. Это внутренняя деталь реализации, касающаяся того, как SUT собирает данные, необходимые для создания отчета. Следовательно, проверка этого вызова приведет к уязвимости теста. Неважно, как SUT генерирует конечный результат, если этот результат правильный.
Вот пример такого хрупкого теста:
[Fact] public void Creating_a_report() < var stub = new Mock(); stub.Setup(x => x.GetNumberOfUsers()).Returns(10); var sut = new Controller(stub.Object); Report report = sut.CreateReport(); Assert.Equal(10, report.NumberOfUsers); // Asserting an interaction with a stub stub.Verify( x => x.GetNumberOfUsers(), Times.Once);
Такая практика проверки того, что не является частью конечного результата, также называется чрезмерной спецификацией.
Моки — это более сложная тема: не все их использования приводят к уязвимости теста, но многие из них делают это. Вы скоро поймете почему.
Совместное использование моков и стабов
Иногда нужно создать тестовый двойник, который проявляет свойства как мока, так и стаба:
[Fact] public void Purchase_fails_when_not_enough_inventory() < var storeMock = new Mock(); // Setting up a canned answer storeMock .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5)) .Returns(false); var sut = new Customer(); bool success = sut.Purchase(storeMock.Object, Product.Shampoo, 5); Assert.False(success); // Examining a call from the SUT to the mock storeMock.Verify( x => x.RemoveInventory(Product.Shampoo, 5), Times.Never); >
Этот тест использует storeMock для двух целей: он возвращает шаблонный ответ и проверяет вызов метода, сделанный SUT.
Однако обратите внимание, что это два разных метода: тест устанавливает ответ от HasEnoughInventory(), но затем проверяет вызов RemoveInventory(). Таким образом, здесь не нарушается правило не проверять взаимодействия со стабами.
Когда тестовый двойник является одновременно и моком, и стабом, он все равно называется моком. Это в основном потому, что нужно выбрать одно имя, но также и потому, что являться моком — более важный факт.
Mocks vs. stubs и commands vs. queries
Понятие моков и стабов связано с принципом command-query separation (CQS). Принцип CQS гласит, что каждый метод должен быть либо командой, либо запросом, но не обоими:
- Команды — это методы, которые вызывают побочные эффекты и не возвращают никакого значения. Примеры побочных эффектов включают изменение состояния объекта, изменение файла в файловой системе и т. д.
- Запросы противоположны этому — они не имеют побочных эффектов и возвращают значение.
Другими словами, задавая вопрос, вы не должны менять ответ. Код, который поддерживает такое четкое разделение, становится легче для чтения. Тестовые двойники, заменяющие команды, становятся моками. Аналогично, тестовые двойники, заменяющие запросы, являются стабами:

Посмотрите еще раз на два теста из предыдущих примеров:
var mock = new Mock(); mock.Verify(x => x.SendGreetingsEmail("user@email.com")); var stub = new Mock(); stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
SendGreetingsEmail() — это команда, побочным эффектом которой является отправка электронного письма. Тестовый двойник, который заменяет эту команду, является моком.
С другой стороны, GetNumberOfUsers() — это запрос, который возвращает значение и не изменяет состояние базы данных. Соответствующий тестовый двойник — стаб.
Когда мокать?
Разобравшись со всеми этими определениями, давайте поговорим о том, когда вам следует использовать моки.
Очевидно, что вы не хотите мокать саму тестируемую систему (SUT), поэтому вопрос «Когда мокать?» сводится к следующему: «Какие типы зависимостей вы должны заменять на моки, а какие использовать в тестах?»
Вот все типы зависимостей модульного тестирования, которые автор оригинала перечислил в своей предыдущей статье:

- Совместная зависимость (shared dependency) — это зависимость, которая является общей между тестами и с помощью нее тесты могут влиять на результаты друг друга.
- Приватная зависимость (private dependency) — это любая зависимость, которая не является совместной.
Совместная зависимость соответствует изменяемой внепроцессорной зависимости (mutable out-of-process dependency) в подавляющем большинстве случаев, поэтому автор оригинала использует здесь эти два понятия как синонимы. (Ознакомьтесь с предыдущим постом Владимира Хорикова, чтобы узнать больше: Unit Testing Dependencies: The Complete Guide.)
Существуют две школы модульного тестирования с собственными взглядами на то, какие типы зависимостей следует заменять на моки:

- Лондонская школа (также известная как школа mockist) выступает за замену всех изменяемых зависимостей на моки.
- Классическая школа (также известная как школа Детройта) выступает за замену только общих (изменяемых внепроцессорных) зависимостей.
Обе школы ошибаются в своем отношении к мокам, хотя классическая школа меньше, чем лондонская.
Моки и неизменяемые внепроцессорные зависимости.
А как насчет иммутабельных внепроцессорных (immutable out-of-process) зависимостей? Разве их не стоит мокать, по крайней мере, по мнению одной из школ? Неизменяемые внепроцессорные зависимости (например, служба API только для чтения) следует заменить тестовым двойником, но этот тестовый двойник будет стабом, а не моком.
Это опять же из-за различий между моками и стабами:
· Моки предназначены для исходящих взаимодействий (команд) — взаимодействий, которые оставляют побочный эффект в зависимости.
· Стабы предназначены для входящих взаимодействий (запросов) — взаимодействий, которые не оставляют побочных эффектов в зависимости.
Взаимодействия с иммутабельными внепроцессорными зависимостями по определению являются входящими и, следовательно, не должны проверяться в тестах, а только заменяться шаблонными ответами (обе школы с этим согласны).
Сначала Владимир Хориков опишет, почему лондонская школа ошибочна, а затем — почему классический подход тоже неверен.
Не мокайте все изменяемые зависимости
Не стоит мокать все изменяемые зависимости. Чтобы понять, почему, нам нужно рассмотреть два типа коммуникаций в типичном приложении: внутрисистемный и межсистемный.

- Внутрисистемные (Intra-system) коммуникации — это коммуникации между классами внутри вашего приложения.
- Межсистемные (Inter-system) коммуникации — это когда ваше приложение взаимодействует с другими приложениями.
Между ними существует огромная разница: внутрисистемные коммуникации являются деталями реализации; межсистемные коммуникации — нет.
Внутрисистемные коммуникации являются деталями реализации, поскольку взаимодействие, через которое проходят ваши доменные классы для выполнения операции, не является частью их наблюдаемого поведения. Это взаимодействие не имеет непосредственной связи с целью клиента. Таким образом, связь с таким взаимодействием приводит к хрупким тестам.
Межсистемные коммуникации — это совсем другое дело. В отличие от взаимодействия между классами внутри вашего приложения, то, как ваша система взаимодействует с внешним миром, формирует наблюдаемое поведение этой системы в целом. Это часть контракта, который ваше приложение должно соблюдать в любое время.

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

Итак, лондонская школа ошибается, потому что она поощряет использование моков для всех изменяемых зависимостей и не проводит различия между внутрисистемными (внутрипроцессорными) и межсистемными (внепроцессорными) коммуникациями.
В результате тесты проверяют связь между классами точно так же, как они проверяют связь между вашим приложением и внешними системами. Это неизбирательное использование моков является причиной того, что следование лондонской школе часто приводит к хрупким тестам — тестам, которые связаны с деталями реализации.
Не мокайте все внепроцессорные зависимости
Классическая школа лучше справляется с этим вопросом, потому что она выступает за замену только внепроцессорных зависимостей, таких как служба SMTP, шина сообщений и т. д. Но классическая школа также не идеальна по отношению к межсистемным коммуникациям. Эта школа также поощряет чрезмерное использование моков, хотя и не так сильно, как лондонская школа.
Не все внепроцессорные зависимости следует мокать. Если внепроцессорная зависимость доступна только через ваше приложение, то связь с такой зависимостью не является частью наблюдаемого поведения вашей системы. Внепроцессорная зависимость, которая не может наблюдаться извне, по сути, действует как часть вашего приложения. Связи с такой зависимостью становятся деталями реализации: они не должны оставаться неизменными после рефакторинга и, следовательно, не должны проверяться с помощью моков.

Помните, что требование всегда сохранять схему взаимодействия между вашим приложением и внешними системами проистекает из необходимости поддерживать обратную совместимость. Вы должны поддерживать способ взаимодействия вашего приложения с внешними системами. Это потому, что вы не можете изменять эти внешние системы одновременно с вашим приложением; они могут следовать другому циклу развертывания, или вы можете просто не контролировать их.
Но когда ваше приложение действует как прокси-сервер для внешней системы, и ни один клиент не может получить к ней прямой доступ, требование обратной совместимости исчезает. Теперь вы можете развернуть свое приложение вместе с этой внешней системой, и это не повлияет на клиентов. Схема взаимодействия с такой системой становится деталью реализации.
Хорошим примером здесь является БД приложения: БД, которая используется только вашим приложением. Ни одна внешняя система не имеет доступа к этой БД. Таким образом, вы можете изменить схему взаимодействия между вашей системой и БД приложения любым удобным вам способом, если это не ломает существующую функциональность. Поскольку эта БД полностью скрыта от глаз клиентов, вы даже можете заменить ее совершенно другим механизмом хранения, и никто этого не заметит.
Использование моков для внепроцессорных зависимостей, которые вы полностью контролируете, также приводит к хрупким тестам. Вы не хотите, чтобы ваши тесты становились красными каждый раз, когда вы разбиваете таблицу в БД или изменяете тип одного из параметров в хранимой процедуре. БД и ваше приложение должны рассматриваться как одна система.
Это различие разделяет внепроцессорные зависимости на две подкатегории:
- Управляемые (managed) зависимости — внепроцессорные зависимости, над которыми вы имеете полный контроль. Эти зависимости доступны только через ваше приложение; взаимодействия с ними не видны внешнему миру. Типичным примером является БД приложения. Внешние системы не имеют прямого доступа к вашей БД; они делают это через API, предоставляемый вашим приложением.
- Неуправляемые (unmanaged) зависимости — внепроцессорные зависимости, над которыми у вас нет полного контроля. Взаимодействия с такими зависимостями наблюдаются извне. Примеры — SMTP-сервер и шина сообщений: оба они создают побочные эффекты, видимые для других приложений.
Только неуправляемые зависимости должны быть заменены моками. Используйте реальные экземпляры управляемых зависимостей в тестах.

Резюме
Тестовый двойник — это всеобъемлющий термин, который описывает все виды непригодных к использованию в конечном продукте (non-production-ready), фейковых зависимостей в тестах.
- Существует пять вариантов тестовых двойников — dummy (манекен), стаб, spy (шпион), мок и фейк, которые можно сгруппировать всего в два типа: моки и стабы.
- Spies функционально такие же, как и моки; dummy и фейки выполняют ту же роль, что и стабы.
Различия между моками и стабами:
- Моки помогают имитировать и изучать исходящие взаимодействия: вызовы от SUT к его зависимостям, которые изменяют состояние этих зависимостей.
- Стабы помогают имитировать входящие взаимодействия: для получения входных данных SUT обращается к своим зависимостям.
- Проверка взаимодействия со стабами всегда приводит к хрупким тестам.
- Тестовые двойники, заменяющие команды CQS, являются моками. Тестовые двойники, заменяющие запросы CQS, являются стабами.
Мок-как-инструмент — это класс из библиотеки моков, который вы можете использовать для создания мока-как-тестовый-двойник или стаба.
Внепроцессорные зависимости можно разделить на 2 подкатегории: управляемые и неуправляемые зависимости.
- Управляемые зависимости — это внепроцессорные зависимости, доступные только через ваше приложение. Взаимодействия с управляемыми зависимостями не наблюдаются извне. Типичным примером является БД приложения.
- Неуправляемые зависимости — это внепроцессорные зависимости, к которым имеют доступ другие приложения. Взаимодействия с неуправляемыми зависимостями наблюдаются извне. Типичные примеры — SMTP-сервер и шина сообщений.
- Связь с управляемыми зависимостями — это детали реализации; связь с неуправляемыми зависимостями является частью наблюдаемого поведения вашей системы.
- Используйте реальные экземпляры управляемых зависимостей в интеграционных тестах; замените неуправляемые зависимости моками.
- и
- автоматическое тестирование
- юнит-тесты
- юнит-тестирование
- моки
- стабы
- Тестирование IT-систем
- Программирование
- Анализ и проектирование систем
- Проектирование и рефакторинг
- Тестирование веб-сервисов
Фиктивные объекты и данные, моки, стабы
Эффективно тестируем функции с внешними зависимостями.
Время чтения: 8 мин
Открыть/закрыть навигацию по статье
- Кратко
- Фиктивные объекты
- Стабы
- Мо́ки
- Шпионы
- Тестовые данные и инфраструктура
- Чем проще — тем лучше
Обновлено 19 июля 2022
При тестировании нам часто приходится заменять настоящие объекты «заглушками», чтобы тесты были проще и прямолинейнее. В этой статье мы рассмотрим разные виды таких «заглушек», когда и какие использовать и как сделать работу с ними удобнее.
В этой статье мы говорим о тестировании. Если вы не знакомы с тестами, мы рекомендуем сперва прочитать статью «Как и зачем писать тесты», а потом вернуться сюда.
Кратко
Среди фиктивных объектов можно выделить две группы: моки и стабы.
Стабы (англ. stub) заменяют объекты, но сами ничего не проверяют. Их реализация простая, а зачастую — даже ничего не делает вовсе. Стабы нужны, чтобы заменить собой зависимость в системе и упростить окружение для тестов.
Моки (англ. mock) тоже заменяют зависимости, но при этом позволяют проверять предположения. Они могут следить за вызовами методов, аргументами этих вызовов и т. д. Моки удобны при тестировании функций с побочными эффектами.
Фиктивные объекты можно представить как беговые дорожки, которые заменяют собой настоящий большой парк. Только стабы — это дорожки попроще: крутящаяся лента без дисплея и настроек; а моки — дорожки, которые следят за темпом, ускорением, сердцебиением и т.д.
И стабы, и моки помогают избежать «постройки целого парка» при тестировании, но стабы нужны лишь для того, чтобы просто было, где бегать, а моки — чтобы знать, как именно проходила пробежка.
Фиктивные объекты
Когда мы пишем тесты, мы проверяем предположения о работе функций. Если тестируемая функция чистая, зависит только от переданных аргументов, то и проверка предположения будет простой:
function add(a, b) return a + b>function add(a, b) return a + b >
Чтобы проверить работу функции add ( ) из примера выше, достаточно вызвать её с подготовленными аргументами и сравнить результат с ожидаемым:
describe('when called with `a` and `b`', () => it('returns the sum of those numbers', () => const result = add(40, 2) expect(result).toEqual(42) >)>)describe('when called with `a` and `b`', () => it('returns the sum of those numbers', () => const result = add(40, 2) expect(result).toEqual(42) >) >)
Но если функция зависит не только от аргументов, но ещё от внешнего мира (то есть у неё есть побочные эффекты), то проверить её работу становится сложнее:
function addRandom(a) return a + Math.random()>function addRandom(a) return a + Math.random() >
Чтобы проверить add Random ( ) , нам нужно знать случайное число, которое вернёт Math . random ( ) . Это непрактично.
Также сложно проверить результат, если функция ничего не возвращает, а меняет окружение или другие объекты. Например, проверить функцию toggle Theme ( ) так в принципе не получится:
function toggleTheme() ourSuperApp.toggleClassName('dark-theme') ourSuperApp.userChangedTheme = true>function toggleTheme() ourSuperApp.toggleClassName('dark-theme') ourSuperApp.userChangedTheme = true >
Для проверки подобных функций удобнее всего использовать фиктивные объекты.
Фиктивные объекты — объекты-заменители зависимостей для тестируемых функций. Они реализуют те же интерфейсы, что и настоящие зависимости, но сильно проще в реализации.
Среди фиктивных объектов можно выделить две группы: стабы и моки.
Стабы
Хороший тест должен быть быстрым, изолированным и воспроизводимым. Чтобы выполнить эти требования фиктивные объекты должны быть максимально простыми. Стабы как раз такие.
Они предоставляют интерфейс объекта, который заменяют, но их реализация гораздо проще. Часто — их методы не делают никаких вычислений, а сразу возвращают нужное значение.
const realMath = Object.create(global.Math) const mathStub = random: () => 0.42>const realMath = Object.create(global.Math) const mathStub = random: () => 0.42 >
Объект math Stub в примере выше предоставляет метод random ( ) , но возвращает не случайное число, а конкретное значение. Если мы подменим настоящий Math на math Stub , метод random ( ) будет возвращать всегда то число, которое нам нужно:
// Подменяем настоящий Math на стаб:beforeEach(() => global.Math = mathStub>) afterEach(() => global.Math = realMath>) // Проверяем:describe('when called with a number `x`', () => it('should return the sum of that `x` and a random number', () => const result = addRandom(2) expect(result).toEqual(2.42) >)>)// Подменяем настоящий Math на стаб: beforeEach(() => global.Math = mathStub >) afterEach(() => global.Math = realMath >) // Проверяем: describe('when called with a number `x`', () => it('should return the sum of that `x` and a random number', () => const result = addRandom(2) expect(result).toEqual(2.42) >) >)
Задача стаба — избавить нас от подготовки и работы с настоящей зависимостью. Это экономит время, когда тестируемая функция зависит от сложных настраиваемых объектов.
Чтобы не приводить настоящий объект в нужное состояние, мы «подмешиваем» заменитель, который имитирует такое состояние. Для тестируемой функции ничего не меняется, но тест становится проще и короче.
Мо́ки
У моков задача чуть шире, чем у стабов. Они не только заменяют зависимость функции, но ещё и следят, как функция эту зависимость использует.
Если тестируемая функция не возвращает результат, единственный способ проверить её работу — посмотреть, как она повлияла на окружение. Моки следят за изменениями и позволяют сравнить новое состояние с ожидаемым.
Вспомним функцию toggle Theme ( ) :
function toggleTheme() ourSuperApp.toggleClassName('dark-theme') ourSuperApp.userChangedTheme = true>function toggleTheme() ourSuperApp.toggleClassName('dark-theme') ourSuperApp.userChangedTheme = true >
Для конечного пользователя её задача выглядит как:
Но с точки зрения самой функции её задача — вызвать метод toggle Class Name ( ) на объекте our Super App и поменять значение поля user Changed Theme . Именно это и можно проверить с помощью моков:
// Создаём мок для объекта приложения:const fakeApp = toggleClassName: jest.fn(), userChangedTheme: false> // Подменяем приложение на мок:beforeEach(() => global.ourSuperApp = fakeApp>) // Проверяем. describe('when called', () => toggleTheme() // . что вызван нужный метод с ожидаемым аргументом: it('should call the theme toggler with a correct class name', () => expect(fakeApp.toggleClassName).toHaveBeenCalledWith('dark-theme') >) // . что значение поля стало ожидаемым: it('should toggle the changed theme flag', () => expect(fakeApp.userChangedTheme).toEqual(true) >)>)// Создаём мок для объекта приложения: const fakeApp = toggleClassName: jest.fn(), userChangedTheme: false > // Подменяем приложение на мок: beforeEach(() => global.ourSuperApp = fakeApp >) // Проверяем. describe('when called', () => toggleTheme() // . что вызван нужный метод с ожидаемым аргументом: it('should call the theme toggler with a correct class name', () => expect(fakeApp.toggleClassName).toHaveBeenCalledWith('dark-theme') >) // . что значение поля стало ожидаемым: it('should toggle the changed theme flag', () => expect(fakeApp.userChangedTheme).toEqual(true) >) >)
Вместо того, чтобы создавать настоящий объект приложения с DOM, окружением, классами и вот этим всем, мы заменили его моком с 2 полями.
Когда функция toggle Theme ( ) вызовет метод toggle Class Name ( ) , мы проверим, сколько раз этот метод был вызван и с какими аргументами. Также убедимся, что второе поле user Changed Theme было изменено на ожидаемое значение.
Этого достаточно, чтобы проверить, как модули общаются друг с другом. Окружение для теста при этом остаётся максимально простым.
Шпионы
В интернете вы можете встретить ещё одну группу фиктивных объектов — шпионы (англ. spy). Мы не стали выносить их в отдельную группу, потому что они сильно похожи по функциональности и задачам на моки.
Шпионы следят за тем, какие функции у зависимостей вызываются. По желанию могут также имитировать возвращаемые значения для этих методов:
beforeEach(() => jest.spyOn(global.Math, 'random').mockReturnValue(0.42)>)beforeEach(() => jest.spyOn(global.Math, 'random').mockReturnValue(0.42) >)
В примере выше функциональность шпиона такая же, как и у мока. Отличие только в том, что мы не создаём мок руками.
Тестовые данные и инфраструктура
Для тестов нам также требуются данные, которые мы передаём функциями как аргументы.
Хорошей практикой считается заранее определиться, какие данные мы будем использовать в одном тесте, а какие — в нескольких сразу.
Бывает полезно заранее создать (или сгенерировать) данные для стандартных сущностей типа пользователя, товара, настроек и т. д., а в тестах использовать их копии. Это делает код тестов чище и короче, а ещё может пригодиться при генерировании документации.
Для данных с намеренными ошибками полезно выносить эти ошибки в названия переменных, чтобы в коде тестов было видно, какие именно данные мы используем:
const fakeUser = name: 'Alex', email: 'alex@site.com', role: 'user'> const fakeUserInvalidEmail = . fakeUser, email: 'упс! неправильная почта'> const fakeUserEmptyName = . fakeUser, name: undefined> // .const fakeUser = name: 'Alex', email: 'alex@site.com', role: 'user' > const fakeUserInvalidEmail = . fakeUser, email: 'упс! неправильная почта' > const fakeUserEmptyName = . fakeUser, name: undefined > // .
То же применимо для стабов и моков. Мы можем создать хранилище фиктивных объектов для проекта, которые потом будем импортировать в код конкретных тестов, переопределяя необходимые для теста методы.
Для генерации простых данных типа почты и почтовых индексов можно также использовать дополнительные инструменты.
Чем проще — тем лучше
Главное правило при работе с фиктивными объектами:
Если можно не мо́кать — лучше не мокать.
Чем меньше инфраструктуры для тестирования и объектов, за которыми надо следить и обновлять, тем быстрее и проще будет проходить работа с тестами.
Если есть возможность написать код так, чтобы зависимостей было меньше, лучше так и сделать. Если есть возможность, передать зависимости явно, чтобы не мокать глобальные объекты, лучше так и сделать.
Следить за размером тестового кода и инфраструктуры помогает TDD. По этой методологии мы сначала пишем тест, а только потом реализацию. Это помогает сразу проектировать API так, чтобы тест не оказался сложным.
Моки и стабы
Существует категория классов, которые тестировать весьма просто. Если класс зависит только от примитивных типов данных и не имеет никаких связей с другими бизнес-сущностями, то достаточно создать экземпляр этого класса, «пнуть» его некоторым образом путем изменения свойства или вызова метода и проверить ожидаемое состояние.
Это самый простой и эффективный способ тестирования, и любой толковый дизайн отталкивается от подобных классов, которые являются «строительными блоками» нижнего уровня, на основе которых затем уже строятся более сложные абстракции. Но количество классов, которые живут в такой «изоляции» не много по своей природе. Даже если мы по нормальному выделили всю логику по работе с базой данных (или сервисом) в отдельный класс (или набор классов), то рано или поздно появится кто-то, кто эти классы будет использовать для получения более высокоуровневого поведения и этого «кого-то» тоже нужно будет тестировать.
Но для начала давайте рассмотрим более типичный случай, когда логика по работе с базой данных или внешним сервисом, а также логика обработки этих данных сосредоточена в одном месте.
// Модель представления, предназначенная для управления входом // пользователя в систему public class LoginViewModel < public LoginViewModel() < // Читаем имя последнего пользователя UserName = ReadLastUserName(); >// Имя пользователя; может быть изменено пользователем public string UserName < get; set; >// Логиним пользователя UserName public void Login() < // Не обращаем внимание на дополнительную логику, которая должна быть // выполнена. Считаем что нам достаточно просто сохранить имя текущего // пользователя SaveLastUserName(UserName); >// Читаем имя последнего залогиненного пользователя private string ReadLastUserName() < // Не важно, как она на самом деле реализована . // Просто возвращаем что-нибудь, чтобы компилятор не возражал return "Jonh Doe"; >// Сохраняем имя последнего пользователя private void SaveLastUserName(string lastUserName) < // Опять таки, нам не интересно, как она реализована >>
Когда речь заходит о тестировании подобных классов, то обычно эта вью-модель помещается на форму, которая затем тестируется руками Если вместо вью-модели подобное смешивание логики происходит при реализации серверных компонент, то они тестируются путем создания простого консольного приложения, которое будет вызывать необходимые высокоуровневые функции, тестируя, таким образом, весь модуль целиком. В обоих случаях такой вариант тестирования нельзя назвать очень уж автоматическим.
ПРИМЕЧАНИЕ
Не нужно бросать в меня камнями с криками «Да кто сегодня вообще такую хрень написать можно? Ведь уже столько всего написано о вреде такого подхода, да и вообще, у нас есть юнити-шмунити и другие полезности, так что это нереальный баян двадцатилетней давности!». Кстати, да, это баян, но, во-первых, речь не юнитях и других контейнерах, а о базовых принципах, а во-вторых, подобное «интеграционное» тестирование все еще невероятно популярно, во всяком случае, среди многих моих «зарубежных» коллег.
Создания «швов» для тестирования приложения
Даже если не задумываться о том, какое количество новомодных принципов проектирования нарушает наша вью-модель, четко видно, что ее дизайн несколько … убог. Ведь даже если проектировать старым дедовским бучевским методом, то становится понятно, что всю работу по сохранению имени последнего пользователя, логику по работе с базой данных (или другим внешним источником данных) нужно спрятать подальше с глаз долой и сделать это «проблемой» кого-то другого и использовать уже этого «кого-то» в качестве «строительного блока» для получения более высокоуровневого поведения:
internal class LastUsernameProvider < // Читаем имя последнего пользователя из некоторого источника данных public string ReadLastUserName() < return "Jonh Doe"; >// Сохраняем это имя, откуда его можно будет впоследствии прочитать public void SaveLastUserName(string userName) < >> public class LoginViewModel < // Добавляем поле для получения и сохранения имени последнего пользователя private readonly LastUsernameProvider _provider = new LastUsernameProvider(); public LoginViewModel() < // Теперь просто вызываем функцию нового вспомогательного класса UserName = _provider.ReadLastUserName(); >public string UserName < get; set; >public void Login() < // Все действия по сохранению имени последнего пользователя также // делегируем новому классу _provider.SaveLastUserName(UserName); >>
Пока что написание модульного теста все еще остается затруднительным, но становится понятным, как можно достаточно просто «подделать» реальную реализацию класса LastUsernameProvider и сымитировать нужное для нас поведение. Достаточно выделить методы этого класса в отдельный интерфейс или просто сделать их виртуальными и переопределить в наследнике. После чего останется лишь «прикрутить» нужный нам объект в нашу вью-модель.
Честно говоря, я не большой фанат изменений в дизайне только ради «тестируемости» кода. Как показывает практика, нормальный ОО дизайн либо уже является достаточно «тестируемым» или же требует лишь минимальных телодвижений, чтобы сделать его таковым. Некоторые дополнительные мысли по этому поводу можно найти в заметке «Идеальная архитектура» .
Даже не прибегая ни к каким сторонним библиотекам для «инджекта» зависимостей мы можем сделать это самостоятельно несколько простыми способами. Нужную зависимость можно передать через дополнительный конструктор, через свойство или создать фабричный метод, который будет возвращать интерфейс ILastUsernmameProvider.
Давайте рассмотрим вариант с конструктором, который является довольно простым и популярным (при небольшом количестве внешних зависимостей он работает просто прекрасно).
// Выделяем методы в интерфейс internal interface ILastUsernameProvider < string ReadLastUserName(); void SaveLastUserName(string userName); >internal class LastUsernameProvider : ILastUsernameProvider < // Читаем имя последнего пользователя из некоторого источника данных public string ReadLastUserName() < return "Jonh Doe"; >// Сохраняем это имя, откуда его можно будет впоследствии прочитать public void SaveLastUserName(string userName) < >> public class LoginViewModel < private readonly ILastUsernameProvider _provider; // Единственный открытый конструктор создает реальный провайдер public LoginViewModel() : this(new LastUsernameProvider()) <>// "Внутренний" предназначен только для тестирования и может принимать "фейк" internal LoginViewModel(ILastUsernameProvider provider) < _provider = provider; UserName = _provider.ReadLastUserName(); >public string UserName < get; set; >public void Login() < _provider.SaveLastUserName(UserName); >>
Поскольку дополнительный конструктор является внутренним (internal), то он доступен только внутри этой сборке, а также «дружеской» сборке юнит-тестов. Конечно, если тестируемые классы являются внутренними не будет не какой, но поскольку все «клиенты» внутреннего класса находятся в одной сборке, то и контролировать их проще. Подобный подход, основанный на добавлении внутреннего метода для установки «фальшивого» поведения является разумным компромиссом упрощения тестирования кода, не налагая ограничения на использования более сложных механизмов управления зависимостями, типа IoC контейнеров.
ПРИМЕЧАНИЕ
Одним из недостатков при работе с интерфейсами является падение читабельности, поскольку не понятно, сколько реализаций интерфейса существует и где находится реализация того или иного метода интерфейса. Такие инструменты, как Решарпер существенно смягчают эту проблему, поскольку поддерживают не только навигацию к объявлению метода (Go To Declaration), но также и навигацию к реализации метода (Go To Implementation):

Проверка состояния vs проверка поведения
Теперь давайте попробуем написать юнит-тест вначале для конструктора класса LoginViewModel, который получает имя последнего залогиненного пользователя, а потом юнит-тест для метода Login, после выполнения которого, имя последнего пользователя должно быть сохранено.
Для нормальной реализации этих тестов нам нужна «фейковая» реализация интерфейса, при этом в первом случае, нам нужно вернуть произвольное имя последнего пользователя в методе ReadLastUserName, а во втором случае – удостовериться, что вызван метод SaveLastUserName.
Именно в этом и отличаются два типа «фейковых» классов: стабы предназначены для получения нужного состояния тестируемого объекта, а моки применяются для проверки ожидаемого поведения тестируемого объекта.
Стабы никогда не применяются в утверждениях, они простые «слуги», которые лишь моделируют внешнее окружение тестового класса; при этом в утверждениях проверяется состояние именно тестового класса, которое зависит от установленного состояния стаба.
// Стаб возвращающее указанное имя последнего пользователя internal class LastUsernameProviderStub : ILastUsernameProvider < // Добавляем публичное поле, для простоты тестирования и // возможности повторного использования этого класса public string UserName; // Реализация метода очень простая - просто возвращаем UserName public string ReadLastUserName() < return UserName; >// Этот метод в данном случае вообще не интересен public void SaveLastUserName(string userName) < >> [TestFixture] public class LoginViewModelTests < // Тестовый метод для проверки правильной реализации конструктора вью-модели [Test] public void TestViewModelConstructor() < var stub = new LastUsernameProviderStub(); // "моделируем" внешнее окружение stub.UserName = "Jon Skeet"; // Ух-ты!! var vm = new LoginViewModel(stub); // Проверяем состояние тестируемого класса Assert.That(vm.UserName, Is.EqualTo(stub.UserName)); >>
У моков же другая роль. Моки «подсовываются» тестируемому объекту, но не для того, чтобы создать требуемое окружение (хотя они могут выполнять и эту роль), а прежде всего для того, чтобы потом можно было проверить, что тестируемый объект выполнил требуемые действия. (Именно поэтому такой вид тестирования называется behaviortesting, в отличие от стабов, которые применяются для state—basedtesting).
// Мок позволяет проверить, что метод SaveLastUserName был вызван // с определенными параметрами internal class LastUsernameProviderMock : ILastUsernameProvider < // Теперь в этом поле будет сохранятся имя последнего сохраненного пользователя public string SavedUserName; // Нам все еще нужно вернуть правильное значение из этого метода, // так что наш "мок" также является и "стабом" public string ReadLastUserName() < return "Jonh Skeet";>// А вот в этом методе мы сохраним параметр в SavedUserName для public void SaveLastUserName(string userName) < SavedUserName = userName; >> // Проверяем, что при вызове метода Login будет сохранено имя последнего пользователя [Test] public void TestLogin() < var mock = new LastUsernameProviderMock(); var vm = new LoginViewModel(mock); // Изменяем состояние вью-модели путем изменения ее свойства vm.UserName = "Bob Martin"; // А затем вызываем метод Login vm.Login(); // Теперь мы проверяем, что был вызван метод SaveLastUserName Assert.That(mock.SavedUserName, Is.EqualTo(vm.UserName)); >
А зачем мне знать об этих отличиях?
Действительно, разница в понятиях может показаться незначительной, особенно если вы реализуете подобные «фейки» руками. В этом случае знание этих паттернов лишь позволит говорить с другими разработчиками на одном языке и упростит наименование фейковых классов.
Однако рано или поздно вам может надоесть это чудесное занятие по ручной реализации интерфейсов и вы обратите свое внимание на один из Isolation фреймворков, таких как Rhino Mocks, Moq или Microsoft Moles. Там эти термины встретятся обязательно и понимание отличий между этими типами фейков вам очень пригодится.
Я осознанно не касался ни одного из этих фреймворков, поскольку каждый из них заслуживает отдельной статьи и ИМО лишь усложнит понимание этих понятий. Но если вам все же интересно посмотреть на некоторые из этих фреймворков более подробно, то об одном из них я писал более подробно: “Microsoft Moles”.