ts-mockito icon indicating copy to clipboard operation
ts-mockito copied to clipboard

Unexpected behavior when partial mocking.

Open besk-cerity opened this issue 5 years ago • 7 comments

Suppose I have a class with two methods: methodA and methodB. Suppose methodB delegates to methodA. Finally, suppose that I have set up a spy on an instance of the class that will modify the behavior of methodA, but leaves methodB alone.

I would expect that when methodB is invoked, it will get the mocked behavior for methodA.

Here is an example in Java that demonstrates the behavior I would expect:

class TestClass {
  public String getMessage() {
    return "real message";
  };
  public String delegatingMethod() {
    return this.getMessage();
  };
}

public class SpyTest {
  @Test
  public void test() {
    TestClass testClass = new TestClass();
    TestClass spy = Mockito.spy(testClass);

    System.out.println("BEFORE MOCKING: testClass.getMessage()=" + spy.getMessage());
    System.out.println("BEFORE MOCKING: testClass.delegatingMethod()=" + spy.delegatingMethod());

    doReturn("spy value").when(spy).getMessage();

    System.out.println("AFTER MOCKING: testClass.getMessage()=" + spy.getMessage());
    System.out.println("AFTER MOCKING: testClass.delegatingMethod()=" + spy.delegatingMethod());
  }
}

the following lines are output:

BEFORE MOCKING: testClass.getMessage()=real message
BEFORE MOCKING: testClass.delegatingMethod()=real message
AFTER MOCKING: testClass.getMessage()=spy value
AFTER MOCKING: testClass.delegatingMethod()=spy value

When I try something similar in typescript, my code doesn't even run:

class TestClass {
  getMessage = () =>  {
    return 'real message';
  };
  delegatingMethod = () => {
    return this.getMessage();
  };
}

describe('Test partial mock', () => {
  it('test', () => {
    const testClass = new TestClass();
    console.log(`*** BEFORE MOCKING: testClass.getMessage()=${testClass.getMessage()}`);
    console.log(`*** BEFORE MOCKING: testClass.delegatingMethod()=${testClass.delegatingMethod()}`);

    const spyTestClass = spy(testClass);
    when(spyTestClass.getMessage()).thenReturn('mocked message');

    console.log(`*** AFTER MOCKING: testClass.getMessage()=${testClass.getMessage()}`);
    console.log(`*** AFTER MOCKING: testClass.delegatingMethod()=${testClass.delegatingMethod()}`);
  })
});

The following shows up in my log:

*** BEFORE MOCKING: testClass.getMessage()=real message
*** BEFORE MOCKING: testClass.delegatingMethod()=real message
*** AFTER MOCKING: testClass.getMessage()=mocked message

TypeError: Cannot read property 'get' of undefined
    at Spy.getEmptyMethodStub (node_modules/ts-mockito/src/Spy.ts:34:50)
    at Spy.Mocker.getMethodStub (node_modules/ts-mockito/src/Mock.ts:242:25)
    at TestClass.delegatingMethod (node_modules/ts-mockito/src/Mock.ts:161:37)
    at Context.it (tests/DbSupportSdkTest.ts:37:78)

So It gets to where the spied version of delegatingMethod() is called, and fails.

While experimenting, I tried moving the spy declaration to the beginning of the test (without doing any stubbing), I would expect the first two calls to execute according to their original implementations. However, instead I get the same error as above, just sooner.

Here is the updated code:

describe('Test partial mock', () => {
  it('test', () => {
    const testClass = new TestClass();
    const spyTestClass = spy(testClass);
    console.log(`*** BEFORE MOCKING: testClass.getMessage()=${testClass.getMessage()}`);
    console.log(`*** BEFORE MOCKING: testClass.delegatingMethod()=${testClass.delegatingMethod()}`);

    when(spyTestClass.getMessage()).thenReturn('mocked message');

    console.log(`*** AFTER MOCKING: testClass.getMessage()=${testClass.getMessage()}`);
    console.log(`*** AFTER MOCKING: testClass.delegatingMethod()=${testClass.delegatingMethod()}`);
  })
})

In the log, I get no log output, just the error message:

TypeError: Cannot read property 'get' of undefined
    at Spy.getEmptyMethodStub (node_modules/ts-mockito/src/Spy.ts:34:50)
    at Spy.Mocker.getMethodStub (node_modules/ts-mockito/src/Mock.ts:242:25)
    at TestClass.getMessage (node_modules/ts-mockito/src/Mock.ts:161:37)
    at Context.it (tests/DbSupportSdkTest.ts:30:73)

This seems to imply that when I spy on a class, any method that hasn't had its behavior stubbed will give me an error. That doesn't seem like it could possibly be right, and the examples in your README seem to imply otherwise. I'm hoping I'm missing something obvious.

For reference, here are my dependencies from package.json:

  "dependencies": {
    "@types/aws-lambda": "^8.10.13",
    "@types/jsonwebtoken": "^8.3.3",
    "@types/yesql": "^3.2.1",
    "aws-sdk": "^2.544.0",
    "jsonwebtoken": "^8.5.1",
  },
  "devDependencies": {
    "@types/chai": "^4.1.7",
    "@types/mocha": "^5.2.6",
    "chai": "^4.2.0",
    "mocha": "^6.0.2",
    "ts-mockito": "^2.5.0",
    "ts-node": "^8.0.2",
    "typescript": "^3.3.3333"
  }

And, in case it matters, my tsconfig.json:

{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "strict": true,
    "typeRoots": [
      "node_modules/@types"
    ],
    "types": [
      "node"
    ],
    "esModuleInterop": true
  },
  "exclude": [
    "**/tests"
  ]
}

besk-cerity avatar Feb 06 '20 20:02 besk-cerity

Hi @besk-cerity, since you don't have class methods but class properties holding functions, you need to mock the property.

Could you please try like this?: when(spyTestClass.getMessage).thenReturn('mocked')

lordrip avatar Feb 06 '20 21:02 lordrip

Thanks for the quick response.

That actually doesn't compile:

TS2345: Argument of type '"mocked message"' is not assignable to parameter of type '() => string'.

Instead I tried:

    const spyTestClass = spy(testClass);
    when(spyTestClass.getMessage).thenReturn(() => 'mocked message');

Now when I run the test, It fails with the message:

TypeError: testClass.getMessage is not a function
    at Context.it (tests/DbSupportSdkTest.ts:35:72)

Your suggestion makes sense, but in my example I was able to get the mocked version of getMessage() to behave correctly. It was the other method (that didn't have any stubbing) that was causing problems. What I was seeing is that if I spied on a class and then tried call a method on it that hadn't been stubbed, the method invocation failed with TypeError: Cannot read property 'get' of undefined.

besk-cerity avatar Feb 06 '20 21:02 besk-cerity

it does seem to be a function declaration issue though.

I rewrote the class as follows:

class TestClass {
  getMessage() {
    return 'real message';
  };
  delegatingMethod() {
    return this.getMessage();
  };
}

Now when I run the test:

describe('Test partial mock', () => {
  it('test', () => {
    const testClass = new TestClass();
    const spyTestClass = spy(testClass);
    console.log(`*** BEFORE MOCKING: testClass.getMessage()=${testClass.getMessage()}`);
    console.log(`*** BEFORE MOCKING: testClass.delegatingMethod()=${testClass.delegatingMethod()}`);

    when(spyTestClass.getMessage()).thenReturn('mocked message');

    console.log(`*** AFTER MOCKING: testClass.getMessage()=${testClass.getMessage()}`);
    console.log(`*** AFTER MOCKING: testClass.delegatingMethod()=${testClass.delegatingMethod()}`);
  })
});

I get the following output.

*** BEFORE MOCKING: testClass.getMessage()=real message
*** BEFORE MOCKING: testClass.delegatingMethod()=real message
*** AFTER MOCKING: testClass.getMessage()=mocked message
*** AFTER MOCKING: testClass.delegatingMethod()=mocked message

besk-cerity avatar Feb 06 '20 21:02 besk-cerity

Any idea how I can work around this issue using arrow functions. Sometimes the context calls for them. Plus, I'd prefer not to rewrite a bunch of classes because there was a possibility I might partially mock it.

besk-cerity avatar Feb 06 '20 21:02 besk-cerity

Sure, Tomorrow I'll check and I let you know

lordrip avatar Feb 06 '20 22:02 lordrip

Sorry for the late response @besk-cerity. For those partially mocked objects, Spy is inspecting the object's property descriptor, and evaluating getters and setters. Since arrow function doesn't have it, then the issue comes.

I don't know if this could be tackled in another fashion or not. I need to investigate further to check.

lordrip avatar Feb 12 '20 12:02 lordrip

Thanks for continuing to investigate. I'm surprised this hadn't come up before.

besk-cerity avatar Feb 12 '20 14:02 besk-cerity