[Solved] Mocking a function in a node test



  • I'm working on making better tests for the youtube plugin. The last bug was that it didn't handle the null return that happened when you ask the API for a nonexistent video.

    So I figured that I'd refactor the bit that makes the actual request into its own function. So I have this:

    YoutubeLite.apiRequest = function( videoId, callback ){  /* stuff happens here */}
    
    YoutubeLite.fetchSnippet = function( videoId, callback ){
        /* calls apiRequest, does stuff with the result */
    }
    

    Then, in my test, I could do:

    var youtubeLite = require( '../library.js'); // the file represented above
    
    youtubeLite.apiRequest = function( videoId, callback ){ /* stuff that simulates the actual API call */}
    

    And then in my tests I make a call to fetchSnippet to see what it would do. However, the function I defined in my test file never gets called, so of course the test fails.

    "That makes no sense, but OK," says I, "let's look for a more sophisticated solution." I end up looking at stuff like sinon, figuring that a mock is what I need. But then I look at the examples and read the documentation and it makes absolutely no sense what's going on.

    How can I make this happen?


  • sockdevs

    @boomzilla said in Mocking a function in a node test:

    How can I make this happen?

    i don't usually go through the effort to make proper mocks... i ususally use stubs instead....

    Assuming you are using sinon for mocking and chai for assertions something like this

    var sinon = require('sinon');
    
    var chai = require('chai');
    
    chai.should();
    
    var YoutubeLite = require('../index.js'); // opr whatever you need to require here
    describe('an api test', function () {
        var sandbox = null; // our play pen!
    
        // Run before each test to set things up
        beforeEach(function(){
            // create our sandbox to play in
            sandbox = sinon.sandbox.create();
            // stub the apiRequest function and choose what it will call the callback with
            sandbox.stub(YoutubeLite, 'apiRequest').yields(null, 'i did a thing!');
        });
    
        // Run after each test to restore things to the way they were
        afterEach(function(){
            sandbox.restore();
        });
    
        it('should do something cool', function(done){
            // make a call to that should call that thing
            YoutubeLite.fetchSnippit('some value', function(err, result){
                // test that the thing was called with the value
                YoutubeLite.apiRequest.calledWith('some value').should.equal(true);
                done(); // signal the completion of an async test
            });
        });
    });
    

    @Yamikuronue is the real expert in this stuff though, i'm sure she'll be along in a bit to correct my approach. ;-)



  • @accalia said in Mocking a function in a node test:

    sandbox.stub(YoutubeLite, 'apiRequest').yields(null, 'i did a thing!');

    What does this actually do? Is that a hardcoded "no error" and 'i did a thing' value passed to the callback for the new apiRequest?


  • mod

    @boomzilla said in Mocking a function in a node test:

    What does this actually do?

    It replaces the actual function with a "stub" function. The subsequent chained commands define the stub behavior; by default it's a black hole. "Yields" calls the last parameter as a callback, passing in the values given. Other handy ones are returns and resolves (for returning and promise resolution); more advanced users can use things like onFirstCall().returns("foo").onSecondCall().returns("bar") to script more complex behavior.

    The actual function is on sinon; however, she's using my favorite template to create a sandbox before every test, and revert it at the end. This is key because otherwise, if you do not revert your stub, it remains stubbed for the lifetime of the test (and a stub cannot be stubbed, Sinon will error out). The sandbox reverts everything in it when you call sandbox.restore(), or else you can call restore on any stub to un-stub it.

    One other handy sinon function: spy will wrap a function without changing its behavior (essentially a pass-through)



  • @Yamikuronue said in Mocking a function in a node test:

    "Yields" calls the last parameter as a callback, passing in the values given.

    OK, thanks. I think I can make that work now...


  • sockdevs

    @boomzilla said in Mocking a function in a node test:

    What does this actually do?

    the first bit replaces the function with a stub, that call returns the stub for chaining.

    you can access the stub as YoutubeLite.apiRequest too at that point

    then i called yields() on the stub which instructs the stub to call the first function in it's argument list with the specified parameters.

    node says callbacks get parameters (err, arg1, arg2, etc) and that err is null on success non-null on error



  • Huzzah!

    describe('Youtube API call',function(){
        var sandbox = null;
        beforeEach(function(){
            sandbox = sinon.sandbox.create();
            youtubeLite.apiKey = 'fakekey';
        });
        afterEach(function(){
            sandbox.restore();
        });
        
        it("doesn't crash on a bad video id",function(){
            sandbox.stub( youtubeLite, 'apiRequest').yields( null, "{}" );
            youtubeLite.fetchSnippet( "badVideo", function( err, snippet  ){
                        expect( snippet ).to.be.null;
                    });
        });
        
        it("returns {title, channelTitle, thumbnails, duration} on a good video id",function(){
            sandbox.stub( youtubeLite, 'apiRequest').yields( null, JSON.stringify(
                        {
                            items: [
                            {
                                snippet: {
                                    title: 'Video title!',
                                    channelTitle: 'Channel Title!',
                                    thumbnails:{
                                        default:{ url:'https://i.ytimg.com/vi/goodvideo/default.jpg'},
                                        high:{ url:'https://i.ytimg.com/vi/goodvideo/hqdefault.jpg'},
                                        medium:{ url:'https://i.ytimg.com/vi/goodvideo/mqdefault.jpg'},
                                        standard:{ url:'https://i.ytimg.com/vi/goodvideo/sddefault.jpg'},
                                    }
                                },
                                contentDetails: {duration: 'PT3H2M31S'}
                            }
                            ]
                        }) );
            youtubeLite.fetchSnippet( "goodvideo", function( err, snippet  ){
                        expect( snippet ).to.deep.equal( 
                        {
                            title: 'Video title!',
                            channelTitle: 'Channel Title!',
                            duration: '3:02:31',
                            thumbnails:{
                                default:{ url:'https://i.ytimg.com/vi/goodvideo/default.jpg'},
                                high:{ url:'https://i.ytimg.com/vi/goodvideo/hqdefault.jpg'},
                                medium:{ url:'https://i.ytimg.com/vi/goodvideo/mqdefault.jpg'},
                                standard:{ url:'https://i.ytimg.com/vi/goodvideo/sddefault.jpg'}, 
                            }
                        } );
                        
                    });
        });
    });
    



Log in to reply
 

Looks like your connection to What the Daily WTF? was lost, please wait while we try to reconnect.