简体   繁体   中英

Mocking method on default export class in Jest in Typescript

Context

I would like to test a custom hook, that depends on @react-native-firebase/dynamic-links . We are using @testing-library for react-native and its utility functions to test hooks ( @testing-library/react-hooks ).

This is the hook I would like to test (this is a simplified example):

import { useEffect } from 'react';
import dynamicLinks from '@react-native-firebase/dynamic-links';
import { navigateFromBackground } from '../deeplink';

// Handles dynamic link when app is loaded from closed state.
export const useDynamicLink = (): void => {
  useEffect(() => {
    void dynamicLinks()
      .getInitialLink()
      .then((link) => {
        if (link && link.url) {
          navigateFromBackground(link.url);
        }
      });
  }, []);
};

I would like the getInitialLink call to return something in each separate test. I have been able to mock getInitialLink with jest.mock(...) , however this mocks it for all tests. I think the trouble is that the method I would like to mock, is a method on a class.

import { useDynamicLink } from './useDynamicLink';
import { renderHook, act } from '@testing-library/react-hooks';
import { navigateFromBackground } from '../deeplink';

jest.mock('../deeplink');
// IMPORTANT: You cannot mock constructors with arrow functions. New cannot be
// called on an arrow function.
jest.mock('@react-native-firebase/dynamic-links', () => {
  return function () {
    return {
      getInitialLink: async () => ({
        url: 'fake-link',
      }),
    };
  };
});

describe('tryParseDynamicLink', () => {
  it('should return null if url is empty', async () => {
    // IMPORTANT: act wrapper is needed so that all events are handled before
    // state is inspected by the test.
    await act(async () => {
      renderHook(() => useDynamicLink());
    });

    expect(navigateFromBackground).toHaveBeenCalledWith('fake-link');
  });
});

Attempts

So this works, but I am not able to change the return value for each test. Jest offers a wide variety of ways to mock dependencies, however I was not able to make it work.

jest.MockedClass

Firebase exports by default a class, but the class itself is wrapped.

declare const defaultExport: ReactNativeFirebase.FirebaseModuleWithStatics<
  FirebaseDynamicLinksTypes.Module,
  FirebaseDynamicLinksTypes.Statics
>;

According to the documentation, you would need to mock it like described below.

import dynamicLinks from '@react-native-firebase/dynamic-links';
const dynamicLinksMock = dynamicLinks as jest.MockedClass<typeof dynamicLinks>;

It however throws the following error:

Type 'FirebaseModuleWithStatics<Module, Statics>' does not satisfy the constraint 'Constructable'.
  Type 'FirebaseModuleWithStatics<Module, Statics>' provides no match for the signature 'new (...args: any[]): any'.

Effectively it does not recognise it as a class since it is wrapped.


jest.MockedFunction

I decided to then mock it by using a function (and not using arrow functions). With this approach I was able to get a lot further, however with this approach I need to provide all properties. I attempted this for a while, but I gave up after adding X amount of properties (see code snippet below). So if this is the way to go, I would like to know how to automock most of this.

import { useDynamicLink } from './useDynamicLink';
import { renderHook, act } from '@testing-library/react-hooks';
import { navigateFromBackground } from '../deeplink';
import dynamicLinks from '@react-native-firebase/dynamic-links';
const dynamicLinksMock = dynamicLinks as jest.MockedFunction<
  typeof dynamicLinks
>;

jest.mock('../deeplink');

describe('tryParseDynamicLink', () => {
  it('should return null if url is empty', async () => {
    // eslint-disable-next-line prefer-arrow-callback
    dynamicLinksMock.mockImplementationOnce(function () {
      return {
        buildLink: jest.fn(),
        buildShortLink: jest.fn(),
        app: {
          options: {
            appId: 'fake-app-id',
            projectId: 'fake-project-id',
          },
          delete: jest.fn(),
          utils: jest.fn(),
          analytics: jest.fn(),
          name: 'fake-name',
          crashlytics: jest.fn(),
          dynamicLinks: jest.fn(),
        },
        onLink: jest.fn(),
        resolveLink: jest.fn(),
        native: jest.fn(),
        emitter: jest.fn(),
        getInitialLink: async () => ({
          minimumAppVersion: '123',
          utmParameters: { 'fake-param': 'fake-value' },
          url: 'fake-link',
        }),
      };
    });

    await act(async () => {
      renderHook(() => useDynamicLink());
    });

    expect(navigateFromBackground).toHaveBeenCalledWith('fake-link');
  });
});

jest.spyOn

The last attempt was to use spyOn which seems fitting in this case. Since it will mock only specific functions, however this throws a runtime error when I try to run the tests.

import { useDynamicLink } from './useDynamicLink';
import { renderHook, act } from '@testing-library/react-hooks';
import { navigateFromBackground } from '../deeplink';
import dynamicLinks from '@react-native-firebase/dynamic-links';

jest.mock('../deeplink');
// Ensure automock
jest.mock('@react-native-firebase/dynamic-links');

describe('tryParseDynamicLink', () => {
  it('should return null if url is empty', async () => {
    jest
      .spyOn(dynamicLinks.prototype, 'getInitialLink')
      .mockImplementationOnce(async () => 'test');

    await act(async () => {
      renderHook(() => useDynamicLink());
    });

    expect(navigateFromBackground).toHaveBeenCalledWith('fake-link');
  });
});

Error:

Cannot spy the getInitialLink property because it is not a function; undefined given instead

So all in all I am at a complete loss on how to mock the getInitialLink method. If anyone could provide any advice or tips it would be greatly appreciated!


Edit 1:

Based on the advice of @user275564 I tried the following:

jest.spyOn(dynamicLinks, 'dynamicLinks').mockImplementation(() => {
   return { getInitialLink: () => Promise.resolve('fake-link') };
});

Unfortunately typescript does not compile because of the following error:

No overload matches this call.
  Overload 1 of 4, '(object: FirebaseModuleWithStatics<Module, Statics>, method: never): SpyInstance<never, never>', gave the following error.
    Argument of type 'string' is not assignable to parameter of type 'never'.
  Overload 2 of 4, '(object: FirebaseModuleWithStatics<Module, Statics>, method: never): SpyInstance<never, never>', gave the following error.
    Argument of type 'string' is not assignable to parameter of type 'never'.

I am only able to put forth the static properties on the object there which are: 在此处输入图像描述

This is why I went for the dynamicLinks.prototype which was suggested in this answer .

Your jest.spyOn needs some work.

Jest.spyOn is different than mocks in that it cleans up its mock in the scope you are inside (and that it's not really a mock until you say explicitly call mockImplentation ect. on it thus it's a 'spy.') Since you want to constantly change your mocks you should be using spyOn() and mocking the implementation in each test to reduce boilerplate from clearing the mocks each time. Both can work just fine though but I would work on attempt 3.

First, remove the mock of dynamic links since we are going to spy on each specific test instead and mock the implementation there.

Second, because you are calling on an exported function directly you have to import and spy on the function like this.

import * as dynamicLinks from '@react-native-firebase/dynamic-links';

const dynamicLinkSpy = jest.spyOn(dynamicLinks, 'dynamicLinks').mockImplentation( ... )

dynamicLinks is now the exported file jest spys on and the function it looks for is dynamicLinks(), which is what the production code is calling.

Another error is from adding.prototype. You should look at how the production code is calling it, that's how the tests should be mocking it. Also for this, you replace implementation on dynamicLinks, you have to create the return value that will work downward from the nested functions being called on that object. Also, since you're using.then() your production code expects a Promise to be resolved in the function. Like so;

const dynamicLinkSpy = jest
  .spyOn(dynamicLinks, 'dynamicLinks')
  .mockImplementation(()=>{ return {getInitialLink: ()=> Promise.resolve('test')}} );

Now, you can play with different return values and expect different results as usual. Also, remember you should test whether or not it's being called on. Like below:

expect(dynamicLinkSpy).toHaveBeenCalled();

I prefer to create a service using dynamic links (or other firebase functions). It's easy to mock.

dynamicLinksService.ts

import dynamicLinks from '@react-native-firebase/dynamic-links';

export const getInitialLink = () => dynamicLinks().getInitialLink();

useDynamicLink.ts

import { useEffect } from 'react';

import { navigateFromBackground } from '../deeplink';

import { getInitialLink } from './dynamicLinkService';

export const useDynamicLink = (): void => {
  useEffect(() => {
    getInitialLink().then((link) => {
      if (link && link.url) {
        navigateFromBackground(link.url);
      }
    });
  }, []);
};

useDynamicLink.test.ts

import { renderHook, act } from '@testing-library/react-hooks';

import { navigateFromBackground } from '../deeplink';

import { getInitialLink } from './dynamicLinkService';
import { useDynamicLink } from './useDynamicLink';

jest.mock('../deeplink', () => ({
  navigateFromBackground: jest.fn(),
}));

jest.mock('./dynamicLinkService', () => ({
  getInitialLink: jest.fn(),
}));

describe('The useDynamicLink', () => {
  it('should not navigate when link in empty', async () => {
    const getInitialLinkMock = getInitialLink as jest.Mock;

    getInitialLinkMock.mockResolvedValue(null);

    await act(async () => {
      renderHook(() => useDynamicLink());
    });

    expect(navigateFromBackground).not.toHaveBeenCalled();
  });

  it('should navigate when link is exist', async () => {
    const getInitialLinkMock = getInitialLink as jest.Mock;

    getInitialLinkMock.mockResolvedValue({ url: 'www.google.com' });

    await act(async () => {
      renderHook(() => useDynamicLink());
    });

    expect(navigateFromBackground).toHaveBeenCalledWith('www.google.com');
  });
});

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM