javascript

Разница между очисткой, сбросом и восстановлением моков

  • среда, 30 октября 2024 г. в 00:00:06
https://habr.com/ru/articles/854336/

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

Например, эта функция:

const fn = vi.fn()

fn('one')
fn('two')

fn.mock.calls
// [ ["one"], ["two"] ]

В приведенном выше примере функция-заглушка fn имеет состояние, которое отслеживает все вызовы, сделанные к ней. Само состояние не является проблемой. На самом деле, вам это состояние нужно! Оно необходимо для проверки правильного количества вызовов и их аргументов во время теста.

Состояние начинает вызывать проблемы, как только с ним неправильно обращаются.

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

const listener = vi.fn()

test('calls the listener when the event is emitted', () => {
  const emitter = new Emitter()
  emitter.on('hello', listener)
  emitter.emit('hello')
  
  expect(listener).toHaveBeenCalledTimes(1)
})

test('does not call the listener after `.removeAllListeners()` has been called', () => {
  const emitter = new Emitter()
  emitter.on('hello', listener)
  emitter.removeAllListeners()
  emitter.emit('hello')
  
  expect(listener).not.toHaveBeenCalled()
})

Здесь нам нужно протестировать случаи для класса Emitter. Первый из них ожидает, что слушатель будет вызван при эмитировании события, а другой ожидает отсутствия вызовов слушателя, потому что все слушатели были удалены.

Чтобы это проверить, мы вводим мок функцию listener, чтобы проверять вызовы.

Но если мы запустим этот набор тестов, мы получим ошибку 😱

FAIL  src/example.test.ts > does not call the listener after `.removeAllListeners()` has been calle
AssertionError: expected "spy" to not be called at all, but actually been called 1 times

Received: 

  1st spy call:

    Array []

Number of calls: 1

 ❯ src/example.test.ts:19:24
     17|   emitter.emit('hello')
     18| 
     19|   expect(listener).not.toHaveBeenCalled()
       |                        ^
     20| })
     21|

Хотя наши тестовые случаи имеют собственную область, объявляя и управляя своими ресурсами, функция listener становится общим состоянием между ними. В результате вызов listener из первого теста просачивается во второй, где вызовов не ожидается.

Есть несколько способов решить эту проблему. Мы можем объявить мок функцию listener в отдельных тестах, чтобы предотвратить её совместное использование, или мы можем управлять состоянием этой мок функции.

Давайте поговорим о втором подходе.

Способы управления состоянием моков

И Vitest, и Jest предоставляют отличные API для управления состоянием ваших моков:

  • .mockClear() / vi.clearAllMocks()

  • .mockReset() / vi.resetAllMocks()

  • .mockRestore() / vi.restoreAllMocks()

Вы можете применять эти методы как к отдельным мок-функциям, так и ко всему набору тестов.

Есть только одна вещь.

Названия этих методов очень похожи 😅

Когда я задал этот вопрос в Твиттере, большинство людей не смогли уверенно отличить их друг от друга. Честно говоря, когда я перечитал документацию, чтобы убедиться, что сам всё правильно понял, у меня началась небольшая паника от мысли, что я использовал .resetAllMocks() вместо .restoreAllMocks() в своём воркшопе (всё в порядке, прошлый я использовал их правильно).

Пришло время развеять путаницу раз и навсегда.

Мы подробно рассмотрим отдельные методы .mock*, потому что их аналоги vi.*AllMocks делают то же самое, просто применяют к каждому существующему моку одновременно.

Перед тем как начать

Во-первых, важно признать, что любая мок функция отслеживает две вещи:

  1. Список вызовов (т.е. как эта функция вызывается);

  2. Реализация функции (т.е. что делает эта функция при вызове).

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

Очистка моков (mockClear)

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

Очищает историю вызовов мок функции
Очищает историю вызовов мок функции

Это то, что делает метод .mockClear() .Он очищает все записанные вызовы мок функции, и на этом всё.

const fn = vi.fn()

fn('one')
expect(fn).toHaveBeenCalledTimes(1) // ✅

// Заставляет мок функцию забыть о
// любых вызовах, произошедших до этого момента..
fn.mockClear()

fn('two')
expect(fn).toHaveBeenCalledTimes(1) // ✅

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

fn('one')
// ...assert

fn.mockClear()

fn('two')
// ...assert

Сброс моков (mockReset)

Продолжая нашу аналогию с коробкой, давайте представим, что помимо того, чтобы класть мячи в нашу коробку, мы можем также поместить внутрь неё более маленькую коробку (реализацию мока). Если мы это сделаем, то эта маленькая коробка будет получать все мячи и распоряжаться ими по своему усмотрению.

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

Очищает историю вызовов мок-функции и сбрасывает её реализацию до пустой функции.
Очищает историю вызовов мок-функции и сбрасывает её реализацию до пустой функции.

Метод .mockReset() чрезвычайно удобен, когда вы имеете дело с функциями, имеющими моковую реализацию. И это может включать как мок-функции, так и "шпионы"!

// Давайте подслушаем консоль!
// Вызов vi.spyOn оборачивает глобальную функцию console.log
// в мок-функцию. Давайте также предоставим мок-реализацию
// чтобы вызовы консоли в тесте ничего не выводили.
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})

console.log('you will not see this') // ничего не выводится!
expect(console.log).toHaveBeenCalledTimes(1) // ✅

// Заставляет мок забыть о любых вызовах к нему
// и также удаляет любую мок-реализацию, которая у него могла быть.
spy.mockReset()

console.log('you will see this!') // выводит "you will see this"
expect(console.log).toHaveBeenCalledTimes(1) // ✅

Тот факт, что сам мок остается активным, делает .mockReset() / vi.resetAllMocks() отличным выбором для очистки состояния мока между тестами:

afterEach(() => {
  // Убедитесь, что состояние не протекает между моками в тестах.
  vi.resetAllMocks()
})

Восстановление моков (mockRestore)

В нашей аналогии с коробкой восстановление мока просто означает выброс коробки.

Метод .mockRestore() фактически отменяет мок, удаляя как записанные вызовы, так и любую смоделированную реализацию. Он актуален только для шпионов (spies). Функция, за которой мы следим (spy), всегда имеет либо нашу смоделированную реализацию, либо оригинальную реализацию, и восстановление этого шпиона означает возвращение функции к её оригинальному, «чистому» состоянию.

Очищает историю вызовов мок-функции и восстанавливает её реализацию до изначальной
Очищает историю вызовов мок-функции и восстанавливает её реализацию до изначальной

Вот как это выглядит в коде:

const spy = vi.spyOn(console, 'log')

console.log(1)
expect(console.log).toHaveBeenCalledTimes(1) // ✅

// Отменяет этот мок, восстанавливая оригинальную реализацию
// отслеживаемой функции (console.log).
spy.mockRestore()

expect(console.log).toHaveBeenCalledTimes(0)
// ❌ TypeError: [Function log] не является шпионом или вызовом шпиона!

Из-за этого .mockRestore() / vi.restoreAllMocks() являются предпочтительным выбором для очистки после себя в хуках, таких как afterAll:

afterAll(() => {
  // Убедитесь, что вы восстановили все моки 
  // после завершения этого набора тестов.
  vi.restoreAllMocks()
})

Выводы

Давайте подытожим различия между этими методами:

  • .mockClear() очищает зарегистрированные вызовы мок-функций;

  • .mockReset() очищает зарегистрированные вызовы и удаляет любые реализации моков;

  • .mockRestore() удаляет мок, полностью отменяя его.

Поскольку вы никогда не хотите, чтобы состояние мока делилось между тестами, рекомендуется настроить автоматический сброс моков в вашей тестовой среде. В Vitest это делается путем предоставления опции mockReset в вашем vite.config.ts / vitest.config.ts:

export default defineConfig({
  test: {
    mockReset: true
  }
})

Вот и всё! Надеюсь, вы запомните это объяснение в следующий раз, когда будете очищать, сбрасывать или восстанавливать ваши моки.