При тестировании нам часто приходится заменять настоящие объекты «заглушками», чтобы тесты были проще и прямолинейнее. В этой статье мы рассмотрим разные виды таких «заглушек», когда и какие использовать и как сделать работу с ними удобнее.
Кратко
СкопированоСреди фиктивных объектов можно выделить две группы: моки и стабы.
Стабы (англ. 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 так, чтобы тест не оказался сложным.