Search code examples
typescriptunit-testingmockingjasmineprivate

Spy on an attribute/function of a private variable with Jasmine


I have a function with variable functionality based on file it reads, which is controlled via a Map it keeps in memory:

file1.ts

function f1(x: number): number {
  // do far-reaching things
  return 1;
}

function f2(x: number): number {
  // do different far-reaching things
  return 2;
}

function f3(x: number): number {
  // do still-different far-reaching things
  return 3;
}

const myMap: Map<string, (number) => number> = new Map<string, () => void>([
  ['key1', f1],
  ['key2', f2],
  ['key3', f3],
]

export function doThing(filename: string): number {
  // open file, make some database calls, and figure out the name of a key
  // ...
  let fileToExecute = myMap.get(key);
  return fileToExecute(someValueDerivedFromFile);
}

f1, f2, and f3 all do much more than shown here, and each requires a lot of mocks to be tested successfully.

As the code grows more developed and use cases continue, there will be an arbitrary number of functions that might need to be called, based on an expanding set of inputs. doThing() is complicated and takes its information from a lot of different sources, including both the contents of given file and a database, which helps it choose which file to execute. From a client's point of view, doThing() is the only function it cares about. Thus, it's the only one exported by this file.

I'm trying to test the mechanism in doThing() that figures out what key it should use. I don't want to mock f1, f2, and f3 specifically - I want to present many more options, pointed to by other things I'm mocking for doThing(). However, to check if it's calling the correct fake method, I need to figure out which fake method it's calling. My attempted solution uses typecasting to try to pull the private myMap out of the file and then spy on its get() method:

file1.spec.ts

import * as file1 from '../src/file1'
...
it("calls the correct fake method", () => {
  // lots of other mocks
  let spies = [
    jasmine.createSpy('f1spy').and.returnValue(4),
    jasmine.createSpy('f2spy').and.returnValue(5),
    jasmine.createSpy('f3spy').and.returnValue(6),
    ...
  ]
  let mockMap = spyOn((file1 as any).myMap, 'get').and.callFake((key) => {  // this fails
    var spy;
    switch(key) {
      case 'key1': spy = spies[0]; break;
      case 'key2': spy = spies[1]; break;
      case 'key3': spy = spies[2]; break;
      ...
    }
    return spy;
  }

  result = file1.doThing(...);

  expect(spies[0]).not.toHaveBeenCalled();
  expect(spies[1]).toHaveBeenCalledWith(7);
  expect(spies[2]).not.toHaveBeenCalled();
});

However, I get an error on the annotated line above: Error: <spyOn> : could not find an object to spy upon for get(). Upon further investigation (i.e. the step-by-step debugger), it turns out that the file1 object I imported only has doThing(), and doesn't have any of its other private variables.

How do I successfully mock the key-value transformation here - which means, in this case, spying on attributes of a private variable, so I can get my spies in the right place? Either replacing myMap entirely or replacing myMap.get() would be an option, if either is possible.


Solution

  • General idea: use rewire.

    Using rewire, we will override your private functions with spy functions.

    However, your const myMap needs to be modified. Because when you do ['key1', f1] - it stores current implementation of f1, so we can't override it after myMap was initialized. One of the ways to overcome this - use ['key1', args => f1(args)]. This way, it will not store f1 function, only the wrapper to call it. You might achieve the same by using apply() or call().

    Example implementation:

    file1.ts:

    function f1(): number {
      // do far-reaching things
      return 1;
    }
    
    const myMap: Map<string, (x: number) => number> = new Map([
      ['key1', (...args: Parameters<typeof f1>) => f1(...args)],
    ]);
    
    export function doThing(): number {
      const key = 'key1';
      const magicNumber = 7;
      const fileToExecute = myMap.get(key);
      return fileToExecute(magicNumber);
    }
    

    file1.spec.ts:

    import * as rewire from 'rewire';
    
    it('calls the correct fake method', () => {
      const spies = [jasmine.createSpy('f1spy').and.returnValue(4)];
    
      const myModule = rewire('./file1');
    
      myModule.__set__('f1', spies[0]);
    
      myModule.doThing();
    
      expect(spies[0]).toHaveBeenCalledWith(7);
    });
    

    In order to use rewire with typescript, you might want to use babel, etc.

    For proof of concept, I'm just going to compile it:

    ./node_modules/.bin/tsc rewire-example/*
    

    and run tests:

    ./node_modules/.bin/jasmine rewire-example/file1.spec.js
    

    Which will run successfully:

    Started
    .
    
    
    1 spec, 0 failures
    

    UPDATE

    Without modifications to myMap:

    file1.spec.ts:

    import * as rewire from 'rewire';
    
    it('calls the correct fake method', () => {
      const spies = [
        jasmine.createSpy('f1spy').and.returnValue(4),
        jasmine.createSpy('f2spy').and.returnValue(5),
        // ...
      ];
    
      const myModule = rewire('./file1');
    
      const myMockedMap: Map<string, (x: number) => number> = new Map();
    
      (myModule.__get__('myMap') as typeof myMockedMap).forEach((value, key) =>
        myMockedMap.set(key, value)
      );
    
      myModule.__set__('myMap', myMockedMap);
    
      // ...
    });