Lessons from Rails: Tests using DB transactions

Lessons from Rails: Tests using DB transactions
import { lessons } from 'rails'; // part 1

Bringing techniques used in Rails to Node

I'm about a decade late.
late
After unknowingly circling it for years, I am now working in Rails. When I started at CoverMyMeds in the beginning of 2017, I wasn't sure what I would think of working with a framework that had clearly crossed peak hype years ago. I expected to be somewhat bored with a system that I assumed wouldn't have much to teach me. But working in Rails has been nothing if not educational. I had no idea how many concepts and practices from Rails permeate thttps://giphy.com/search/hackhe libraries and frameworks I have used throughout my career. The Rails community continually discovers and enshrines best practices in the framework and supporting libraries. From what I can tell, this community generally prefers to converge. The JavaScript community, for all of its strengths, generally does not.

I still love working in Node though. So, as I have learned Rails and RSpec, some of the techniques I discovered there proved so useful that I decided to find a way to bring them into my JavaScript projects.

Technique 1 - In tests, use DB transactions

Rails has always supported running your database operations in tests as transactions that are automatically rolled back after each test. By using this feature, you prevent your test database from being inundated by old or even misleading data.

TBH I'm not sure if a transaction with a rollback is faster than a DB write plus a delete, but it's much, much simpler to manage than manual test teardown statements that restore the state of your database. I highly recommend this practice.

Setting this up in Node is going to depend on your DB abstraction layer. I prefer Objection.js and the example presented here uses that library.

For the testing framework, I typically use Mocha, but I think the semantics used here would work with Jasmine and Jest as well.

Setup

Create a helper file that you will use in your test files. I put mine at test/support/transactional_tests.js. We're going to import this file in each test file in which we want transactional tests.

'use strict';

const dbConfig = require('../../knexfile'),
  knex = require('knex')(dbConfig[process.env.NODE_ENV]),
  Model = require('objection').Model;

let afterDone;

beforeEach('initialize transaction', function (done) {
  knex.transaction(function (newtrx) {
    Model.knex(newtrx);
    done();
  }).catch(function () {
    // call afterEach's done
    afterDone();
  });
});

afterEach('rollback transaction', function (done) {
  afterDone = done;
  Model.knex().rollback();
});

Explanation

It's a fun little hack. Let's break it down.

const dbConfig = require('../../knexfile'),  
  knex = require('knex')(dbConfig[process.env.NODE_ENV]),
  Model = require('objection').Model;

Here we are initializing knex and objection. My knexfile is keyed by environment, hence I pass in process.env.NODE_ENV to the knex initialization function.

Now for the main trick. Rather than analyze the code line by line, it's easier to think about how the tests will interact with individual functional sections.

First:

beforeEach('initialize transaction', function (done) {  
  knex.transaction(function (newtrx) {
    Model.knex(newtrx);
    done();
  })
  ...

In the beforeEach section, which will run before every test, we initialize a transaction and inject it into the base Objection model. All Objection models in the test will now use this transaction. Immediately after injecting the transaction, we call the beforeEach function's done callback, so the current test is now free to run.

After the test, the afterEach executes:

afterEach('rollback transaction', function (done) {
  afterDone = done;
  Model.knex().rollback();
});

The afterDone = done; line is like a bookmark; we're holding on to a reference to afterEach's done function so that we can call it later.

Calling .rollback() rolls the transaction back (obviously), but it also results in an exception being thrown that is caught by the original transaction's catch function (which you will see in the beforeEach callback):

...
  .catch(function () {
    // call afterEach's done
    afterDone();
  });

Here, we call afterEach's done function, which we refer to with afterDone. The teardown code is now complete and the test will not hang.

So, the steps can be summarized as follows:

  1. Initialize a transaction and inject it into Objection's base Model class.
  2. Run test
  3. Rollback transaction
  4. Catch resulting exception
  5. Call afterEach done function

Use it

In any test file in which you want automatic transactions, simply require your helper file.

require('../../support/transactional_tests');

describe('Class under test', function () {
...
});

The end.

If you're using Objection and want cleaner tests, give transactions a try!