skip to Main Content
// my-func.test.js
import { jest } from '@jest/globals';
import { myFunc } from './my-func.js';
import fs from 'node:fs';

const mock = jest.fn().mockReturnValue({isDirectory: () => true});
jest.spyOn(fs, 'statSync').mockImplementation(mock);

it('should work', () => {
  expect(myFunc('/test')).toBeTruthy();

  expect(mock).toHaveBeenCalled();
});

The test passes with this implementation:

// my-func.js
import fs from 'node:fs';
// import { statSync } from 'node:fs';

export function myFunc(dir) {
  const stats = fs.statSync(dir, { throwIfNoEntry: false });
  // const stats = statSync(dir, { throwIfNoEntry: false });
  return stats.isDirectory();
}

However, if the implementation is changed to (note the different importing mechanism):

// my-func.js
// import fs from 'node:fs';
import { statSync } from 'node:fs';

export function myFunc(dir) {
  // const stats = fs.statSync(dir, { throwIfNoEntry: false });
  const stats = statSync(dir, { throwIfNoEntry: false });
  return stats.isDirectory();
}

Jest will fail with:

Cannot read properties of undefined (reading 'isDirectory')
TypeError: Cannot read properties of undefined (reading 'isDirectory')

How do I spy on or mock statSync using the import { statSync } from 'node:fs'; syntax? Is it even possible?

Note that the test is being run in the ES module mode:

# package.json
{
  "type": "module",
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    ...
  },
  "devDependencies": {
    "@eslint/js": "^9.17.0",
    "@jest/globals": "^29.7.0",
    "globals": "^15.14.0",
    "jest": "^29.7.0",
    ...
  },
  ...
}

2

Answers


  1. Jest works by replacing or spying on properties of objects (like fs.statSync when fs is imported as a whole). The issue with a named import is that there’s no object or property available to mock

    Login or Signup to reply.
  2. Your updated implementation fails because the code under test is no longer using the fs module object your test is spying on – the named import brings in the relevant function directly. However this tells you a few things:

    • Firstly, that it’s not a useful test – tests should give you the confidence to refactor your implementation, but here a behaviour-preserving change gives a false positive failing test; and
    • Secondly, that the code doesn’t work – if the real statSync returns undefined, surely the function should return false (or at least throw a more informative error)?

    Using jest.spyOn(fs, 'statSync') means your test is coupled to the implementation detail that the code it’s testing uses import fs from 'node:fs'. To successfully mock the whole fs module instead, you need to refer to Module mocking in ESM. Applying this guidance to your specific case:

    import { jest } from '@jest/globals';
    
    const mock = jest.fn().mockReturnValue({ isDirectory: () => true });
    
    // 1. need to use (unstable!) ES module mocking
    jest.unstable_mockModule('node:fs', () => {
      const module = { statSync: mock };
      // 2. to tolerate default or named import, provide both
      return { default: module, ...module };
    });
    
    // 3. module needs dynamically importing _after_ mock is registered
    const { myFunc } = await import('./my-func.js');
    
    it('should work', () => {
      expect(myFunc('/test')).toBeTruthy();
    
      expect(mock).toHaveBeenCalled();
    });
    

    Note that it’s not always the case that the default export is an object containing all of the named exports; as always, it’s important to ensure that the test double has the same interface as the thing it’s replacing.

    This test will now pass with either implementation (using named or default export to access statSync).


    However, given what you’re trying to test, another way to test it (that depends on even fewer implementation details – you can now use methods other than statSync if desired) is to actually try it on the local file system. For example, assuming it’s in the root of a conventional npm project (alternatively you could use a test fixtures directory with a known setup):

    import { myFunc } from "./my-func.js";
    
    it('should work for a directory', () => {
      expect(myFunc('./node_modules')).toBe(true);
    });
    
    it('should work for a file', () => {
      expect(myFunc('./package.json')).toBe(false);
    });
    
    it('should work for a missing object', () => {
      expect(myFunc('./missing')).toBe(false);
      // or something like:
      // expect(() => myFunc('./missing')).toThrow(/could not be found/);
    });
    

    As noted above, this shows that there’s one case where the behaviour probably isn’t correct:

     FAIL  ./my-func.test.js
      ✓ should work for a directory (1 ms)
      ✓ should work for a file
      ✕ should work for a missing object (1 ms)
    
      ● should work for a missing object
    
        TypeError: Cannot read properties of undefined (reading 'isDirectory')
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search