We recently re-committed to TDD on our older Rails project at work. I’m on a newer team and was a little leery of our team lead pushing for TDD as I wasn’t sure how aware he was about just how brutal of a feedback loop our RSpec suite provided.
TDD is typically best applied when there’s a fast (ideally instant) feedback loop from running tests -> seeing test results, as the result of the test determines whether you write more code or move on to the next test case. The faster the loop, the faster the developer can make decisions, and the faster the development cycle for a given feature.
In our case, our RSpec suite was taking huge amounts of time to load, much less actually run any tests. We’re talking significant chunks of time – upwards of a minute – just to load.
Obviously, this makes TDD brutal in practice as even small changes require minutes to get any reasonable feedback. It leads to developer frustration at best and a reluctance to write tests in the worst case.
I took on the challenge of figuring out how to solve this longstanding issue and found a very surprising issue lurking under the hood.
Benchmarking spec_helper
If you’re suffering from slow RSpec suite loads, the first thing you should do is start benchmarking your spec_helper
file. This is where RSpec loads all the supporting middleware it’ll need to do any testing, and most developers just chuck stuff in there willy-nilly to get their builds to pass.
Therefore, it’s a great way for a lot of cruft to build up over time and gradually bog everything down. Every time the suite loads, this file gets run, so if something is egregiously wrong here, it’ll cause major issues for even tiny tests.
Ruby Benchmarking
Ruby includes Benchmark
in its standard library and is a super accessible way to start benchmarking code. Our first step to diagnosing our load issue was to throw a benchmark block into our spec_helper
, one for each line (or group of lines). This includes benchmarking all the require
statements as well – we need to know absolutely everything about this file to make an educated guess as to what’s going on:
Benchmark.bm do |x|
x.report("requires") do
require "test_helper"
require "rspec/rails"
require "capybara/rspec"
require "capybara/rails"
require "capybara-screenshot/rspec"
require "shoulda/matchers"
require "pundit/rspec"
require "shoulda-callback-matchers"
require "faker"
require "data/matchers"
end
x.report("AJ uniqueness") { ActiveJob::Uniqueness.test_mode! }
x.report("maintain_test_schema!") do
# Checks for pending migration and applies them before tests are run.
# If you are not using ActiveRecord, you can remove this line.
ActiveRecord::Migration.maintain_test_schema!
end
x.report("helper load") do
Dir[Rails.root.join("spec/helpers/**/*.rb")].each { |f| require f }
end
# more setup code...
end
Running this code will give you an output when the suite runs. We don’t actually care if the tests pass at this point; we’re just trying to figure out how long this file takes to work through.
An example of what this output looks like from the docs (the labels don’t correspond to the sample above):
user system total real
for: 1.010000 0.000000 1.010000 ( 1.015688)
times: 1.000000 0.000000 1.000000 ( 1.003611)
upto: 1.030000 0.000000 1.030000 ( 1.028098)
Signs of Trouble
Once we had this data, it was pretty clear to see what the issue was.
ActiveRecord::Migration.maintain_test_schema!
was taking on average ~25 seconds to complete, for every invocation of RSpec. While it doesn’t completely solve the problem, this is such a significant chunk of time it warrants further exploration.
Let’s jump over to the documentation, aaaand… it’s marked :nodoc:
.
It is, however, a part of the spec_helper
file that RSpec generates by default, so it’s likely this piece of code has been here since the application used RSpec.
Deep Dive into ActiveRecord
So now that we’ve reached a dead end on documentation, we’ve got to figure out how to diagnose what’s going on in the source code. Reading source code is fine on GitHub, but we already have it installed locally, and we’re absolutely sure it’s the right version, so why not use that? bundle open
will open an editor with the gem’s source code:
bundle open activerecord
And from there we can begin tracing the path our application takes when calling maintain_test_schema!
.
Application Background
To begin to understand what’s going on, I need to give a little backstory on the application’s context.
It’s an older Rails application running Rails 5.1.7. It also exists within an ecosystem of similar Rails applications, and it shares a data model with a couple of them. For this reason, the migrations for some of the applications lives in a shared gem which injects its migrations paths into the wrapper application on initialization. Applications that do this usually don’t even have a db/migrate
directory to discourage developers from trying to put migrations there.
I don’t think this is a common pattern much anymore, but our data model gem basically is a carbon copy of this guide (perhaps it was followed back in the day). The relevant piece is this block:
module EngineWithMigrations
class Engine < ::Rails::Engine
isolate_namespace EngineWithMigrations
initializer :append_migrations do |app|
unless app.root.to_s.match root.to_s
app.config.paths["db/migrate"] += config.paths["db/migrate"].expanded
end
end
end
end
Because the gem is mounted as an engine, each gem migration path (config.paths["db/migrate"]
) is merged into the application migration paths (app.config.paths["db/migrate"]
). A larger discussion is probably warranted for how Rails handles paths from its configurations, as it’s not quite as simple as an array of hashes, but that exercise is left to the reader as it’s not totally relevant here.
What’s important is understanding that at the end of this process, the migration paths should look like:
db/migrate
gem/db/migrate
Tracing maintain_test_schema!
With all the above in mind, let’s walk through the code. You can do this in a couple ways – if you’ve bundle open
ed ActiveRecord you can put debugger breakpoints directly in ActiveRecord itself (please remember to remove them!), or you can add a debugger breakpoint in your spec_helper
and manually run statements as needed. Either case will work here. Add your breakpoints of choice and fire up an RSpec test run.
We start with the offending call:
ActiveRecord::Migration.maintain_test_schema!
ActiveRecord::Migration.maintain_test_schema!
method definition:
def maintain_test_schema! # :nodoc:
if ActiveRecord::Base.maintain_test_schema
suppress_messages { load_schema_if_pending! }
end
end
Nothing out of the ordinary here, we’ll assume ActiveRecord::Base.maintain_test_schema
is truthy or it wouldn’t take 25+ seconds, and suppress_messages
doesn’t look particularly offensive. The next candidate is load_schema_if_pending!
.
ActiveRecord::Migration.load_schema_if_pending!
method definition:
def load_schema_if_pending!
if ActiveRecord::Migrator.needs_migration? || !ActiveRecord::Migrator.any_migrations?
# Roundtrip to Rake to allow plugins to hook into database initialization.
FileUtils.cd Rails.root do
current_config = Base.connection_config
Base.clear_all_connections!
system("bin/rails db:test:prepare")
# Establish a new connection, the old database may be gone (db:test:prepare uses purge)
Base.establish_connection(current_config)
end
check_pending!
end
end
Alright, we have a lot going on here. First thing that stands out to me here is that we have the possibility to call out to the shell to run bin/rails db:test:prepare
. This would absolutely eat up a massive amount of time as it runs essentially three expensive commands in tandem to blow away the test DB and recreate it:
export RAILS_ENV=test
bin/rails db:drop
bin/rails db:create
bin/rails db:migrate
Let’s keep digging though, as we need to know if we’re entering this block. Here, we’ll actually test the conditions:
ActiveRecord::Migrator.needs_migration? || !ActiveRecord::Migrator.any_migrations?
#=> true
ActiveRecord::Migrator.needs_migration?
#=> false
!ActiveRecord::Migrator.any_migrations?
#=> true
Aha! So we are running bin/rails db:test:prepare
every time RSpec is run, even if we don’t need to (i.e. database is up-to-date already), because this ActiveRecord::Migrator
thinks we have no migrations.
This isn’t the end of the story as we need to figure out why the migrator doesn’t think there are migrations.
ActiveRecord::Migrator.any_migrations?
method definition:
def any_migrations?
migrations(migrations_paths).any?
end
Simple. This is doing exactly what it says. We can infer the migrations
method is looking in the migrations paths and determining what’s a valid migration or not. We can come back to that later if we have to, but it seems intuitive for now, so we’ll leave it.
The next question is what the migrator thinks the migration paths are.
ActiveRecord::Migrator.migrations_paths
method definition:
def migrations_paths
@migrations_paths ||= ["db/migrate"]
# just to not break things if someone uses: migrations_path = some_string
Array(@migrations_paths)
end
This looks very promising. Notice the hardcoded migration path of db/migrate
. If we hit this block without @migrations_paths
being defined for some reason, our paths are now wrong (recall that our migrations actually live in gem/db/migrate
). We can confirm this simply in our debugger:
ActiveRecord::Migrator.migrations_paths
#=> ["db/migrate"]
Perfect. This change means this is looking in the wrong migration paths for any migrations. any_migrations?
returns false, which is negated in the check for loading schema, and so the whole thing will drop the test DB every time you run RSpec.
Implementing a Fix
I honestly have no idea how the application config is failing to be picked up in this particular instance. It’s only happening in test environments, but once I noticed that this is an issue I did find an older, unresolved Rails issue that talks about it. The solution was to upgrade Rails.
To that end, we’re working towards upgrading to a modern Rails version where this is likely fixed, but in the meantime, we applied a monkey patch to fix this issue:
if Rails.env.test?
class ActiveRecord::Migrator
def self.migrations_paths
@migrations_paths ||= if defined?(Rails) && Rails.application
Rails.application.paths["db/migrate"].to_a
else
["db/migrate"]
end
Array(@migrations_paths)
end
end
end
Here, we explicitly check the application configuration instead of reverting to a hardcoded migration path. Also note we’re monkey patching ActiveRecord and that makes me crazy nervous, so there’s also a check to only do this on the test environment. No need to risk a production outage if you can help it. And finally, this is placed into an initializer and run before RSpec.
The result? About a 25 second drop in load times, exactly what was expected from our benchmarking tests. Hooray!
Wrap Up
We did end up speeding the suite up a little bit more by about another 20 to 25 seconds by installing Spring. I didn’t feel that was useful to cover here as there are plenty of tutorials that probably explain that installation process in more detail. There’s also caveats to using Spring, but in our case it was a huge win for both developer experience and TDD as well.
That’s all for now. Thanks for reading!
- ← Previous: Implementing Session Expiration in Rails
- Next: Building a Custom Markup Language for Raspberry Pi LED Matrices →