Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 436
Green Cloak Guy Avatar asked Apr 03 '20 21:04

Green Cloak Guy


3 Answers

  1. Jasmine, as far as I know, doesn't use any compiler-kind magic, so it's impossible for Jasmine to get access to you private variables.

  2. 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.

    but this doesn't mean that you should deprive your tests from the access to over staff. Instead you can create two files

    file1.ts - for a client

    import { doThing } from "./file1_implementation"
    export doThing
    

    and file1_implementation.ts - for your tests

    export function f1(...) ...
    export function f2(...) ...
    export function f3(...) ...
    export const myMap ...
    export function doThing(...) ...
    

    then in file1.spec.ts you can use file1_implementation.ts and you'll have access to everything you need

    import * as file1 from '../src/file1_implementation'
    ...
    
like image 56
x00 Avatar answered Oct 08 '22 16:10

x00


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);

  // ...
});
like image 3
Maxim Mazurok Avatar answered Oct 08 '22 16:10

Maxim Mazurok


Can you just make file1 into a class? Then you can definitely access its private methods / attributes from jasmine.

so file1 becomes:

export class FileHelper {

  private f1 () : void {}
  private f2 () : void {}
  private f3 () : void {}

  private myMap: Map<whatever, whatever>;

  public doThing () : void {}

}

then in your spec:

let mapSpy: jasmine.Spy;
let myFileHelper: FileHelper;

beforeEach(() => {
  myFileHelper = new FileHelper();
  mapSpy = spyOn(<any>myFileHelper, 'myMap').and.callFake(() => {
    //whatever you were doing
  });
});


it('should do whatever', () => {

});
like image 2
Dwayne Hua Avatar answered Oct 08 '22 15:10

Dwayne Hua