skip to main content

Фиктивные объекты и данные, моки, стабы

js

При тестировании нам часто приходится заменять настоящие объекты «заглушками», чтобы тесты были проще и прямолинейнее. В этой статье мы рассмотрим разные виды таких «заглушек», когда и какие использовать и как сделать работу с ними удобнее.

Кратко 🔗

Среди фиктивных объектов можно выделить две группы: моки и стабы.

Стабы (англ. stub) заменяют объекты, но сами ничего не проверяют. Их реализация простая, а зачастую — даже ничего не делает вовсе. Стабы нужны, чтобы заменить собой зависимость в системе и упростить окружение для тестов.

Моки (англ. mock) тоже заменяют зависимости, но при этом позволяют проверять предположения. Они могут следить за вызовами методов, аргументами этих вызовов и т. д. Моки удобны при тестировании функций с побочными эффектами.

Фиктивные объекты можно представить как беговые дорожки, которые заменяют собой настоящий большой парк. Только стабы — это дорожки попроще: крутящаяся лента без дисплея и настроек; а моки — дорожки, которые следят за темпом, ускорением, сердцебиением и т.д.

Фиктивные объекты 🔗

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

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)
})
})

Но если функция зависит не только от аргументов, но ещё от внешнего мира (то есть у неё есть побочные эффекты), то проверить её работу становится сложнее:

function addRandom(a) {
return a + Math.random();
}

Чтобы проверить addRandom, нам нужно знать случайное число, которое вернёт Math.random. Это непрактично.

Также сложно проверить результат, если функция ничего не возвращает, а меняет окружение или другие объекты. Например, проверить функцию toggleTheme так в принципе не получится:

function toggleTheme() {
ourSuperApp.toggleClassName('dark-theme');
ourSuperApp.userChangedTheme = true;
}

Для проверки подобных функций удобнее всего использовать фиктивные объекты.

Среди фиктивных объектов можно выделить две группы: стабы и моки.

Стабы 🔗

Хороший тест должен быть быстрым, изолированным и воспроизводимым. Чтобы выполнить эти требования фиктивные объекты должны быть максимально простыми. Стабы как раз такие.

Они предоставляют интерфейс объекта, который заменяют, но их реализация гораздо проще. Часто — их методы не делают никаких вычислений, а сразу возвращают нужное значение.

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);
})
})

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

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

Мóки 🔗

У моков задача чуть шире, чем у стабов. Они не только заменяют зависимость функции, но ещё и следят, как функция эту зависимость использует.

Если тестируемая функция не возвращает результат, единственный способ проверить её работу — посмотреть, как она повлияла на окружение. Моки следят за изменениями и позволяют сравнить новое состояние с ожидаемым.

Вспомним функцию toggleTheme:

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)
})
})

Вместо того, чтобы создавать настоящий объект приложения с DOM, окружением, классами и вот этим всем, мы заменили его моком с 2 полями.

Когда функция toggleTheme вызовет метод toggleClassName, мы проверим, сколько раз этот метод был вызван и с какими аргументами. Также убедимся, что второе поле userChangedTheme было изменено на ожидаемое значение.

Этого достаточно, чтобы проверить, как модули общаются друг с другом. Окружение для теста при этом остаётся максимально простым.

Шпионы 🔗

В интернете вы можете встретить ещё одну группу фиктивных объектов — шпионы (англ. spy). Мы не стали выносить их в отдельную группу, потому что они сильно похожи по функциональности и задачам на моки.

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

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
}

// ...

То же применимо для стабов и моков. Мы можем создать хранилище фиктивных объектов для проекта, которые потом будем импортировать в код конкретных тестов, переопределяя необходимые для теста методы.

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

Чем проще — тем лучше 🔗

Главное правило при работе с фиктивными объектами:

Чем меньше инфраструктуры для тестирования и объектов, за которыми надо следить и обновлять, тем быстрее и проще будет проходить работа с тестами.

Если есть возможность написать код так, чтобы зависимостей было меньше, лучше так и сделать. Если есть возможность, передать зависимости явно, чтобы не мокать глобальные объекты, лучше так и сделать.

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