1

I have a function that does a lot of things, but among them is that it copies a file to a special directory, does something with it (calls something to interact with that file without using the fs module), and then deletes the copied file once finished.

import { copyFileSync, unlinkSync } from 'fs';

myOtherFunction(path: string) {
  ...
}

myIOFunction(somePath: string) {
  var copyPath = resolve('otherDir/file2.csv');
  copyFileSync(somePath, copyPath);
  try {
    myOtherFunction(copyPath);
  } finally {
    unlinkFileSync(copyPath);
  }
}

export myFunction() {
  ...
  myIOFunction(resolve('file1.csv));
}

Since only myFunction() is exported (it's the only thing that should be able to be directly interacted with), I have to unit test myOtherFunction() and myIOFunction() through it. Part of that is copyFileSync and unlinkFileSync.

My test looks something like this:

import * as fs from 'fs';
import myFunction from './myFile';

...

it("tests something involving input/output", () => {
  mockCopyFile = spyOn(fs, 'copyFileSync');
  mockUnlinkFile = spyOn(fs, 'unlinkSync');

  ...

  myFunction();

  expect(mockCopyFile).toHaveBeenCalledWith(resolve('file1.csv'), resolve('otherDir/file2.csv'));
  expect(mockUnlinkFile).toHaveBeenCalled();

  ...
});

The test is failing with the errors that neither mockCopyFile nor mockUnlinkFile is called. The problem is that the corresponding functions are called - I've stepped through the test with a debugger, and they execute without issue. So the problem must be that the spies aren't properly attaching themselves.

I don't know how to get them to be called. I've tried doing import * as fs from 'fs' and fs.copyFileSync()/fs.unlinkFileSync() in the file being tested. I've tried putting the mocks in a beforeAll() function. Neither solution helps. I'm mocking several other, not immediately relevant, method calls in the same test spec, and they're all working exactly as intended; it's only this one that isn't, and I can't figure out why.


My package.json includes the following dependencies:

  "scripts": {
    "test": "tsc && jasmine",
  },
"devDependencies": {
    "@types/jasmine": "^3.5.10",
    "@types/node": "^13.7.7",
    "@types/pg": "^7.14.3",
    "copyfiles": "^2.2.0",
    "jasmine": "^3.5.0",
    "jasmine-core": "^3.5.0",
    "jasmine-ts": "^0.3.0",
    "js-yaml": "^3.13.1",
    "mock-fs": "^4.11.0",
    "morgan": "^1.10.0",
    "nodemon": "^2.0.2",
    "swagger-ui-express": "^4.1.3",
    "ts-node": "^8.7.0",
    "typescript": "^3.8.3"
  },
  "dependencies": {
    "@types/express": "^4.17.3",
    "chokidar": "^3.3.1",
    "cors": "^2.8.5",
    "csv-writer": "^1.6.0",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "murmurhash": "0.0.2",
    "pg": "^7.18.2",
    "pg-format": "^1.0.4",
    "winston": "^3.2.1"
  }

and my jasmine.json looks like so:

{
  "spec_dir": "dist",
  "spec_files": [
    "**/*[sS]pec.js"
  ],
  "helpers": [
    "helpers/**/*.js"
  ],
  "stopSpecOnExpectationFailure": false,
  "random": true
}

And tsconfig:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "module": "commonjs",
    "esModuleInterop": true,
    "target": "es6",
    "moduleResolution": "node",
    "sourceMap": true,
    "outDir": "dist",
    "typeRoots": [
      "node_modules/@types",
      "node_modules/@types/node"
    ],
  },
  "lib": [
    "es2015"
  ]
}
Green Cloak Guy
  • 23,793
  • 4
  • 33
  • 53

1 Answers1

4

Jasmine spyOn mocking function returns a Spy class object which does not represent any function call, but it has helper methods regarding mocking the function. You have to call expect directly to fs.<function> in order to check if it's called:

import * as fs from 'fs';
import * as path from 'path';
import { myFunction } from '../src/myFunction';

describe('MyFunc', () => {
  it("tests something involving input/output", () => {
    spyOn(fs, 'copyFileSync');
    spyOn(fs, 'unlinkSync');

    myFunction();

    expect(fs.copyFileSync).toHaveBeenCalledWith(
      path.resolve('file1.csv'),
      path.resolve('otherDir/file2.csv')
    );
    expect(fs.unlinkSync).toHaveBeenCalled();
  });
});

You can test a simple repro example using this GitHub repo: https://github.com/clytras/fs-jasminte-ts-mocking

git clone https://github.com/clytras/fs-jasminte-ts-mocking.git
cd fs-jasminte-ts-mockin
npm i
npm run test

UPDATE

It appears that you're having esModuleInterop set to true inside tsconfig.json. That means that when you import * as fs from 'fs' that won't keep a single instance of the fs object.

You can set esModuleInterop to false and have your tests passing with toHaveBeenCalled and toHaveBeenCalledWith, but that may break some other functionality of your project. You can read more about what esModuleInterop does here Understanding esModuleInterop in tsconfig file.

If you don't want to set esModuleInterop to false, then you have to import fs as in ES6 Javascript like this:

import fs from 'fs'; // Use plain default import instead of * as
import path from 'path';
import { myFunction } from '../src/myFunction';

describe('MyFunc', () => {
  it("tests something involving input/output", () => {
    spyOn(fs, 'copyFileSync');
    spyOn(fs, 'unlinkSync');

    myFunction();

    expect(fs.copyFileSync).toHaveBeenCalledWith(
      path.resolve('file1.csv'),
      path.resolve('otherDir/file2.csv')
    );
    expect(fs.unlinkSync).toHaveBeenCalled();
  });
});

I also noticed these things missing from your config files:

  1. You have to use jasmine-console-reporter if you don't:

    • npm i -D jasmine-console-reporter
    • Change test script inside package.json to be "test": "tsc && jasmine --reporter=jasmine-console-reporter"
  2. Inside jasmine.json, add ts-node/register/type-check.js to helpers like:

    {
      ...
      "helpers": [
        "helpers/**/*.js",
        "../node_modules/ts-node/register/type-check.js"
      ],
    }
    

Now your tests should be passing.

Christos Lytras
  • 36,310
  • 4
  • 80
  • 113
  • I initially thought this worked, but upon further scrutiny, this didn't appear to work, regardless of whether the other function being tested invokes it as `copyFileSync` or `fs.copyFileSync`. – Green Cloak Guy Apr 02 '20 at 15:19
  • Can you please provide some more details on how it didn't work and maybe update your question with the errors you faced? This is about mocking `fs` functions. I'll provide an example with some debug logs to see how the functions are being called. – Christos Lytras Apr 02 '20 at 15:25
  • I'm not sure how much more detail I can provide - the error message is just `Expected spy unlinkSync to have been called once. It was called 0 times.` From using a debugger, the name `fs.copyFileSync` seems to be replaced by a spy immediately after I call `spyOn()`, but then as soon as I enter `myFunction()`, it's back to normal. I can actually see the file appearing and disappearing in my filesystem, which proves that the spy isn't sticking for whatever reason. – Green Cloak Guy Apr 02 '20 at 15:27
  • Can you please provide your `package.json` to see the versions using? It's working on my end with latest `jasmine@3.4.0` and it doesn't actually call any of the mocking functions. If we want to actually call the functions, we shall use [`callThrough`](https://jasmine.github.io/api/edge/SpyStrategy.html) like `spyOn(fs, 'copyFileSync').and.callThrough()`. Did you try to clone and test the repo I posted? Does the repo code serve as a good reproduction of your case and if not, can you provide a minimal reproduction example? Because the github repo code literally has a function setup like yours. – Christos Lytras Apr 02 '20 at 15:43
  • Your repo code works on my machine; I don't know why mine isn't. My jasmine version is actually 3.5.0, along with `jasmine-core@3.5.0` and `jasmine-ts@0.3` and `@types/jasmine@3.5.10`. Does it matter if this file was imported/loaded (but nothing in it was called) by something else in the program (one of the other testing files, in a roundabout way) before this failing test was run? – Green Cloak Guy Apr 02 '20 at 16:24
  • No, it does not matter. I have duplicated the test and execute it before the one with the `fs` mocks and it passes everything (*fs functions got to execute before `spyOn`*). It seems I have a different setup, because my project does not use `jasmine-ts` at all, it uses `ts-node/register/type-check.js` inside `jasmine.json`. Can you please provide the contents of your `package.json` and `jasmine.json` so I can reproduce your exact project environment? – Christos Lytras Apr 02 '20 at 16:53
  • I've edited the contents of those files into my question. – Green Cloak Guy Apr 02 '20 at 18:00
  • Thank you, I forgot to ask you to add the also very important `tsconfig.json` file. I have to check if you have `"esModuleInterop": true` inside. Please also add `tsconfig.json`. – Christos Lytras Apr 02 '20 at 18:25
  • I do have `esModuleInterop` set to true in tsconfig, though I've added that to my question as well. – Green Cloak Guy Apr 02 '20 at 18:33
  • Please check my updated answer. You have to either set `esModuleInterop` to `false` inside `tsconfig.json`, or import `fs` as in JS like `import fs from 'fs'`. In any case, I added some remarks like that you have to use [`jasmine-console-reporter`](https://www.npmjs.com/package/jasmine-console-reporter) and add `ts-node/register/type-check.js` to `helpers` array inside `jasmine.json` config file. – Christos Lytras Apr 02 '20 at 20:08
  • That was the issue. Thanks so much. – Green Cloak Guy Apr 02 '20 at 20:14