Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How Sinon stubs works under the hood?

In the last months I've been working with JavaScript and using SinonJS to stub some behaviours. I've manage to make it work, I've stubbed lots of methods and everything works great.

But I still have some questions about how Sinon works under the table. I guess I'm saying Sinon, but this question might apply to every other library designed to mock/stub/spy.

The language I've worked most in the past years was Java. In Java, I've used Mockito to mock/stub the dependencies and dependency injection. I used to import the Class, annotate the field with @Mock and pass this mock as param to the class under test. It's easy to me to see what I'm doing: mocking a class and passing the mock as param.

When I first start working with SinonJS, I saw something like this:

moduleUnderTest.spec.js

const request = require('request')

describe('Some tests', () => {
  let requestStub

  beforeEach(() => {
    requestStub = sinon.stub(request, 'get')
  })

  afterEach(() => {
    request.get.restore()
  })

  it('A test case', (done) => {
    const err = undefined
    const res = { statusCode: 200 }
    const body = undefined
    requestStub
      .withArgs("some_url")
      .yields(err, res, body)

    const moduleUnderTest = moduleUnderTest.someFunction()

    // some assertions
    })
})

moduleUnderTest.js

const request = require('request')
// some code
  request
    .get("some_url", requestParams, onResponse)

And it works. When we run the tests, the request inside the implementation moduleUnderTest.js calls the stubbed version of request module.

My question is: why this works?

When the test calls the implementation execution, the implementation requires and uses the request module. How Sinon (and other mock/stub/spy libraries) manage to make the implementation call the stub if we are not passing the stubbed object as param (injecting it)? Sinon replaces the whole request module (or part of it) during the test execution, making the stub available via require('request') and then restore it after the tests are finished?

I've tried to follow the logic in stub.js code in Sinon repo, but I'm not very familiar with JavaScript yet. Sorry the long post and sorry if this is a dummy question. :)

like image 896
Lucas Beier Avatar asked Jul 05 '17 17:07

Lucas Beier


1 Answers

How Sinon (and other mock/stub/spy libraries) manage to make the implementation call the stub if we are not passing the stubbed object as param (injecting it)?

Let's write our own simple stubbing util, shall we?

For brevity it is very limited, provides no stubbing API and only returns 42 every time. But this should be enough to show how Sinon works.

function stub(obj, methodName) {
    // Get a reference to the original method by accessing
    // the property in obj named by methodName.
    var originalMethod = obj[methodName];

    // This is actually called on obj.methodName();
    function replacement() {
        // Always returns this value
        return 42;

        // Note that in this scope, we are able to call the
        // orignal method too, so that we'd be able to 
        // provide callThrough();
    }

    // Remember reference to the original method to be able 
    // to unstub (this is *one*, actually a little bit dirty 
    // way to reference the original function)
    replacement.originalMethod = originalMethod;

    // Assign the property named by methodName to obj to 
    // replace the method with the stub replacement
    obj[methodName] = replacement;

    return {
        // Provide the stub API here
    };
}

// We want to stub bar() away
var foo = {
    bar: function(x) { return x * 2; }
};

function underTest(x) {
    return foo.bar(x);
}

stub(foo, "bar");
// bar is now the function "replacement"
// foo.bar.originalMethod references the original method

underTest(3);

Sinon replaces the whole request module (or part of it) during the test execution, making the stub available via require('request') and then restore it after the tests are finished?

require('request') will return the same (object) reference, that was created inside the "request" module, every time it's called.

See the NodeJS documentation:

Modules are cached after the first time they are loaded. This means (among other things) that every call to require('foo') will get exactly the same object returned, if it would resolve to the same file.

Multiple calls to require('foo') may not cause the module code to be executed multiple times. This is an important feature. With it, "partially done" objects can be returned, thus allowing transitive dependencies to be loaded even when they would cause cycles.

If it has not become clear yet: it replaces only a single method of the object reference returned from the "requested" module, it does not replace the module.

This is why you do not call

stub(obj.method)

since this would only pass a reference to the function method. Sinon would be unable to change the object obj.

The documentation further says:

If you want to have a module execute code multiple times, then export a function, and call that function.

That means, if a module looks like this:

foo.js

module.exports = function() {
    return {
        // New object everytime the required "factory" is called
    };
};

main.js

        // The function returned by require("foo") does not change
const   moduleFactory = require("./foo"),
        // This will change on every call
        newFooEveryTime = moduleFactory();

Such module factory functions cannot be stubbed since you can't replace what was exported by require() from within the module.


In Java, I've used Mockito to mock/stub the dependencies and dependency injection. I used to import the Class, annotate the field with @Mock and pass this mock as param to the class under test. It's easy to me to see what I'm doing: mocking a class and passing the mock as param.

In Java, where you (nothing) can not reassign a method to a new value, this cannot be done. Instead new bytecode is generated that makes a mock provide the same interface like the mocked class. In contrast to Sinon, with Mockito all methods are mocked and should be explictly instructed to call the real method.

Mockito will effectively call mock() and finally assign the result to the annotated field.

But you'd still need to replace/assign the mock to a field in the class under test or pass it to a tested method since that mock on its own is not helpful.

@Mock
Type field;

or

Type field = mock(Type.class)

Is actually equivalent to Sinons mocks

var myAPI = { method: function () {} };
var mock = sinon.mock(myAPI);

mock.expects("method").once().throws();

The method is first replaced with the expects() call:

wrapMethod(this.object, method, function () {
    return mockObject.invokeMethod(method, this, arguments);
});
like image 53
try-catch-finally Avatar answered Oct 18 '22 19:10

try-catch-finally