We're planting a tree for every job application! Click here to learn more

FP Unit Testing in Node - Part 5: Noops, Stub Soup, and Mountebank

Jesse Warden

16 Dec 2022

•

9 min read

FP Unit Testing in Node - Part 5: Noops, Stub Soup, and Mountebank
  • integration testing

Noops, Stub Soup, and Mountebank

Welcome to Part 5 where we cover more about noops with a utility to test them, the "stub soup" that can happen if you don't create small pure functions, and how we can utilize stubs instead of mocks to unit test larger funtions. The most important part, though, is setting up Mountebank to show how integration tests can show problems in your unit tests despite 100% coverage. We use wrapping class instances as an example to show you the pitfalls Object Oriented Programming code can make for you.

The Final Battle?

At this point, we've shrunk our Express route as much as we're going to without some serious refactoring. Let's unit test it using (read copy pasta) all the stubs we've already made.

Noops

Before we proceed, let's explain what a noop is. Pronounced "no awp", it's slang for "no operation". It means a function that doesn't return a value so it apparently has no effect because we have no proof it did any operation. That isn't true; we all live and die by console.log which always returns undefined. If you run in ECS, all those console.log calls are putting text in standard out and you're probably collecting all those logs for into ELK or CloudWatch or Splunk. That's certainly an "operation with noticeable effect". Functional Programmers call that a "side effect" of the function.

Often you'll stub them in unit tests like () => undefined or the less clear, but shorter () => {}. Save yourself some typeing and use Lodash' noop, Ramda [always](https://ramdajs.com/docs# always) if you're a Ramda purist or noop in Ramda Adjunct.

My God, It's Full Of Stubs

Here's the unit test with stubs needed above it for sendEmail t3h lelz:

describe('sendEmail when called', ()=> {
	const readFileStub = (path, encoding, callback) => callback(undefined, 'email')
	const configStub = { has: stubTrue, get: () => 'email service' }
	const createTransportStub = () => ({
        sendEmail: (options, callback) => callback(undefined, 'info')
	})
	const getUserEmailStub = () => 'email'
	const renderStub = stubTrue
	const reqStub = {
		cookie: { sessionID: '1' },
		files: [{scan: 'clean', originalname: 'so fresh', path: '/o/m/g'}]
	}
	const resStub = {}
	const nextStub = noop
	it('should work with good stubs', ()=> {
		return expect(
			sendEmail(
				readFileStub,
				configStub,
				createTransportStub,
				getUserEmailStub,
				renderStub,
				reqStub,
				resStub,
				nextStub
			)
		).to.be.fulfilled
	})
})

A successful test, but the stubs, while succinct, almost outnumber the lines of code for the test. As you can see, Functional Programming, even when attempted with best effort, doesn't necessarely "solve" your unit tests having to create a lot of "test code". Mocks often get a bad rap for being verbose and hard to maintain. Stubs I believe are included in this, but at least with stubs they're smaller, easier, and "mostly pure". Still, as soon as you refactor your implementation, you'll have to fix your tests, and sometimes your stubs will have to change too.

Trust me, this is much more preferable than to refactoring mocks.

The best thing to do is remember your training of the basics, like DRY: don't repeat yourself, and keep your tests organized with commonly used good and bad stubs within reach (meaning you don't have to scroll too far to read them). Like OOP, FP functions should be short and focused to be re-usable, and most importantly, composeable. It's hard and gets easier with practice. A fully fleshed out idea of the problem you're trying to solve helps a lot, don't be afraid to prototype in imperative code.

sendEmail Unit Tests

Let's add the failing which is easy because basically any stub could fail and the whole function fails:

it('should fail when reading files fails', ()=> {
    return expect(
        sendEmail(
            readFileStubBad,
            configStub,
            createTransportStub,
            getUserEmailStub,
            renderStub,
            reqStub,
            resStub,
            nextStub
        )
    ).to.be.rejected
})

And the most important, what sendEmail eventually resolves to. Yes, returning a Promise everytime is importante, but let's ensure it resolves to something:

it('should resolve to an email sent', ()=> {
    return sendEmail(
        readFileStub,
        configStub,
        createTransportStub,
        getUserEmailStub,
        renderStub,
        reqStub,
        resStub,
        nextStub
    )
    .then(result => {
        expect(result).to.equal('info')
    })
})

Screenshot 2021-06-15 at 11.44.18.png And coverage is now:

Screenshot 2021-06-15 at 11.44.35.png

The feels.

Class Composition is Hard, Get You Some Integration Tests

Sadly, this is where Functional Programming and Object Oriented Programming stop working together. Our code has a bug that you won't find in unit tests, only integration tests. In languages like JavaScript, Python, and Lua, when you call functions on Classes without the class attached, they'll often lose scope (this or self will be undefined/nil). Eric Elliot breaks down the details of a lot of these cases in his article Why Composition is Harder With Classes.

Integration tests using Supertest or Mountebank help with different levels of integration tests. We'll use Mountebank in this article. Suffice to say your unit tests are only as good as the stubs you provide. The stubs you provide basically fake or emulate functionality of the dependencies and are as small and simple as possible. Stubs are different code than the concrete (real) implementations, and unless they capture it exactly, don't always test the same. You'll see an example of this below testing OOP code.

Pitfalls When Stubbing Class Methods

Notice none of our stubs use any classes or Object.prototype, and yet, this is exactly how nodemailer works. It all comes down to class instances losing their this scope. Let's write a basic pure wrapper around nodemailer, unit test it, show it pass, then setup Mountebank so we can show it breaks at runtime when you use the real nodemailer vs. stubs. Given Mountebank is a large topic, I won't cover too much how it works, but the code is included. Just know it'll listen on the email port, act like an email server, and send real email responses so nodemailer believes it truly is sending an email.

Let's create a sandbox.js file to play and a sandbox.test.js in the tests folder.

const nodemailer = require('nodemailer')

const sendEmailSafe = (createTransport, mailOptions, options) =>
    new Promise((success, failure) => {
        const { sendMail } = createTransport({mailOptions})
        sendMail(options, (err, info) =>
            err
            ? failure(err)
            : success(info))
    })

module.exports = sendEmailSafe

And the unit test:

...
describe.only('sendEmailSafe when called', ()=> {
    it('should work with good stubs', () => {
        const createTransportStub = () => ({
            sendMail: (options, callback) => callback(undefined, 'email sent')
        })
        return sendEmailSafe(createTransportStub, {}, {})
        .then(result => {
            expect(result).to.equal('email sent')
        })
    })
})
...

Screenshot 2021-06-15 at 11.44.52.png

Integration Test

So it passes. Let's try with the integration test that uses a real nodemailer instance vs. our unit test stubs. I've setup mountebank to listen on port 2626 at localhost for emails. If someone sends an email with the from address equaling "jesterxl@jessewarden.com", it'll respond with an "ok the email was sent, my man". Before we automate this, let's do it manually first.

Setting Up Mountebank

Run npm i mountebank --save-dev first, then once it is complete, open your package.json and add this script:

"scripts": {
    ...
    "mb": "npx mb"
  },
...

Now when you run npm run mb, it'll run Mountebank. It'll basically start a Node server on port 2525. As long as it's running, you can send it JSON to add imposters (mocks for running services).

Setting Up Your Imposters

An imposter is basically a mock or stub for services. Typically mocks and stubs are used for unit tests and are created in code. In Mountebank, however, you create one by sending Mountebank REST calls. For example a POST call, typically on localhost:2525. You send it JSON describing what port it's supposed to listen on, what other things to look for, and what JSON & headers to respond with. Mountebank will then spawn a service on that port listening for incoming connections. If the request has attributes it recognizes (you do a GET on port 9001 with a path of /api/health, you send an email on port 2626 from 'cow@moo.com', etc), it'll respond with whatever stub you tell it too. For example, some hardcoded JSON with an HTTP 200 status code. While stubs typically respond immediately or with Promises, these respond as a REST response.

Check out the import top part of our setupMountebank.js that I've placed in the "test-integration" folder:

...

const addNodemailerImposter = () =>
    new Promise((success, failure)=>
        request({
            uri: 'http://localhost:2525/imposters',
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json'
            },
            body: JSON.stringify({
                protocol: 'smtp',
                port: 2626,
                stubs: [{
                    predicates: [
                        {
                            contains: {
                                from: 'jesterxl@jessewarden.com'
                            }
                        }
                    ]
                }]
            })
        },
	...

As you can see, a simple POST request to tell Mountebank, "Yo, if any dude sends an email on port 2626 and it is from me, respond it worked". Typically Mountebank wants to define that response in the stubs Array, but for emails, you can leave it blank and it'll default to a success response. At the time of this writing, there is no way to make an email fail, only REST calls. As long as Mountebank is running, it'll remember to do this unless you delete it, or close Mountebank.

Now, to play around, I've run it manually to register. Check the bottom code:

if (require.main === module) {
    Promise.all([
        addNodemailerImposter()
    ])
    .then(() => console.log('Mountebank Imposters intiailized successfully.'))
    .catch(error => console.error('Mountebank Imposters failed:', error))
}

Open a new terminal, and run node test-integration/setupMountebank.js, and you should see the Mountebank terminal light up:

> npx mb

info: [mb:2525] mountebank v1.14.1 now taking orders - point your browser to http://localhost:2525 for help
info: [mb:2525] POST /imposters
info: [smtp:2626] Open for business...

That "Open for business..." line is key; that means Mountebank understood what you want and worked and you can now send emails to port 2626. Let's do that.

Sending the Email

Open up sandbox.js and check the code that'll run if we use Node to run it:

if (require.main === module) {
    sendEmailSafe(
        nodemailer.createTransport,
        {
            host: 'localhost',
            port: 2626,
            secure: false
        },
        {
            from: 'jesterxl@jessewarden.com',
            to: 'jesterxl@jessewarden.com',
            subject: 'what you hear, what you hear is not a test',
            body: 'Dat Body Rock'
        }
    )
    .then(result => {
        console.log("result:", result)
    })
    .catch(error => {
        console.log("error:", error)
    })
}

Swing and a Miss

Rad, now try to send an email via node src/sandbox.js:

error: TypeError: Cannot read property 'getSocket' of undefined
    at sendMail (/Users/jessewarden/Documents/_Projects/fp-node/node_modules/nodemailer/lib/mailer/index.js:143:25)
...

Whoa, wat? Let's go into Nodemailer's sourcecode and see what the heck is going on:

/* node_modules/nodemailer/lib/mailer/index.js line 134 */
sendMail(data, callback) {
    ...
		/* broken below this comment */
    if (typeof this.getSocket === 'function') {
        this.transporter.getSocket = this.getSocket;
        this.getSocket = false;
    }
...

It's doing a typecheck to see if this.getSocket is a function vs. a Boolean. That's fine, but they should of check for this being undefined first.

Or should they? Once you're in class world, and you've been doing OOP for awhile, you shouldn't have to check for this; it's just a normal part of how classes work. If it doesn't, something more fundamental is messed up, the most common being forgetting to setup bind in the constructor for callbacks for example.

We're not going to fix Nodemailer to make it more friendly to FP developers. In fact, this is a common trend in that many libraries you use both in Node and in the Browser (i.e. from node_modules) will be written in all sorts of ways. You need to be accomodating.

Instead, we'll assume that we're not allowed to call sendEmail alone, and ensure it's always something.sendEmail; whatever to the left is the instance, and will retain scope if you call it like that. This is why a lot of my examples you'll see I'll make fs the dependency and then call fs.readFile vs. just readFile.

FP-Fu

Let's first fix our implementation to be OOP friendly, and ensure the tests still work, else fix 'em. The old code:

const { sendEmail } = createTransport({mailOptions})
        sendMail(options, (err, info) =>

The new code:

const transport = createTransport({mailOptions})
        transport.sendMail(options, (err, info) =>

Re-run the tests:

Screenshot 2021-06-15 at 11.45.05.png

Cool, the original public interface still works and hides the OOPy stuff. Let's test the integration test now with sending a real email with real, concrete implementation vs. stubs:

result: { accepted: [ 'jesterxl@jessewarden.com' ],
  rejected: [],
  envelopeTime: 3,
  messageTime: 3,
  messageSize: 590,
  response: '250 2.0.0 Ok: queued as f19f95e62da4afeade02',
  envelope:
   { from: 'jesterxl@jessewarden.com',
     to: [ 'jesterxl@jessewarden.com' ] },
  messageId: '<ab0bab09-7628-007b-f497-3d53806704ae@jessewarden.com>' }

I was like Whee!!! Let's fix our original implementation now that we've proven we know how to wrangle OOP with FP now. We'll change:

...
sendEmailSafe(
  createTransport(
    createTransportObject(emailService.host, emailBody.port)
  ).sendEmail,
...

To:

...
sendEmailSafe(
  createTransport(
    createTransportObject(emailService.host, emailBody.port)
  ),
...

And switch sendEmailSafe:

const sendEmailSafe = curry((sendEmailFunction, mailOptions) =>
  new Promise((success, failure) =>
    sendEmailFunction(mailOptions, (err, info) =>
      err
      ? failure(err)
      : success(info)
    )
  )
)

To the new OOP-friendly version:

const sendEmailSafe = curry((transport, mailOptions) =>
  new Promise((success, failure) =>
    transport.sendEmail(mailOptions, (err, info) =>
      err
      ? failure(err)
      : success(info)
    )
  )
)

This breaks 1 of the tests because the stub used to be a function; now it needs to be an Object with a function. However... we should be a little more honest and use a true class in the unit tests to be 100% sure. We'll take the old 2 tests:

describe('sendEmailSafe when called', ()=> {
    const sendEmailStub = (options, callback) => callback(undefined, 'info')
    const sendEmailBadStub = (options, callback) => callback(new Error('dat boom'))
    it('should work with good stubs', ()=> {
        return expect(sendEmailSafe(sendEmailStub, {})).to.be.fulfilled
    })
    it('should fail with bad stubs', ()=> {
        return expect(sendEmailSafe(sendEmailBadStub, {})).to.be.rejected
    })
})

And change 'em to:

describe('sendEmailSafe when called', ()=> {
    class TransportStub {
        constructor(mailOptions) {
            this.mailOptions = mailOptions
        }
        sendEmail(options, callback) {
            callback(undefined, 'info')
        }
    }
    class TransportStubBad {
        constructor(mailOptions) {
            this.mailOptions = mailOptions
        }
        sendEmail(options, callback) {
            callback(new Error('dat boom'))
        }
    }
    it('should work with good stubs', ()=> {
        return expect(sendEmailSafe(new TransportStub({}), {})).to.be.fulfilled
    })
    it('should fail with bad stubs', ()=> {
        return expect(sendEmailSafe(new TransportStubBad({}), {})).to.be.rejected
    })
})

Notice once you go OOP, things get more verbose. Bleh.

Did you like this article?

Jesse Warden

Software @ Capital One, Amateur Powerlifter & Parkourist

See other articles by Jesse

Related jobs

See all

Title

The company

  • Remote

Title

The company

  • Remote

Title

The company

  • Remote

Title

The company

  • Remote

Related articles

JavaScript Functional Style Made Simple

JavaScript Functional Style Made Simple

Daniel Boros

•

12 Sep 2021

JavaScript Functional Style Made Simple

JavaScript Functional Style Made Simple

Daniel Boros

•

12 Sep 2021

WorksHub

CareersCompaniesSitemapFunctional WorksBlockchain WorksJavaScript WorksAI WorksGolang WorksJava WorksPython WorksRemote Works
hello@works-hub.com

Ground Floor, Verse Building, 18 Brunswick Place, London, N1 6DZ

108 E 16th Street, New York, NY 10003

Subscribe to our newsletter

Join over 111,000 others and get access to exclusive content, job opportunities and more!

© 2024 WorksHub

Privacy PolicyDeveloped by WorksHub