При тестировании нам часто приходится заменять настоящие объекты «заглушками», чтобы тесты были проще и прямолинейнее. В этой статье мы рассмотрим разные виды таких «заглушек», когда и какие использовать и как сделать работу с ними удобнее.
Кратко
Секция статьи "Кратко"Среди фиктивных объектов можно выделить две группы: моки и стабы.
Стабы (англ. 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(); }
Чтобы проверить addRandom
, нам нужно знать случайное число, которое вернёт Math
. Это непрактично.
Также сложно проверить результат, если функция ничего не возвращает, а меняет окружение или другие объекты. Например, проверить функцию toggleTheme
так в принципе не получится:
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 }
Объект mathStub
в примере выше предоставляет метод random
, но возвращает не случайное число, а конкретное значение. Если мы подменим настоящий Math
на mathStub
, метод 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); }) })
Задача стаба — избавить нас от подготовки и работы с настоящей зависимостью. Это экономит время, когда тестируемая функция зависит от сложных настраиваемых объектов.
Чтобы не приводить настоящий объект в нужное состояние, мы «подмешиваем» заменитель, который имитирует такое состояние. Для тестируемой функции ничего не меняется, но тест становится проще и короче.
Мо́ки
Секция статьи "Мо́ки"У моков задача чуть шире, чем у стабов. Они не только заменяют зависимость функции, но ещё и следят, как функция эту зависимость использует.
Если тестируемая функция не возвращает результат, единственный способ проверить её работу — посмотреть, как она повлияла на окружение. Моки следят за изменениями и позволяют сравнить новое состояние с ожидаемым.
Вспомним функцию toggleTheme
:
function toggleTheme() { ourSuperApp.toggleClassName('dark-theme'); ourSuperApp.userChangedTheme = true;}
function toggleTheme() { ourSuperApp.toggleClassName('dark-theme'); ourSuperApp.userChangedTheme = true; }
Для конечного пользователя её задача выглядит как:
Но с точки зрения самой функции её задача — вызвать метод toggleClassName
на объекте ourSuperApp
и поменять значение поля userChangedTheme
. Именно это и можно проверить с помощью моков:
// Создаём мок для объекта приложения: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 полями.
Когда функция toggleTheme
вызовет метод toggleClassName
, мы проверим, сколько раз этот метод был вызван и с какими аргументами. Также убедимся, что второе поле userChangedTheme
было изменено на ожидаемое значение.
Этого достаточно, чтобы проверить, как модули общаются друг с другом. Окружение для теста при этом остаётся максимально простым.
Шпионы
Секция статьи "Шпионы"В интернете вы можете встретить ещё одну группу фиктивных объектов — шпионы (англ. 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: 'oops! wrong email'}const fakeUserEmptyName = { ...fakeUser, name: undefined}// ...
const fakeUser = { name: 'Alex', email: 'alex@site.com', role: 'user' } const fakeUserInvalidEmail = { ...fakeUser, email: 'oops! wrong email' } const fakeUserEmptyName = { ...fakeUser, name: undefined } // ...
То же применимо для стабов и моков. Мы можем создать хранилище фиктивных объектов для проекта, которые потом будем импортировать в код конкретных тестов, переопределяя необходимые для теста методы.
Для генерации простых данных типа почты и почтовых индексов можно также использовать дополнительные инструменты.
Чем проще — тем лучше
Секция статьи "Чем проще — тем лучше"Главное правило при работе с фиктивными объектами:
Чем меньше инфраструктуры для тестирования и объектов, за которыми надо следить и обновлять, тем быстрее и проще будет проходить работа с тестами.
Если есть возможность написать код так, чтобы зависимостей было меньше, лучше так и сделать. Если есть возможность, передать зависимости явно, чтобы не мокать глобальные объекты, лучше так и сделать.
Следить за размером тестового кода и инфраструктуры помогает TDD. По этой методологии мы сначала пишем тест, а только потом реализацию. Это помогает сразу проектировать API так, чтобы тест не оказался сложным.