Separating logic from Express routes for easier testing

Have you ever been confused on how to structure your Express applications in a way that makes them testable?

As with most things in the Node.js world, there are many ways of writing and structuring Express apps.

The best place to start though is usually with the canonical "Hello World" example, and here's the one from the Express documentation:

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => res.send('Hello World!'))

app.listen(port, () => console.log(`Example app listening on port ${port}!`))

The line app.get('/', (req, res) => res.send('Hello World!')) is the actual route the serves up the response.

So going off of that, if we wanted to add a new HTTP route it seems like it would make sense to follow the same pattern of adding your route-handling code in the callback to the .get() or .post method.

If we had an web forum application and wanted to create a user, that code might look like:

app.post('/api/user', async (req, res) => {
  const userName = req.body.user_name
  const userType = req.body.user_type
  try {
    await insert(userType, userName)
    res.sendStatus(201)
  } catch(e) {
    res.sendStatus(500)
    console.log(e)
  }
})

...which follows the sample "Hello World" structure

But what about when it comes time to actually test this? How would we test the route end-to-end, as well as unit test the actual user creation logic contained in the route handler?

As it currently stands, a test might look like:

describe('POST /api/user', () => {
  before(async () => {
    await createTable('admin')
    await createTable('member')
  })

  after(async () => {
    await dropTable('admin')
    await dropTable('member')
  })

  it('should respond with 201 if user account created successfully', async () => {
    const response = await request(app)
      .post('/api/user')
      .send({user_name: "ccleary00", user_type: "admin"})
      .set('Accept', 'application/json')

      expect(response.statusCode).to.equal(201)
  })
})

Right now the user creation logic is in the callback, so we can't just "export" the callback. To test that logic, we'd always have to test it by sending a request to the server so it would actually hit the POST /api/user route.

And that's what we're doing above, using supertest to send a request and perform assertions on the resulting response from the server.

Smells in the air

But something feels off about this...

It feels weird to write end-to-end tests like this for something that should be tested more as a unit.

And what if the user creation logic starts to become a lot more complex - like needing to call an email service to send out a user signup email, needing to check if user account already exists or not, etc.? We'd have to test all those different branches of logic that would accompany the code, and doing that all through and end-to-end test with supertest would get really annoying really quickly.

Fortunately, the fix for making this testable is pretty simple. Not to mention it helps us in achieving better separation of concerns by separating our HTTP code from our business logic code.

Pulling out the logic from the route

The simplest way to make this route testable is putting the code that is currently in the callback into it's own function:

export default async function createUser (req, res) => {
  const userName = req.body.user_name
  const userType = req.body.user_type
  try {
    await insert(userType, userName)
    res.sendStatus(201)
  } catch(e) {
    res.sendStatus(500)
    console.log(e)
  }
}

and then importing that into the Express route:

const createUser = require('./controllers/user')
app.post('/api/user', createUser)

Now we can still write end-to-end tests for the route, using much of the same test code as before, but we can also test the createUser() function more as a unit.

Brick by brick

For example, if we had validation/transformation logic to disallow LOUD, all-caps user names, we could add that in and assert that the name stored in the database was indeed lowercase:

export default async function createUser (req, res) => {
  const userName = req.body.user_name.toLowerCase() // QUIETER!!
  const userType = req.body.user_type
  try {
    await insert(userType, userName)
    res.sendStatus(201)
  } catch(e) {
    res.sendStatus(500)
    console.log(e)
  }
}

That validation/transformation logic might get even more complex, like needing to trim white space from the user name or check for offensive names before creation of the user, etc. You get the idea.

At that point we could pull that logic out into its own function and test that as a unit.

export function format(userName) {
  return userName.trim().toLowerCase()
}

describe('#format', () => {
  it('should trim white space from ends of user name', () => {
    const formatted = format('  ccleary00 ')
    expect(formatted).to.equal('ccleary00')
  })

  it('should convert the user name to all lower case', () => {
    const formatted = format('CCLEARY00')
    expect(formatted).to.equal('ccleary00')
  })
})

So instead of having all that logic in the callback to the route, we can break it up into individual units to more easily test, without necessarily having to mock out a lot of things.

And while we could technically write these tests using our original way sending in a request to the Express route, it would be much more difficult to do this. And when writing tests are difficult, they tend to not get written at all...

Wrapping up

There are a lot of ways to structure Express applications, and you could break this down even further by pulling out the core user creation logic into a "service", while having the route controller handle the validation.

But for now, the key takeaway from this is to avoid putting logic in the route callbacks. You'll make it much easier on yourself to test and to refactor in the future.

Testing is supposed to be easy, not hard. If you find that writing the tests for your application are painful, that's usually the first hint that you need to restructure or rewrite part of your code. Sometimes you don't even realize that until you've already written a lot of code, and doing that refactoring would be even more painful.

The best way I've found to avoid this is to use Test Driven Development (TDD) - it's ended up saving me so many times from writing poor code (like the Express user route code I used as the starting example in this post).

It can feel pretty weird to write the test first, then the code, but if you want some guidance on adopting a mindset that will help it "click", check out another post I wrote on TDD here.

Also, I'm writing a lot of new content to help make testing in JavaScript (and JavaScript in general) easier. Easier, because I don't think it needs to be as complex as it is sometimes. If you don't want to miss out on one of these new posts, be sure to subscribe below! And I'll be sending out helpful cheatsheets, great posts by other developers, etc. to help you on your journey.

Subscribe for more testing and JavaScript content!

No spam ever. Unsubscribe any time.