How to spy on a method of an ES6 class with Jest and TypeScript?

Viewed 107

I'm trying to spy on a method of an ES6 class which I have manually mocked.

I'm following the official Jest documentation, which targets only JavaScript.

Here's my code:

// sound-player.ts
export default class SoundPlayer {
  foo: string

  constructor() {
    this.foo = 'bar'
  }

  playFile(fileName: string) {
    console.log(`Playing sound file: ${fileName}`)
  }
}
// __mocks__/sound-player.ts
export const mockPlayFile = jest.fn()

const mock = jest.fn().mockImplementation(() => {
  return { playFile: mockPlayFile }
})

export default mock
// sound-player-consumer.ts
import SoundPlayer from './sound-player'

export default class SoundPlayerConsumer {
  soundPlayer: SoundPlayer

  constructor(soundPlayer: SoundPlayer) {
    this.soundPlayer = soundPlayer
  }

  playSomethingCool() {
    this.soundPlayer.playFile('something-cool.mp3')
  }
}
// sound-player-consumer.test.ts
// @ts-ignore
import SoundPlayer, { mockPlayFile } from './sound-player'
import SoundPlayerConsumer from './sound-player-consumer'

jest.mock('./sound-player')

const MockedSoundPlayer = SoundPlayer as jest.Mock<SoundPlayer>

beforeEach(() => {
  MockedSoundPlayer.mockClear()
  mockPlayFile.mockClear()
})

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

it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayer = new MockedSoundPlayer()
  const soundPlayerConsumer = new SoundPlayerConsumer(soundPlayer)
  soundPlayerConsumer.playSomethingCool()
  expect(mockPlayFile).toHaveBeenCalledWith('something-cool.mp3')
})

These tests pass, because I used a trick.

Please pay attention to the first line of sound-player-consumer.test.ts. Without that, the TypeScript compiler would complain about mockPlayFile not being exported by sound-player.ts.

To me, this means that from Jest's perspective everything is fine.

However, I would like to fix everything from TypeScript's perspective and remove the @ts-ignore comment.

1 Answers

Yeah this is a little tricky and awkward.

// @ts-ignore
import SoundPlayer, { mockPlayFile } from './sound-player'

The reason the @ts-ignore comment is required here is because the types themselves are not rewired as a result of calling jest.mock('./sound-player') only the actual module values (i.e. javascript).

The reason this still works with @ts-ignore is because with the mock, importing anything from ./sound-player is as if you were importing from __mocks__/sound-player.ts, minus the types.

So how to get around this? You could just import mockPlayFile directly from the __mocks__ file. This will use the actual type of mockPlayFile alleviating the need for the @ts-ignore.

// sound-player-consumer.test.ts
import SoundPlayer from './sound-player'
import { mockPlayFile } from './__mocks__/sound-player'
import SoundPlayerConsumer from './sound-player-consumer'

jest.mock('./sound-player')

// ...

Alternatively, you could also just not import mockPlayFile and use the class instance method instead (i.e. soundPlayer.mockPlayFile).

it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayer = new MockedSoundPlayer()
  const soundPlayerConsumer = new SoundPlayerConsumer(soundPlayer)
  soundPlayerConsumer.playSomethingCool()
  expect(soundPlayer.mockPlayFile).toHaveBeenCalledWith('something-cool.mp3')
})

Note this is only possible because you define mockPlayFile as a jest.fn mock outside of the class mock implementation in __mocks__/sound-player.ts. As opposed to defining the jest.fn for each call to the class mock implementation, in which case the mock function would be different.


Side note

Jest permits manually mocking a class by simply exporting the mock class implementation directly, see docs example here. In your case you could just do...

// __mocks__/sound-player.ts
export const mockPlayFile = jest.fn();

export default class SoundPlayer {
  foo: string = 'bar';

  constructor () {
    console.log('mocked SoundPlayer constructor');
  }

  playFile = mockPlayFile;
}

I haven't tested everything IRL but I have done something similar to this in the last couple of days. LMK if there are issues a I can put up a working example, too bad https://codesandbox.io doesn't support jest.mock.

Related