Search code examples
expressmongoosebluebirdsinon

Mongoose Model#save not being called in Promise.all() handling


I am testing an express route handler which makes use of mongoose. My route handler code is the following.

// Require models.
var Comment = require('../models/commentModel');
var User = require('../models/userModel');
var Post = require('../models/postModel');

// Require mongoose and set bluebird to handle its promises.
var  mongoose = require('mongoose');
mongoose.Promise = require('bluebird');

// Creates a comment.
exports.create_comment = function(req, res) {
    var comment = new Comment();

    return comment.save().then(function(createdComment) {
        res.json(createdComment);

        var promises = [
            User.findById(userId).exec(),
            Post.findById(postId).exec()
        ];

        // Should resolve to [{ user: {/* */} }, { post: {/* */} }]
        var promisedDocs = Promise.all(promises);

        // Function provided to Array.map().
        function pushAndSave(obj) {
            // Extract the doc from { doc: {/* */} }
            var doc = obj[Object.keys(obj)[0];
            doc.comments.push(createdComment);
            return doc.save();
        }

        // Return promise and process the docs returned by resolved promise.
        return promisedDocs.then(function(results) {
            results.map(pushAndSave);
        });
    })
    .catch(function(err) {
        res.json(err);
    });
}

The logic I am trying to test is that when everything goes right, the calls to the appropriate functions are made. Basically, I am expecting the following: comment.save(), User.findById().exec(), Post.findById().exec(), user.save(), and post.save() to be called.

To test this, I am using mocha, chai, sinon, sinon-mongoose, sinon-stub-promise, and node-mocks-http. This is my test (I am obviating the setup).

it('Makes all the appropriate calls when everyting goes right', function() {
    // Mock through sinon-mongoose
    var userMock = sinon.mock(User);
    var postMock = sinon.mock(Post);

    userMock
        .expects('findById')
        .chain('exec')
        .resolves({
            user: {/**/}
        });

    postMock
        .expects('findById')
        .chain('exec')
        .resolves({
            post: {/**/}
        });

    // Stubbing calls to Model#save.
    var saveComment = sinon.stub(Comment.prototype, 'save');
    var saveUser = sinon.stub(User.prototype, 'save');
    var savePost = sinon.stub(Post.prototype, 'save');

    saveComment.returnsPromise().resolves({
        comment: {/**/}
    }); 

    saveUser.returnsPromise().resolves({
        user: {/**/}
    }); 

    savePost.returnsPromise().resolves({
        post: {/**/}
    }); 

    // Mocking req and res with node-mocks-http
    req = mockHttp.createRequest({
        method: 'POST',
        url: '/comments',
        user: {/**/},
        body: {/**/}
    });

    res = mockHttp.createResponse();

    // Call the handler.
    commentsController.create_comment(req, res);

    expect(saveComment.called).to.equal(true); // Pass
    userMock.verify(); // Pass
    postMock.verify(); // Pass
    expect(saveUser.called).to.equal(true); // Fail
    expect(savePost.called).to.equal(true); // Fail
});

As you can see, the calls to user.save() and post.save() are not made. This might be a problem with my Promise.all() setup and subsequent handling or my test itself, but I am out of ideas. Can you spot my error? Thanks in advance, guys.


Solution

  • It took me longer to recreate the missing parts of your example, than to actually find the problem. Below you can find a fixed version of your test scenario.

    user.js

    var mongoose = require('mongoose');
    
    module.exports = mongoose.model('User', {
        name: String,
        comments: Array
    });
    

    post.js

    var mongoose = require('mongoose');
    
    module.exports = mongoose.model('Post', {
        name: String,
        comments: Array
    });
    

    comment.js

    var mongoose = require('mongoose');
    
    module.exports = mongoose.model('Comment', {
        text: String
    });
    

    controller.js

    var mongoose = require('mongoose');
    
    mongoose.Promise = require('bluebird');
    
    var Comment = require('./comment');
    var User = require('./user');
    var Post = require('./post');
    
    // Creates a comment.
    exports.create_comment = function (req, res) {
        var comment = new Comment();
    
        const userId = req.user.id;
        const postId = req.body.id;
    
        return comment.save().then(function (createdComment) {
            res.json(createdComment);
    
            var promises = [
                User.findById(userId).exec(),
                Post.findById(postId).exec()
            ];
    
            // Should resolve to [{ user: {/* */} }, { post: {/* */} }]
            var promisedDocs = Promise.all(promises);
    
            // Function provided to Array.map().
            function pushAndSave (doc) {
                doc.comments.push(createdComment);
                return doc.save();
            }
    
            // Return promise and process the docs returned by resolved promise.
            return promisedDocs.then(function (results) {
                results.map(pushAndSave);
            });
        })
            .catch(function (err) {
                console.error('foo', err);
                res.json(err);
            });
    };
    

    test.js

    'use strict';
    
    const chai = require('chai');
    const sinon = require('sinon');
    const SinonChai = require('sinon-chai');
    
    var sinonStubPromise = require('sinon-stub-promise');
    sinonStubPromise(sinon);
    require('sinon-mongoose');
    
    chai.use(SinonChai);
    const expect = chai.expect;
    
    var mockHttp = require('node-mocks-http');
    
    const commentsController = require('./controller');
    
    var Comment = require('./comment');
    var User = require('./user');
    var Post = require('./post');
    
    describe.only('Test', () => {
    
        it('Makes all the appropriate calls when everyting goes right',
            function (done) {
    
                // Mock through sinon-mongoose
                var userMock = sinon.mock(User);
                var postMock = sinon.mock(Post);
    
                userMock
                    .expects('findById')
                    .chain('exec')
                    .resolves(new User());
    
                postMock
                    .expects('findById')
                    .chain('exec')
                    .resolves(new Post());
    
                // Stubbing calls to Model#save.
                var saveComment = sinon.stub(Comment.prototype, 'save');
                var saveUser = sinon.stub(User.prototype, 'save');
                var savePost = sinon.stub(Post.prototype, 'save');
    
                saveComment.resolves({
                    comment: { /**/}
                });
    
                saveUser.resolves({
                    user: { /**/}
                });
    
                savePost.resolves({
                    post: { /**/}
                });
    
                // Mocking req and res with node-mocks-http
                const req = mockHttp.createRequest({
                    method: 'POST',
                    url: '/comments',
                    user: {id: '123'},
                    body: {id: 'xxx'}
                });
    
                const res = mockHttp.createResponse();
    
                // Call the handler.
                commentsController.create_comment(req, res).then(() => {
    
                    expect(saveComment.called).to.equal(true); // Pass
                    userMock.verify(); // Pass
                    postMock.verify(); // Pass
                    expect(saveUser.called).to.equal(true); // Fail
                    expect(savePost.called).to.equal(true); // Fail
                    done();
                });
    
            });
    
    });
    

    Overall, the assertion logic was ok in theory. The problems that I found were the following:

    • Your userMock and postMock returned a plain empty object instead of a Mongoose model instance. Therefore, there was an exception thrown within the pushAndSave function that was never caught, since the .save() method was undefined. I simplified the pushAndSave function so that you can see what I'm talking about
    • You tested an async method in sync mode and this would definitely cause issues with the assertions. I've switched the test case into async and the assertions are executed as soon as the method completes