Jest.mock(): как высмеивать импорт по умолчанию класса ES6 с помощью параметра factory

Отказывание импорта классов ES6

Я хотел бы высмеять мои импортированные классы ES6 в своих тестовых файлах.

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

Jest.mock()

jest.mock() могут модифицировать импортированные модули. Когда передан один аргумент:

jest.mock('./my-class.js');

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

Параметр factory

jest.mock() принимает второй аргумент, который является модулем factory. Для ES6-классов, экспортированных с использованием export default, неясно, как должна возвращаться эта функция factory. Это:

  • Другая функция, возвращающая объект, который имитирует экземпляр класса?
  • Объект, который имитирует экземпляр класса?
  • Объект с свойством default, который является функцией, которая возвращает объект, который имитирует экземпляр класса?
  • Функция, возвращающая функцию более высокого порядка, которая сама возвращает 1, 2 или 3?

Документы довольно неопределенны:

Второй аргумент может быть использован для указания явного модуля factory, который выполняется вместо использования функции Jest automocking:

Я изо всех сил пытаюсь найти определение factory, которое может функционировать как конструктор, когда потребитель import класс. Я продолжаю получать TypeError: _soundPlayer2.default is not a constructor (например).

Я попытался избежать использования функций стрелок (поскольку их нельзя вызвать с помощью new), а factory возвращает объект, у которого есть свойство default (или нет).

Вот пример. Это не работает; все тесты бросают TypeError: _soundPlayer2.default is not a constructor.

Испытываемый класс: звук-плеер-consumer.js

import SoundPlayer from './sound-player'; // Default import

export default class SoundPlayerConsumer {
  constructor() {
    this.soundPlayer = new SoundPlayer(); //TypeError: _soundPlayer2.default is not a constructor
  }

  playSomethingCool() {
    const coolSoundFileName = 'song.mp3';
    this.soundPlayer.playSoundFile(coolSoundFileName);
  }
}

Класс издевается: звук player.js

export default class SoundPlayer {
  constructor() {
    // Stub
    this.whatever = 'whatever';
  }

  playSoundFile(fileName) {
    // Stub
    console.log('Playing sound file ' + fileName);
  }
}

Тестовый файл: sound-player-consumer.test.js

import SoundPlayerConsumer from './sound-player-consumer';
import SoundPlayer from './sound-player';

// What can I pass as the second arg here that will 
// allow all of the tests below to pass?
jest.mock('./sound-player', function() { 
  return {
    default: function() {
      return {
        playSoundFile: jest.fn()
      };
    }
  };
});

it('The consumer should be able to call new() on SoundPlayer', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(soundPlayerConsumer).toBeTruthy(); // Constructor ran with no errors
});

it('We can check if the consumer called the mocked class constructor', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalled();
});

it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();
  expect(SoundPlayer.playSoundFile).toHaveBeenCalledWith(coolSoundFileName);
});

Что я могу передать в качестве второго аргумента jest.mock(), который позволит пройти все тесты в примере? Если тесты необходимо изменить, чтобы все было в порядке - пока они все еще проверяют одни и те же вещи.

Ответ 1

Обновленный с решением благодаря обратной связи от @SimenB на GitHub.


Заводская функция должна возвращать функцию

Фабричная функция должна возвращать макет: объект, который заменяет все, что имитируется.

Так как мы высмеиваем класс ES6, который является функцией с некоторым синтаксическим сахаром, то сам макет должен быть функцией. Поэтому фабричная функция, переданная jest.mock() должна возвращать функцию; другими словами, это должна быть функция высшего порядка.

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

Простой макет вы можете назвать new:

Вот простая версия, которая, поскольку она возвращает функцию, позволит вызывать new:

jest.mock('./sound-player', () => {
  return function() {
    return { playSoundFile: () => {} };
  };
});

Примечание: функции стрелок не будут работать

Обратите внимание, что наш макет не может быть функцией стрелки, потому что мы не можем вызывать new для функции стрелки в Javascript; что присуще языку. Так что это не сработает:

jest.mock('./sound-player', () => {
  return () => { // Does not work; arrow functions can't be called with new
    return { playSoundFile: () => {} };
  };
});

Это вызовет TypeError: _soundPlayer2.default не является конструктором.

Отслеживание использования (шпионаж на макете)

Не выбрасывать ошибки - это хорошо, но нам может потребоваться проверить, был ли вызван наш конструктор с правильными параметрами.

Чтобы отслеживать вызовы конструктора, мы можем заменить функцию, возвращаемую HOF, функцией Jest mock. Мы создаем его с помощью jest.fn(), а затем определяем его реализацию с помощью mockImplementation().

jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => { // Works and lets you check for constructor calls
    return { playSoundFile: () => {} };
  });
});

Это позволит нам проверить использование нашего SoundPlayer.mock.calls класса с помощью SoundPlayer.mock.calls.

Следите за методами нашего класса

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

Поскольку во время наших тестов будет создан новый фиктивный объект, SoundPlayer.playSoundFile.calls нам не поможет. Чтобы обойти это, мы заполняем playSoundFile другой фиктивной функцией и сохраняем ссылку на эту же фиктивную функцию в нашем тестовом файле, чтобы мы могли обращаться к ней во время тестов.

let mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => { // Works and lets you check for constructor calls
    return { playSoundFile: mockPlaySoundFile }; // Now we can track calls to playSoundFile
  });
});

Полный пример

Вот как это выглядит в тестовом файле:

import SoundPlayerConsumer from './sound-player-consumer';
import SoundPlayer from './sound-player';

let mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return { playSoundFile: mockPlaySoundFile };
  });
});

it('The consumer should be able to call new() on SoundPlayer', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(soundPlayerConsumer).toBeTruthy(); // Constructor ran with no errors
});

it('We can check if the consumer called the class constructor', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalled();
});

it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();
  expect(mockPlaySoundFile.mock.calls[0][0]).toEqual(coolSoundFileName);
});

Ответ 2

Для тех, кто читает этот вопрос, я настроил репозиторий GitHub для тестирования модулей и классов. Он основан на принципах, описанных в ответе выше, но охватывает как экспорт по умолчанию, так и именной экспорт.

Ответ 3

Если вы все еще получаете TypeError:...default is not a constructor и используете TypeScript, продолжайте чтение.

TypeScript передает ваш файл TS, и ваш модуль, вероятно, импортируется с помощью импорта ES2015. const soundPlayer = require('./sound-player'). Поэтому создание экземпляра класса, который был экспортирован по умолчанию, будет выглядеть следующим образом: new soundPlayer.default(). Однако, если вы издеваетесь над классом, как предложено в документации.

jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return { playSoundFile: mockPlaySoundFile };
  });
});

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

jest.mock('./sound-player', () => {
    return {
        default: jest.fn().mockImplementation(() => {
            return {
                playSoundFile: mockPlaySoundFile 
            }   
        })
    }
})