Боксёр бьёт большую грушу (фрукт)
Иллюстрация: Кира Кустова

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

Эффективно тестируем функции с внешними зависимостями.

Время чтения: 8 мин

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

Кратко

Скопировано

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

Стабы (англ. 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.random(). Это непрактично.

Также сложно проверить результат, если функция ничего не возвращает, а меняет окружение или другие объекты. Например, проверить функцию 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: 'упс! неправильная почта'}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 так, чтобы тест не оказался сложным.