jest spyOn not working on index file, cannot redefine property
Asked Answered
F

7

90

I have UserContext and a hook useUser exported from src/app/context/user-context.tsx. Additionally I have an index.tsx file in src/app/context which exports all child modules.

If I spyOn src/app/context/user-context it works but changing the import to src/app/context I get:

TypeError: Cannot redefine property: useUser at Function.defineProperty (<anonymous>)

Why is that?

Source code:

// src/app/context/user-context.tsx

export const UserContext = React.createContext({});

export function useUser() {
  return useContext(UserContext);;
}

// src/app/context/index.tsx

export * from "./user-context";
// *.spec.tsx

// This works:
import * as UserContext from "src/app/context/user-context";

// This does not work:
// import * as UserContext from "src/app/context";

it("should render complete navigation when user is logged in", () => {

    jest.spyOn(UserContext, "useUser").mockReturnValue({
        user: mockUser,
        update: (user) => null,
        initialized: true,
    });
})
Foreordain answered 7/6, 2021 at 13:32 Comment(2)
How are you importing UserContext in the code being tested? If you're importing from "src/app/context/user-context", then you'll need to import it the same way in the spec file, or else it won't be mocking the same thing. Though your TypeError seems like a different issue...Emporium
I think you need to read this article: chakshunyu.com/blog/…Sonneteer
C
42

If you take a look at the js code produced for a re-export it looks like this

Object.defineProperty(exports, "__esModule", {
  value: true
});

var _context = require("context");

Object.keys(_context).forEach(function (key) {
  if (key === "default" || key === "__esModule") return;
  if (key in exports && exports[key] === _context[key]) return;
  Object.defineProperty(exports, key, {
    enumerable: true,
    get: function get() {
      return _context[key];
    }
  });
});

and the error you get is due to the compiler not adding configurable: true in the options for defineProperty, which would allow jest to redefine the export to mock it, from docs

configurable

true if the type of this property descriptor may be changed and if the property may be deleted from the corresponding object. Defaults to false.

I think you could tweak your config somehow to make the compiler add that, but it all depends on the tooling you're using.

A more accessible approach would be using jest.mock instead of jest.spyOn to mock the user-context file rather than trying to redefine an unconfigurable export

it("should render complete navigation when user is logged in", () => {
  jest.mock('./user-context', () => {
    return {
      ...jest.requireActual('./user-context'),
      useUser: jest.fn().mockReturnValue({
        user: {},
        update: (user: any) => null,
        initialized: true
      })
    }
  })
});

Capriccio answered 13/11, 2021 at 5:28 Comment(2)
If the lack of "configurable" is the case, won't babel's config assumption "constantReexports" fix that? Check here. But doesn't work for me tbhSheeb
@Sheeb Thanks for the hint. Enabling the constantReexports assumption fixed it for me in my Jest tests :)Anthropophagite
S
115

Can't agree that using jest.mock() instead of spyOn() is a good solution. With that approach you have to mock all functions once at the top of your spec file.

But some of the test cases might need a different setup or stay real.

Anyway, I want to continue use spyOn().

A better way is to add following lines at the top of your spec file (right after the imports and before describe()).

import * as Foo from 'path/to/file'; 

jest.mock('path/to/file', () => {
  return {
    __esModule: true,    //    <----- this __esModule: true is important
    ...jest.requireActual('path/to/file')
  };
});

...

//just do a normal spyOn() as you did before somewhere in your test:
jest.spyOn(Foo, 'fn');

P.S. Also could be an one-liner:

jest.mock('path/to/file', () => ({ __esModule: true, ...jest.requireActual('path/to/file') }));

P.P.S. Put 'path/to/file' to a constant might not work. At least for me it didn't. So sadly you need to repeat the actual path 3 times.

Sheeb answered 6/7, 2022 at 14:45 Comment(4)
spying on a mock really defeats the purpose of spying at all. not sure if this is a good idea.Histidine
@PouyaAtaei you are not spying on mock, since you are doing jest.requireActual. I also not happy with this solution, but can't find anything better. Probably a better solution would be to adjust the way babel compiles files for unit tests (with configurable: true), but for that you need to create a babel plugin I guessSheeb
excellent find, works for me mocking useBreakpointValue from @chakra-ui/media-query - thanks!Rausch
A clarification: The reason you can't use a constant is that the mock statement is hoisted, meaning that it is always moved to the top of the file by jest.Ditter
C
42

If you take a look at the js code produced for a re-export it looks like this

Object.defineProperty(exports, "__esModule", {
  value: true
});

var _context = require("context");

Object.keys(_context).forEach(function (key) {
  if (key === "default" || key === "__esModule") return;
  if (key in exports && exports[key] === _context[key]) return;
  Object.defineProperty(exports, key, {
    enumerable: true,
    get: function get() {
      return _context[key];
    }
  });
});

and the error you get is due to the compiler not adding configurable: true in the options for defineProperty, which would allow jest to redefine the export to mock it, from docs

configurable

true if the type of this property descriptor may be changed and if the property may be deleted from the corresponding object. Defaults to false.

I think you could tweak your config somehow to make the compiler add that, but it all depends on the tooling you're using.

A more accessible approach would be using jest.mock instead of jest.spyOn to mock the user-context file rather than trying to redefine an unconfigurable export

it("should render complete navigation when user is logged in", () => {
  jest.mock('./user-context', () => {
    return {
      ...jest.requireActual('./user-context'),
      useUser: jest.fn().mockReturnValue({
        user: {},
        update: (user: any) => null,
        initialized: true
      })
    }
  })
});

Capriccio answered 13/11, 2021 at 5:28 Comment(2)
If the lack of "configurable" is the case, won't babel's config assumption "constantReexports" fix that? Check here. But doesn't work for me tbhSheeb
@Sheeb Thanks for the hint. Enabling the constantReexports assumption fixed it for me in my Jest tests :)Anthropophagite
T
5

In my case the problem was that i tried to redefine index.tsx located in parential useSteps folder with the same name as hook file:

my folder structure was like:

hooks/useSteps/
              index.tsx
              useSteps/useSteps.tsx

It was like this import * as step from './hooks/useSteps';

but should be like this import * as step from './hooks/useSteps/useSteps';

Tillandsia answered 6/9, 2022 at 9:1 Comment(0)
A
4

The UserContext when re-exported from app/context/index.tsx throws that issue since it's a bug with Typescript on how it handled re-exports in versions prior to 3.9.

This issue was fixed as of version 3.9, so upgrade Typescript in your project to this version or later ones.

This issue was reported here and resolved with comments on the fix here

below is a workaround without version upgrades.

Have an object in your index.tsx file with properties as the imported methods and then export the object.

inside src/app/context/index.tsx

import { useUser } from './context/user-context.tsx'

const context = {
  useUser,
  otherFunctionsIfAny
}

export default context;

or this should also work,

import * as useUser from './context/user-context.tsx';

export { useUser };

export default useUser;

Then spy on them,

import * as UserContext from "src/app/context";

it("should render complete navigation when user is logged in", () => {

    jest.spyOn(UserContext, "useUser").mockReturnValue({
        user: mockUser,
        update: (user) => null,
        initialized: true,
    });
});

Ref

Good to Know:- Besides the issue with re-exports, the previous versions did not support live bindings as well i.e., when the exporting module changes, importing modules were not able to see changes happened on the exporter side.

Ex:

Test.js

let num = 10;

function add() {
    ++num;  // Value is mutated
}

exports.num = num;
exports.add = add;

index.js

enter image description here

A similar issue but due to the import's path.

The reason for this error message (JavaScript) is explained in this post TypeError: Cannot redefine property: Function.defineProperty ()

Antilog answered 12/11, 2021 at 15:48 Comment(2)
We're using TS 4.4.4, but still has the issue; I believe it's the issue of esbuild?Bedesman
Workaround if you are using ESbuild: github.com/aelbore/esbuild-jest/issues/26Erratum
C
4

Another, generic alternative to the solutions mentioned above would be to wrap Object.defineProperty() to ensure that the exported objects are configurable:

// The following Object.defineProperty wrapper will ensure that all esModule exports
// are configurable and can be mocked by Jest.
const objectDefineProperty = Object.defineProperty;
Object.defineProperty = function <T>(obj: T, propertyName: PropertyKey, attributes: PropertyDescriptor & ThisType<any>): T {
  if ((obj as { __esModule?: true })['__esModule']) {
    attributes = { ...attributes, configurable: true };
  }
  return objectDefineProperty(obj, propertyName, attributes);
};

This code should then be executed as (or within) the first script provided via the setupFilesAfterEnv Jest config option.

Caras answered 22/9, 2023 at 12:43 Comment(1)
Did not work for me, TypeError: Result of the Symbol.iterator method is not an object :thinking_face:Sonneteer
P
2

I vote for @diedu's reason: https://mcmap.net/q/235434/-jest-spyon-not-working-on-index-file-cannot-redefine-property. I have also faced this same problem in one of my test cases and i fixed it by simply adding

jest.mock("path to the file");

Eg:

import * as Obj from "path to the file";
jest.mock("path to the file");
const spy = jest.spyOn(Obj, "prop")
spy.mockImplementation(() => {
 // Desired mock Implementation 
}

Explanation on spying without mocking:

  • When you do an import without mocking that importing file path, it actually downloads / importing the entire file and its inner and nested inner imports as well.

  • And when you do a spyOn one of its property and mocking implementation for that spied property, it actually mock only that particular property of the file.

  • So this doesn't work for re-exported constants.

  • The reason behind it is, the entire file is imported as an object without having configurable as true

Explanation on mocking before spying:

  • when you add jest.mock("path to the file") just before Spying, it is not actually importing the file.

  • So there is no descriptor associated with the object and you are the one who is gonna add descriptors.

Thanks.

Pulling answered 5/9, 2023 at 18:0 Comment(0)
A
0

Try the replaceProperty method instead.

Documentation: https://jestjs.io/docs/jest-object#jestreplacepropertyobject-propertykey-value

import * as UserContext from "src/app/context";

it('should render complete navigation when user is logged in', () => {
    jest.replaceProperty(UserContext, 'useUser', {
        user: mockUser,
        update: (user) => null,
        initialized: true,
    });
})
Atticism answered 19/5, 2023 at 14:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.