10

I am trying to mock the promise version of fs.writeFile using Jest, and the mocked function is not being called.

Function to be tested (createFile.js):

const { writeFile } = require("fs").promises;

const createNewFile = async () => {
    await writeFile(`${__dirname}/newFile.txt`, "Test content");
};

module.exports = {
    createNewFile,
};

Jest Test (createFile.test.js):

const fs = require("fs").promises;
const { createNewFile } = require("./createFile.js");

it("Calls writeFile", async () => {
    const writeFileSpy = jest.spyOn(fs, "writeFile");

    await createNewFile();
    expect(writeFileSpy).toHaveBeenCalledTimes(1);

    writeFileSpy.mockClear();
});

I know that writeFile is actually being called because I ran node -e "require(\"./createFile.js\").createNewFile()" and the file was created.

Dependency Versions

  • Node.js: 14.1.0
  • Jest: 26.6.3

-- Here is another attempt at the createFile.test.js file --

const fs = require("fs");
const { createNewFile } = require("./createFile.js");

it("Calls writeFile", async () => {
    const writeFileMock = jest.fn();

    jest.mock("fs", () => ({
        promises: {
            writeFile: writeFileMock,
        },
    }));

    await createNewFile();
    expect(writeFileMock).toHaveBeenCalledTimes(1);
});

This throws the following error:

ReferenceError: /Users/danlevy/Desktop/test/src/createFile.test.js: The module factory of `jest.mock()` is not allowed to reference any out-of-scope variables.
    Invalid variable access: writeFileMock
Dan Levy
  • 3,931
  • 4
  • 28
  • 48

3 Answers3

13

Since writeFile is destructured at import time instead of being consistently referred as fs.promises.writeFile method, it cannot be affected with spyOn.

It should be mocked as any other module:

jest.mock("fs", () => ({
  promises: {
    writeFile: jest.fn().mockResolvedValue(),
    readFile: jest.fn().mockResolvedValue(),
  },
}));

const fs = require("fs");

...

await createNewFile();
expect(fs.promises.writeFile).toHaveBeenCalledTimes(1);

It make sense to mock fs scarcely because unmocked functions provide side effects and potentially have negative impact on test environment.

Yet Another User
  • 2,627
  • 3
  • 18
  • 27
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • In this case, how would I get access to the mocked version of `writeFile` so that I can call something like `toHaveBeenCalled(..)` on the mocked function? I tried simply setting `writeFile` to a variable outside of `jest.mock(..)`, but I get the following error: `The module factory of "jest.mock()" is not allowed to reference any out-of-scope variables` with the out-of-scope variable being the variable outside of `jest.mock(..)`. See my attempt in my original question. – Dan Levy Nov 21 '20 at 22:10
  • The error tries to protect you from accessing variables that may not be declared at the time when they are used because jest.mock is hoisted, you can name the variable as `let mockWriteFile = jest.fn()...` but at your own risk, see the explanation here https://jestjs.io/docs/en/es6-class-mocks#calling-jestmockdocsenjest-objectjestmockmodulename-factory-options-with-the-module-factory-parameter . `fs` should be imported in test file, this way fs.promises.writeFile can be accessed for assertion. – Estus Flask Nov 21 '20 at 22:18
  • I tried importing `fs` in the test file as well and still had the same error. See updated code in OP. – Dan Levy Nov 21 '20 at 22:21
  • Ok and I see what you're saying about the hoisting. Thanks for the link! So at the end of the day, is there a good solution for doing what I was originally trying to do (simply checking if `writeFile` gets called) using just Jest and nothing else (e.g. not using `mock-fs`)? – Dan Levy Nov 21 '20 at 22:24
  • 1
    jest.mock should be located at top level, otherwise it doesn't have a chance to be evaluated before an import it's supposed to affect. The purpose of importing `fs` is to refer to it instead of writeFileSpy variable in test scope because this way it's efficiently `writeFileSpy === fs.promises.writeFile`. I updated the post for clarity. – Estus Flask Nov 21 '20 at 22:31
6

Mock "fs/promises" async functions in jest

Here is a simple example using fs.readdir(), but it would also apply to any of the other async fs/promises functions.

files.service.test.js

import fs from "fs/promises";
import FileService from "./files.service";

jest.mock("fs/promises");

describe("FileService", () => {
  var fileService: FileService;

  beforeEach(() => {
    // Create a brand new FileService before running each test
    fileService = new FileService();

    // Reset mocks
    jest.resetAllMocks();
  });

  describe("getJsonFiles", () => {
    it("throws an error if reading the directory fails", async () => {
      // Mock the rejection error
      fs.readdir = jest.fn().mockRejectedValueOnce(new Error("mock error"));

      // Call the function to get the promise
      const promise = fileService.getJsonFiles({ folderPath: "mockPath", logActions: false });

      expect(fs.readdir).toHaveBeenCalled();
      await expect(promise).rejects.toEqual(new Error("mock error"));
    });

    it("returns an array of the .json file name strings in the test directory (and not any other files)", async () => {
      const allPotentialFiles = ["non-json.txt", "test-json-1.json", "test-json-2.json"];
      const onlyJsonFiles = ["test-json-1.json", "test-json-2.json"];

      // Mock readdir to return all potential files from the dir
      fs.readdir = jest.fn().mockResolvedValueOnce(allPotentialFiles);

      // Get the promise
      const promise = fileService.getJsonFiles({ folderPath: "mockPath", logActions: false });

      expect(fs.readdir).toBeCalled();
      await expect(promise).resolves.toEqual(onlyJsonFiles); // function should only return the json files
    });
  });
});

files.service.ts

import fs from "fs/promises";

export default class FileService {
  constructor() {}

  async getJsonFiles(args: FilesListArgs): Promise<string[]> {
    const { folderPath, logActions } = args;
    try {
      // Get list of all files
      const files = await fs.readdir(folderPath);

      // Filter to only include JSON files
      const jsonFiles = files.filter((file) => {
        return file.includes(".json");
      });

      return jsonFiles;
    } catch (e) {
      throw e;
    }
  }
}
Matthew Rideout
  • 7,330
  • 2
  • 42
  • 61
3

I know this is an old thread, but in my case, I wanted to handle different results from readFile (or writeFile in your case). So I used the solution Estus Flask suggested with the difference that I handle each implementation of readFile in each test, instead of using mockResolvedValue.

I'm also using typescript.

import { getFile } from './configFiles';

import fs from 'fs';
jest.mock('fs', () => {
  return {
    promises: {
      readFile: jest.fn()
    }
  };
});

describe('getFile', () => {
   beforeEach(() => {
      jest.resetAllMocks();
   });

   it('should return results from file', async () => {
      const mockReadFile = (fs.promises.readFile as jest.Mock).mockImplementation(async () =>
        Promise.resolve(JSON.stringify('some-json-value'))
      );

      const res = await getFile('some-path');

      expect(mockReadFile).toHaveBeenCalledWith('some-path', { encoding: 'utf-8' });

      expect(res).toMatchObject('some-json-value');
   });

   it('should gracefully handle error', async () => {
      const mockReadFile = (fs.promises.readFile as jest.Mock).mockImplementation(async () =>
        Promise.reject(new Error('not found'))
      );

      const res = await getFile('some-path');

      expect(mockReadFile).toHaveBeenCalledWith('some-path', { encoding: 'utf-8' });

      expect(res).toMatchObject('whatever-your-fallback-is');
   });
});

Note that I had to cast fs.promises.readFile as jest.Mock in order to make it work for TS.

Also, my configFiles.ts looks like this:

import { promises as fsPromises } from 'fs';

const readConfigFile = async (filePath: string) => {
  const res = await fsPromises.readFile(filePath, { encoding: 'utf-8' });
  return JSON.parse(res);
};

export const getFile = async (path: string): Promise<MyType[]> => {
  try {
    const fileName = 'some_config.json';
    return readConfigFile(`${path}/${fileName}`);
  } catch (e) {
    // some fallback value
    return [{}];
  }
};
Vasileios Pallas
  • 4,801
  • 4
  • 33
  • 50