При тестировании нам часто приходится заменять настоящие объекты «заглушками», чтобы тесты были проще и прямолинейнее. В этой статье мы рассмотрим разные виды таких «заглушек», когда и какие использовать и как сделать работу с ними удобнее.
Кратко
СкопированоСреди фиктивных объектов можно выделить две группы: моки и стабы.
Стабы (англ. 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
, нам нужно знать случайное число, которое вернёт Math
. Это непрактично.
Также сложно проверить результат, если функция ничего не возвращает, а меняет окружение или другие объекты. Например, проверить функцию toggle
так в принципе не получится:
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
в примере выше предоставляет метод random
, но возвращает не случайное число, а конкретное значение. Если мы подменим настоящий Math
на math
, метод 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
:
function toggleTheme() { ourSuperApp.toggleClassName('dark-theme') ourSuperApp.userChangedTheme = true}
function toggleTheme() { ourSuperApp.toggleClassName('dark-theme') ourSuperApp.userChangedTheme = true }
Для конечного пользователя её задача выглядит как:
Но с точки зрения самой функции её задача — вызвать метод toggle
на объекте our
и поменять значение поля user
. Именно это и можно проверить с помощью моков:
// Создаём мок для объекта приложения: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
вызовет метод toggle
, мы проверим, сколько раз этот метод был вызван и с какими аргументами. Также убедимся, что второе поле user
было изменено на ожидаемое значение.
Этого достаточно, чтобы проверить, как модули общаются друг с другом. Окружение для теста при этом остаётся максимально простым.
Шпионы
СкопированоВ интернете вы можете встретить ещё одну группу фиктивных объектов — шпионы (англ. 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 так, чтобы тест не оказался сложным.