How I made my Clojure database tests 5x faster

June 17, 2015

Summary: Setting up and tearing down a test database can be slow. Use a rolled back transaction to quickly reset the database to a known state. You can do that in an :each fixture to run each test in isolation.

On one of my projects, I wrote a bunch of tests that had to hit the database. There was a :once fixture to create all of the tables anew and an :each fixture to delete everything in the tables before each test. That ensured that I was always working with a known empty database. Overall, the tests took about 10 seconds. Woah! That's a long time. But I lived with it.

(defn clear
  "Delete all rows before and after, just for good measure.
  (cleardb db) ;; delete all rows from all tables
      (cleardb db))))

(defn setupdb [tests]
  (initdb db) ;; create the tables
      (teardown db)))) ;; drop the tables

(use-fixtures :each clear)
(use-fixtures :once setupdb)

Then I remembered a technique someone once mentioned where you use a transaction that you roll back instead of starting with a fresh db each time. It's supposed to be a lot faster.

After a little experimentation, I came up with this:

(defn clear [test]
  (sql/with-db-transaction [db db]
    (sql/db-set-rollback-only! db)
    (binding [db db] ;; rebind dynamic var db, used in tests

We open a transaction, immediately set it to rollback (which it will do when the transaction closes). Then we have to rebind our dynamic db var, which holds the current connection. And inside of that we run the test. Inside of the transaction, anything you write to the database will be available to read. When the test ends, the transaction closes and it rolls back all of the changes, leaving the database empty again.

The result? Running the tests went from 10 seconds to 2 seconds. They still start and end with a clean database, but it's done faster with a transaction.

The one gotcha that I ran into was that the PostgreSQL function now() was always returning the same time within the transaction. I had made an assumption (that was true before) that different calls would happen at different times. That assumption was no longer true inside the transaction. I had to fix the code to not rely on time.

The other part of this technique, which I did not really have to use, was that you can set up your database with test data in the :once fixture. It's costly to set up the test data, but because you're rolling back transactions, once it's set up it's quick to reset it.

If you'd like to learn more about testing in Clojure, you might be interested in my LispCast Intro to clojure.test. In it, we cover test namespaces, assertions, running your tests, and of course fixtures. It's an interactive course with exercises, screencasts, animations, and code. You should also check out the free cheatsheet below.

You might also like