How to Speed up Your Tests without Touching the Code

The larger the test suite the slower it gets. This is an obvious yet annying truth. In this article, I present a simple and generic technique for improving test suite performance (almost five-fold in my case) without touching the code base at all.

This is useful not only as a personal productivity improvement but can also improve your team workflow. If tests are easier to run then developers are more likely to detect build failures themselves. This saves some back-and-forth between them, reviewers and the continuous integration server and should help you merge changes faster.

A Bit of Theory

In order to maintain ACID guarantees databases need to ensure data was written to disk before completing a transaction. It’s essential in production but becomes a burden during development. If we could reduce the number of disk writes we might be able to speed up tests considerably.

Some databases provide proprietary in-memory capabilities but there’s a generic method applicable to any database system. The idea is to configure the database to store the data directory on an an in-memory file system (tmpfs on Linux) to store the data. It would also be nice to persist the data accross reboots. Otherwise recreting it over and over again would be enough of a burden to erase any productivity benefits.

This is a two-step process: set up the file system and point the database to it. Let’s take a look how to do that on Linux and MySQL.

Practice

I assume we’re using MySQL on Linux with systemd. I’ll cover macOS and PostgreSQL in a later article and won’t cover Windows at all (sorry!).

Before we start messing up with the database let’s stop it with sudo systemctl stop mysqld.

Step 0: Preparations

On my Arch Linux, MySQL stores data in /var/lib/mysql. You can look for datadir in my.cnf to find the path on your machine. Before making any changes please back up that directory.

We’ve already shut down MySQL so we can rename /var/lib/mysql to /var/lib/mysql-persistent (you can pick any other name as long as you use it in the subsequent commands). This directory will persist changes across reboots. The original directory is now gone so let’s reconfigure it as a mount point for tmpfs.

Step 1: Configuring the File System

Let’s create a file named /lib/systemd/system/var-lib-mysql.mount with the content below. The file name must correspond to the full path name of the mount point with slashes replaced with dashes. If your data directory is located somewhere else remember to reflect that in the name of the unit file.

Code speaks louder than words so let me just show you the file with some explanatory comments:

[Unit]
Description=The temporary file system for MySQL data directory.

[Install]
# Ensure the file system is mounted before starting MySQL.
WantedBy=mysqld.service

[Mount]
# This is a memory file system so there's no need to specify a device.
What=none

# The path to datadir. Remember to reflect that path in the name of the unit.
Where=/var/lib/mysql

Type=tmpfs

# We not only need to provide the size but also uid and gid. Otherwise MySQL
# will complain that the data directory is owned by someone else (e.g. root).
Options=size=1G,uid=mysql,gid=mysql

We now need to enable the unit, reload systemd configuration and mount it:

sudo systemctl enable var-lib-mysql.mount
sudo systemctl daemon-reload
sudo systemctl start var-lib-mysql.mount

We should now see the new mount point in the output of mount.

Step 2: Configuring MySQL

The last step is modifying the MySQL service unit to make it restore and dump data after starting and before stopping respectively. Before that ensure you have rsync installed as we’ll use it to copy files

We need to add two lines to the Service section of mysqld.service (lines broken for readability):

ExecStartPre=/usr/bin/rsync --archive --recursive --delete
                            /var/lib/mysql-original/ /var/lib/mysql/
ExecStopPost=/usr/bin/rsync --archive --recursive --delete
                            /var/lib/mysql/ /var/lib/mysql-original/

The only difference between the commands is the directories are passed in reversed order.

After making these changes we need to reload the configuration one more time and, lastly, start MySQL:

sudo systemctl daemon-reload
sudo systemctl start mysqld

Results — The Good and The Bad

When I implemented this optimization for the first time I hoped to speed up a Cucumber test suite. It used to take over 12 minutes to finish on my machine. To my surprise it reduced that time down to 2 minutes 30 seconds (an almost five-fold improvement!). That’s the good news. The bad news is RSpec performance is unaffected. It might be less than I expected but it’s still a great productivity boost especially if applied company-wide.

What’s Next?

There are some natural follow up steps and questions:

  • Apply the technique to PostgreSQL.
  • Port to macOS.
  • Check whether Capybara (not only Cucumber) test performance improves as well.
  • Why is RSpec performance unaffected?

I’ll address some of these points in future articles. For now, I encourage you to give this a try on your development machine and enjoy a faster test suite. If you have any questions, suggestions or ideas feel free to drop me a line at.

Enjoyed the article? Follow me on Twitter!

I regularly post about Ruby, Ruby on Rails, PostgreSQL, and Hotwire.

Leave your email to receive updates about articles.