How To: Stub ".promise()" in AWS-SDK Node.js
Appending AWS-SDK's ".promise()" to the end of calls is a clean and simple way to create asynchronous operations. Stubbing these calls with Sinon.js tests can be a bit tricky, but this article explains exactly how to do it without modifying production code.
Since AWS released support for Node v8.10 in Lambda, I was able to refactor Lambda API to use async/await
instead of Bluebird promises. The code is not only much cleaner now, but I was able to remove a lot of unnecessary overhead as well. As part of the refactoring, I decided to use AWS-SDK's native promise implementation by appending .promise()
to the end of an S3 getObject
call. This works perfectly in production and the code is super compact and simple:
javascriptlet data = await S3.getObject(params).promise()
The issue came with stubbing the call using Sinon.js. With the old promise method, I was using promisifyAll()
to wrap new AWS.S3()
and then stubbing the getObjectAsync
method. If you're not familiar with stubbing AWS services, read my post: How To: Stub AWS Services in Lambda Functions using Serverless, Sinon.JS and Promises.
The old way looked like this (condensed for readability):
javascriptconst AWS = require('aws-sdk') // AWS SDK const Promise = require('bluebird') // Promise library const sinon = require('sinon') // Sinon.js library // Promisify S3 const S3 = Promise.promisifyAll(new AWS.S3()) // Stub the 'async' version let stub = sinon.stub(S3,'getObjectAsync') stub.withArgs({Bucket: 'my-test-bucket', Key: 'test'}).resolves({ AcceptRanges: 'bytes', LastModified: new Date('2018-04-25T13:32:58.000Z'), ContentLength: 23, ETag: '"ae771fbbba6a74eeeb77754355831713"', ContentType: 'text/plain', Metadata: {}, Body: Buffer.from('Test file\n') }) S3.getObjectAsync({Bucket: 'my-test-bucket', Key: 'test'}).then(data => { // data from stub })
Any test calls to S3.getObjectAsync
that use the specified arguments resolves a promise and returns the data. Getting rid of promises seemed straightforward:
javascriptconst AWS = require('aws-sdk') // AWS SDK const sinon = require('sinon') // Sinon.js library // Init S3 const S3 = new AWS.S3() // Stub getObject let stub = sinon.stub(S3,'getObject') stub.withArgs({Bucket: 'my-test-bucket', Key: 'test'}).resolves({ AcceptRanges: 'bytes', LastModified: new Date('2018-04-25T13:32:58.000Z'), ContentLength: 23, ETag: '"ae771fbbba6a74eeeb77754355831713"', ContentType: 'text/plain', Metadata: {}, Body: Buffer.from('Test file\n') }) let data = await S3.getObject({Bucket: 'my-test-bucket', Key: 'test'}).promise()
But then I obviously got the error: **"**S3.getObject(...).promise is not a function."
As I always do when I encounter something like this, I Googled it. I found a GitHub issue in the aws-sdk-js repo that sort of gave a solution: https://github.com/aws/aws-sdk-js/issues/1973. The problem is that stubbing (or mocking) services should be entirely contained within your tests. You should avoid adding anything to your production code, which this solution would have required.
The Answer
After a bit of experimentation I finally realized that Sinon.js completely rewires stubbed functions, essentially eliminating any underlying prototype methods. In this case, the .promise()
method no longer existed. Lucky for us, we can return anything we want from Sinon.js stubs, including (🥁 drumroll please) functions!
If we examine how our code is calling the getObject
method, we see that .promise()
is chained to it. This means that getObject
has to return a promise
method in order for the code to execute.
javascriptlet data = await S3.getObject(params).promise()
We can return an object from our stub (rather than resolve it) using the .returns()
method provided with stubs:
defaultstub.withArgs({Bucket: 'my-test-bucket', Key: 'test/test.txt'}).returns({ promise: () => {} })
Now getObject
returns a promise()
method and the code works. The only problem is that our stub isn't returning any data. This is easily fixed by returning the data from inside the promise()
method:
javascriptstub.withArgs({Bucket: 'my-test-bucket', Key: 'test/test.txt'}).returns({ promise: () => { return { AcceptRanges: 'bytes', LastModified: new Date('2018-04-25T13:32:58.000Z'), ContentLength: 23, ETag: '"ae771fbbba6a74eeeb77754355831713"', ContentType: 'text/plain', Metadata: {}, Body: Buffer.from('Test file\n') }} })
Voilà! Now your stub works correctly and we didn't have to alter any of our production code to make it work.
You might have noticed that this "promise" doesn't actually return a promise. For our implementation with await
it doesn't matter, because it will assume a value as a resolved promise. However, if you needed it to return a promise (like if you were still using promise chains), you could simply wrap your data in a Promise.resolve()
and that should work.