Skip to content

Instantly share code, notes, and snippets.

@kddnewton
Last active November 6, 2024 13:59
Show Gist options
  • Save kddnewton/66f2e7749535f30f868d36021056fd15 to your computer and use it in GitHub Desktop.
Save kddnewton/66f2e7749535f30f868d36021056fd15 to your computer and use it in GitHub Desktop.

Faster Rails tests

Feedback loop speed in one of the biggest contributing factors to overall development time. The faster you get results, the faster you can move on to other things. A fast enough test suite is therefore critical to teams' success, and is worth investing some time at the beginning to save in the long run.

Below is a list of techniques for speeding up a Rails test suite. It is not comprehensive, but should definitely provide some quick wins. This list of techniques assumes you're using minitest, but most everything should translate over to rspec by simply replacing test/test_helper.rb with spec/spec_helper.rb.

Lead with data

Note that in general you shouldn't apply any technique until you measure. A great tool for that is ruby-prof. You can add a trigger to your test suite that will profile the entire thing (note that this takes quite a while for a larger test suite) and writes out the results to a file that you can peruse later.

In test/test_helper.rb:

if ENV['PROFILE']
  require 'ruby-prof'
  RubyProf.start

  Minitest.after_run do
    File.open('tmp/profile.out', 'w') do |file|
      result = RubyProf.stop
      printer = RubyProf::FlatPrinter.new(result)
      printer.print(file, min_percent: 0.1)
    end
  end
end

This will list the methods in which your test suite spent the longest amount of execution time, and is a good starting point from which to begin looking for solutions.

Additionally you can use a plugin for your test runner that will report the longest running tests. For minitest, you can use minitest-reporters's MeanTimeReporter.

In test/test_helper.rb:

if ENV['PROFILE']
  require 'minitest/reporters'
  Minitest::Reporters.use!(Minitest::Reporters::MeanTimeReporter.new)
end

The bcrypt gem (used by has_secure_password) takes a while to compute password hashes, and generally you don't need that kind of security in test. Instead, you can roll an incredibly simple and fast crypto that just reverses the input string. The point of this is that you don't need to worry about cryptographic security if you're just logging in in tests, and this can save a lot of cycles (especially in systems tests).

In test/test_helper.rb:

module BCrypt
  class Password
    def initialize(encrypted)
      @encrypted = encrypted
    end

    def is_password?(unencrypted)
      @encrypted == unencrypted.reverse
    end

    def self.create(unencrypted, **)
      unencrypted.reverse
    end
  end
end

bootsnap is a gem that does a ton to speed up the boot time of your application, including prescanning load paths and precompiling instruction sequences. It's additionally baked into the default Rails 5.2 Gemfile. Follow the instructions in the README to get an automatic speed boost to the test suite startup time.

Cache Time::utc calls

Since you're typically working with a smaller dataset in tests (especially if you're working with Rails fixtures) the number of unique time objects that are going to be serialized/deserialized from the database is going to be relative small. As such, caching the ::utc responses can drastically reduce the time spent in this method.

In test/test_helper.rb:

class << Time
  prepend(
    Module.new do
      def cache
        @cache ||= {}
      end

      def utc(*args)
        cache[args] ||= super
      end
    end
  )
end

Disable ActiveSupport::Notifications

ActiveSupport::Notifications allow you (and Rails itself) to hook into a system that notifies objects when certain events happen throughout the system. These take up cycles that don't need to be spent in test unless you're specifically testing them. You can turn them off both explicitly unsubscribing from each predefined event and by overriding the ActiveSupport::Notifications::instrument method to not even check if a notification needs to be pushed out.

In test/test_helper.rb:

%w[sql.active_record render_collection.action_view render_partial.action_view
   logger.action_view render_template.action_view].each do |notification|
  ActiveSupport::Notifications.unsubscribe(notification)
end

class << ActiveSupport::Notifications
  def instrument(_, payload = {})
    yield payload if block_given?
  end
end

Disable garbage collection

WARNING: This may or may not speed up your tests, depending on the verison of Ruby that you're running, the content of your tests, the nature of your application, and the nature of your test suite. This could potentially have very negative consequences, as in if your entire test suite were testing CSV deserialization (read: lots of memory usage). For SOME test suites however, you can reap major speed wins by taking GC out of the equation entirely.

In test/test_helper.rb:

GC.disable

Disable logging

Logging is Rails tests is opt-out as opposed to opt-in. It's useful when you need it, but can drastically slow down speed when you don't. Disable all of Rails' various logging facilities to recoup some speed.

In config/environments/test.rb:

Rails.application.configure do
  config.logger = Logger.new(nil)
  config.log_level = :fatal
end

If you're using the blank? or present? method heavily in your code (and are therefore seeing it in your profile) you can use the fast_blank gem to drastically improve the perform of these methods. You should see a significant drop off in the amount of time spent in these methods just by adding this gem to your Gemfile.

fast_underscore is a small gem that overwrites one method from Rails: ActiveSupport::Inflector#underscore. It's used a lot internally in Rails (determining table names, determining inverse associations, etc.) and can end up eating up a large percentage of the execution time of the test suite. Follow the instructions in the README to get an automatic speed boost, especially to the startup time of the test suite.

If you're using paperclip to handle image uploads and you're generating multiple styles from the input, that can take a very long time. Paperclip will shell out to ImageMagick to identify and convert the images, and these steps usually aren't necessary in test. First, disable post processing so that you don't generate the thumbnails.

In test/test_helper.rb:

module Paperclip
  class Attachment
    def post_process(*)
      false
    end
  end
end

You can then choose to either leave in the call to identify to check content-type or remove that altogether as well. If you do leave it in, you can speed it up by using a different fork function.

paperclip depends on the cocaine gem to run the identify command. cocaine supports multiple strategies for how to run commands, from which it chooses the best strategy to use on the running platform. The default strategy is Process.spawn, but this can be sped up with the posix-spawn gem. The difference (from the README) is below:

fork(2) calls slow down as the parent process uses more memory due to the need to copy page tables. In many common uses of fork(), where it is followed by one of the exec family of functions to spawn child processes (Kernel#system, IO::popen, Process::spawn, etc.), it’s possible to remove this overhead by using special process spawning interfaces (posix_spawn(), vfork(), etc.)

The last technique taken from a great blog post on speeding up paperclip.

Use a MemoryStore

A MemoryStore in general is going to be much faster than a FileStore for caching. If you are using your cache anywhere in test, be sure to switch it over to a MemoryStore in your configuration.

In config/environments/test.rb:

Rails.application.configure do
  config.cache_store = :memory_store
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment