Testing Node.js for Rails Lovers

You’ve built your first Rails-like Node app. But you’re used to test-driven development, and you have no tests! Don’t be a bad developer – let’s get into the nitty gritty of Node app testing.

This is the second part in the Node.js for Rails Lovers series, starting with Part 1.

TLDR

Want to see the finished project? Check out the GitHub repo.

Test-driven Development

Test-driven development (TDD) is a software development process that focuses on a short development cycle that relies on automated tests. Here’s how we want to build our app each time we make a feature change:

  • Add a test
  • Run all tests to see if the new test fails
  • Write some code
  • Run all tests to see if the new test passes
  • Refactor
  • Repeat

The Test Database

Since our app interacts with a database, our tests will need their own database to interact with as well. We’ve already setup Knex.js to work with our database layer, so let’s add a test config to our knexfile. We’re also specifying separate seed directories for our different environments which will come into play later. We’re sticking to the Rails-like naming conventions for our databases.

// knexfile.js
module.exports = {
  development: {
    client: 'pg',
    connection: {
      database: 'blog_development'
    },
    seeds: {
      directory: './seeds/development'
    }
  },
  test: {
    client: 'pg',
    connection: {
      database: 'blog_test'
    },
    seeds: {
      directory: './seeds/test'
    }
  }
}

And create the test database:

psql postgres

create database blog_test;
\q

Meet Jest

Just as there are in Rails, there are plenty of testing frameworks for Node to choose from. We’re going to use Jest, a super-popular simple test framework that works out of the box with Node. Let’s install it – both in our project and globally.

yarn add --dev jest
yarn global add jest

We can initialize Jest and answer a few questions about including coverage and adding Jest to our package scripts with another command. Answer y to all questions for now.

jest --init

This adds a jest.config.js file to our project and a test script to our package.json file.

Parallel Testing

By default, Jest will run all our tests in separate processes. Unfortunately, when dealing with a single test database, this can cause problems, as migrations interfere with each other.

Also, when Jest finishes running, sometimes asynchronous operations fail to stop. We’ll add a configuration option to troubleshoot those as well.

So let’s edit our test script to make sure we run our tests in a single process and explain any failures to exit.

// package.json
{
  ...
  "scripts": {
    ...
    "test": "jest --runInBand --detectOpenHandles"
  }
  ...
}

Now we’re all set to start testing. Let’s build our first test.

Unit Testing

Unit testing covers the smallest pieces of our app. We want to ensure at a small scale that the fundamental elements work properly in isolation.

For our first unit test, we’re going to test our Article model to make sure every article in our blog has a title.

All Jest tests are expected to go in a __tests__ directory at the root of your project or be named with a .test.js extension. Like Rails we’re going to keep all our tests in a test directory at our project root, and stick to that naming convention.

Write our first failing test:

// test/models/Article.test.js
const knex = require('knex')
const { Model } = require('objection')
const knexConfig = require('../../knexfile')
const Article = require('../../models/Article')

const db = knex(knexConfig[process.env.NODE_ENV || 'test'])
Model.knex(db)

describe('Article', () => {
  let article

  beforeEach(async () => {
    await db.migrate.rollback()
    await db.migrate.latest()
    await db.seed.run()

    article = await Article.query().findOne({ title: 'First Article!' })
  })

  afterEach(async () => {
    await db.migrate.rollback()
  })

  test('has a valid seed', async () => {
    expect(article).toBeDefined()
  })

})

Run the test, and watch it fail.

yarn test

Oh no! Our Article model doesn’t have a seed to test with. Let’s add one.

NODE_ENV=test knex seed:make seed

This will make a new test seed file at seeds/test/seed.js. Edit it and create our Article seed.

// seeds/test/seed.js
exports.seed = async (knex) => {
  await knex('articles').del()
  await knex('articles').insert([
    { id: 1, title: 'First Article!', text: 'This is my first article.' },
  ])
}

This script will delete all articles before seeding, and insert one new one for testing.

In Rails, you might use a fixture library like factory_bot to generate test data. I find using test-specific seeds as fixtures can fulfill that role.

Run our test again, and watch it pass. Woohoo! Our work is done!

Oh wait, we want to ensure every article has a title. Let’s write our new failing test for that.

// test/models/Article.test.js
...
describe('Article', () => {
  ...
  test('titles are required', async () => {
    article.title = undefined
    expect(() => {
      article.$validate()
    }).toThrow()
  })
  ...
})

Run the test, and watch it fail. We need to validate our article. You’ll note I called the $validate() method on the article. Similar to Rails ActiveRecord validation, Objection.js makes it super easy for us to provide validation criteria for all our models. Let’s edit our Article model to check for a title. We need to add a JSON Schema that describes our model.

// models/Article.js
...
class Article extends BaseModel {
  ...
  static get jsonSchema() {
    return {
      type: 'object',
      required: ['title'],
      properties: {
        id: { type: 'integer' },
        title: { type: 'string', minLength: 1, maxLength: 255 },
        text: { type: 'string' }
      }
    }
  }
  ...
}

Now we know our articles require a title. Run the test suite again, and watch it pass!

Integration Testing

Unit tests are great for isolating the granular pieces of your app, but our blog is a web application with lots of connected components – well, not so much yet, but it will be 😀. We need a way to test that the system works as a whole. To automate this, we require an abstract way to test integrated HTTP requests. Enter SuperTest.

yarn add --dev supertest

But first, we need to do a little refactoring so we can access our app from our tests. Copy index.js into a new app.js file, pull out a couple of lines, and add a module export at the bottom.

// app.js
...
// const port = 3000
...
// app.listen(port, () => console.log(`My blog is listening on port ${port}!`))
...
module.exports = {
  app,
  db
}

Our new index.js file will only contain the following.

// index.js
const { app } = require('./app')
 
const port = 3000

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

Now we can create our integration test. Note that we run a new migration and seed the database before each test, and then tear it all down after each test.

// test/integration/articles.test.js
const request = require('supertest')
const { app, db } = require('../../app')
const Article = require('../../models/Article')

describe('/articles', () => {
  beforeEach(async () => {
    await db.migrate.rollback()
    await db.migrate.latest()
    await db.seed.run()
  })

  afterEach(async () => {
    await db.migrate.rollback()
  })

  describe('GET /articles/new', () => {
    test('can enter a new article', async () => {
      const response = await request(app)
        .get('/articles/new')
      expect(response.status).toBe(200)
    })
  })

  describe('POST /articles', () => {
    test('can create an article', async () => {
      const response = await request(app)
        .post('/articles')
        .send('title=Can Create&body=Article successfully.')
      expect(response.status).toBe(302)
      const articles = await Article.query().orderBy('id')
      const article = articles[articles.length - 1]
      expect(response.header.location).toBe(`/articles/${article.id}`)
    })
  })
})

We already wrote these endpoints in our app, so we can run the test and watch it pass. This proves that the user can successfully reach the new article page, they can submit the form to create a new article, and will be redirected to its page.

Note that for a full end-to-end integration test you may want to consider a headless browser like Puppeteer. But in this case, our SuperTest integration suite will suffice.

Coverage

Finally, let’s take a look at how thorough we’ve been with our test coverage. Jest makes it super easy to view a coverage chart. Just add the flag to our test command.

// package.json
{
  ...
  "test": "jest --runInBand --detectOpenHandles --coverage"
  ...
}

And run our tests again to view our test coverage.

You can see at a glance where we need to add more coverage.

Summary

Now that you’re equipped with the tools to build out your Node projects with TDD, go forth and make your app! There’s still plenty to do for our blog app: relational mapping, security, partials, debugging – the list goes on. Let me know what you’d like to see next. I’d also love to hear what you bring from your Rails background to Node.

Keep making! 🚀