Rails applications have specific requirements when running under mutant: the application must be initialized in test mode, all subjects must be loaded eagerly so they are discoverable, and parallel workers need isolated database resources to avoid conflicts.
This document is the canonical reference for setting up mutant against a Rails application. The hook recipes below are verified on every non-EOL Rails version (currently 7.2, 8.0 and 8.1) against both PostgreSQL and SQLite by a runnable example app — see Verified example. The mechanisms it relies on are documented in:
- Configuration — the configuration file format
- Hooks — the hooks system used for eager loading and resource isolation
- Test Runner — parallel execution and resource isolation
- RSpec Integration / Minitest Integration — test framework integrations
A working Rails configuration needs three things in config/mutant.yml:
---
requires:
- ./config/environment
environment_variables:
RAILS_ENV: test
integration:
name: rspec # or minitest
hooks:
- config/mutant/hooks.rbrequires: ./config/environmentloads the Rails application.RAILS_ENV: testis set before the application loads, so initializers see the correct environment. Seeenvironment_variables.hooks:is where Rails-specific behavior (eager loading, database isolation) is wired in.
The same configuration can be expressed on the CLI:
RAILS_ENV=test bundle exec mutant run -r ./config/environment --integration rspec UserRails relies on autoloading: a constant is only loaded the first time it is referenced. Mutant needs all subjects to be present in memory before subject discovery runs — otherwise classes that have not been touched will be invisible to the matcher.
The fix is to eager-load the application after mutant has infected the environment:
hooks.register(:env_infection_post) do
Rails.application.eager_load!
endThis is the recommended baseline for any Rails project, even when no other hooks are needed. Without it, subject expressions can silently match nothing. The database-isolation recipes below already include this hook.
See the hooks reference for the full list of hook events.
PostgreSQL-specific. The example below uses the
pgdriver (PG.connect,PG::Connection) and theCREATE DATABASE ... TEMPLATEclause. The hook structure is portable to other adapters (see Other Databases below), but the code as written will not run against MySQL, SQLite, or other engines.
When running with multiple workers (the default), each worker shares the same Rails process layout and therefore the same database configuration. Without isolation, workers fight over the same tables and tests become non-deterministic.
The recipe below creates a per-worker copy of the test database using PostgreSQL's TEMPLATE clause. Each worker gets its own database (<test_db>_mutant_worker_<index>) cloned from the template at process start. Save it as config/mutant/hooks.rb (the path referenced by config/mutant.yml):
# The abstract base classes whose connection pools need a per-worker database.
# A single-database app has just ActiveRecord::Base; add each additional
# abstract base class (e.g. AnalyticsRecord) here if the app connects to more
# than one database, and the hooks below will isolate each of them per worker.
#
# If those base classes use different database engines (e.g. one on PostgreSQL
# and one on SQLite), merge this recipe with the SQLite one and branch on
# base.connection_pool.db_config.adapter inside the loop.
base_records = -> { [ActiveRecord::Base] }
disconnect_pool = ->(base:) { base.connection_pool.disconnect! }
with_root_connection = lambda do |db_config, &block|
config = db_config.configuration_hash
connection = PG.connect(
host: config[:host],
port: config[:port],
user: config[:username],
password: config[:password],
dbname: 'postgres'
)
begin
block.call(connection)
ensure
connection.close
end
end
isolate_database = lambda do |base:, index:|
db_config = base.connection_pool.db_config
template = db_config.database
isolated = "#{template}_mutant_worker_#{index}"
with_root_connection.call(db_config) do |connection|
quoted_template = PG::Connection.quote_ident(template)
quoted_isolated = PG::Connection.quote_ident(isolated)
connection.exec("DROP DATABASE IF EXISTS #{quoted_isolated}")
connection.exec("CREATE DATABASE #{quoted_isolated} TEMPLATE #{quoted_template}")
end
db_config._database = isolated
end
isolate_index = lambda do |index:|
base_records.call.each do |base|
disconnect_pool.call(base:)
isolate_database.call(base:, index:)
end
end
hooks.register(:env_infection_post) do
Rails.application.eager_load!
end
hooks.register(:setup_integration_post) do
base_records.call.each { |base| disconnect_pool.call(base:) }
end
hooks.register(:test_worker_process_start) { |index:| isolate_index.call(index:) }
hooks.register(:mutation_worker_process_start) { |index:| isolate_index.call(index:) }What this does:
The actual per-worker database setup happens in the worker-start hooks. The other two hooks set up preconditions in the parent process.
mutation_worker_process_startandtest_worker_process_start— the core of the recipe. Both runisolate_index, which fires once inside each forked worker process: for every base class it drops the inherited connection pool, opens a root connection via the rawpgdriver (PG.connectto thepostgresmaintenance database), runsCREATE DATABASE … TEMPLATE, and rebindsdb_config._databaseto the per-worker name. The two hooks fire under different commands —mutation_worker_process_startundermutant run,test_worker_process_startundermutant test— so both registrations are required if you want isolation in both modes. Drop one and that command's workers will trample each other on the shared template database.setup_integration_post— releases the parent's connection to the template database so workers can use it as aTEMPLATE. PostgreSQL refusesCREATE DATABASE … TEMPLATE myapp_testwhile any session (including the parent's RSpec/Minitest connection opened during integration setup) is connected tomyapp_test. Without this hook the first worker'sCREATE DATABASEerrors withsource database "myapp_test" is being accessed by other users. Fires in bothmutant runandmutant test.env_infection_post— eager-loads the application so subjects are discoverable (see Recommended Hook: Eager Load above). Fires in bothmutant runandmutant test.
PG.connect reads its host, port, user and password from the connection's configuration_hash, so the same recipe works whether the application is configured with a DATABASE_URL or with discrete keys in config/database.yml. The root connection targets the postgres maintenance database because you cannot CREATE/DROP a database while connected to it.
The mutant test case is worth calling out explicitly: this same hook file lets you run mutant test as a drop-in parallel test runner (no mutations), with the same per-worker database isolation — provided you registered test_worker_process_start. See Test Runner for that workflow.
SQLite-specific. SQLite database files should not be shared by parallel workers. The recipe below uses a test database file prepared before mutant starts, then copies that file for each worker process.
Run the Rails test database preparation step before starting mutant:
RAILS_ENV=test bin/rails db:test:prepareThen save the following as config/mutant/hooks.rb. It copies the prepared database for every worker:
# The abstract base classes whose databases need a per-worker copy.
# A single-database app has just ActiveRecord::Base; add each additional
# abstract base class (e.g. AnalyticsRecord) here if the app connects to more
# than one database, and the hooks below will isolate each of them per worker.
#
# If those base classes use different database engines (e.g. one on SQLite and
# one on PostgreSQL), merge this recipe with the PostgreSQL one and branch on
# base.connection_pool.db_config.adapter inside the loop.
base_records = -> { [ActiveRecord::Base] }
worker_database_dir = File.join(Dir.pwd, 'tmp/mutant')
disconnect_pool = ->(base:) { base.connection_pool.disconnect! }
isolate_database = lambda do |base:, index:|
connection_config = base.connection_pool.db_config.configuration_hash
template = connection_config.fetch(:database)
unless File.file?(template)
raise "Missing #{template}; run bin/rails db:test:prepare before mutant"
end
FileUtils.mkdir_p(worker_database_dir)
name = File.basename(template, '.*')
isolated = File.join(worker_database_dir, "#{name}_mutant_worker_#{index}.sqlite3")
FileUtils.cp(template, isolated)
base.establish_connection(connection_config.merge(database: isolated))
end
isolate_index = lambda do |index:|
base_records.call.each do |base|
disconnect_pool.call(base:)
isolate_database.call(base:, index:)
end
end
hooks.register(:env_infection_post) do
Rails.application.eager_load!
end
hooks.register(:setup_integration_post) do
base_records.call.each { |base| disconnect_pool.call(base:) }
end
hooks.register(:test_worker_process_start) { |index:| isolate_index.call(index:) }
hooks.register(:mutation_worker_process_start) { |index:| isolate_index.call(index:) }What this does:
env_infection_post— eager-loads the application so subjects are discoverable.setup_integration_post— disconnects the parent process from the template database after the test integration has loaded.test_worker_process_startandmutation_worker_process_start— runisolate_index, which for every base class copies the prepared SQLite file to a worker-specific path undertmp/mutant, then reconnects Active Record to the copy. Both registrations are required if you use bothmutant testandmutant run.
This recipe assumes the test database lives at the path Active Record reports for each base class (bin/rails db:test:prepare writes it there). Applications with multiple SQLite databases list each abstract base class in base_records; every one is copied and reconnected separately.
A complete, runnable version of this setup lives in rails_example/ at the repository root: a minimal Rails app, the two hook recipes above (config/mutant/hooks_postgresql.rb and config/mutant/hooks_sqlite.rb), and a single Gemfile parameterized by RAILS_VERSION. CI runs it for Rails 7.2, 8.0 and 8.1 against both databases via manager ruby rails-verify (PostgreSQL is provisioned on demand by the pg-ephemeral gem). The fenced recipes above are kept byte-identical to those files by a guard spec, so what you copy from here is exactly what is tested.
The hook event structure (setup_integration_post for the initial disconnect, test_worker_process_start / mutation_worker_process_start for per-worker setup) applies to any database. What changes is how the worker-specific database is created and selected:
- MySQL — connect as a privileged user,
CREATE DATABASEper worker, then run your schema-load step (e.g.db:schema:load) since MySQL has noTEMPLATEclause. - Multiple databases — extend
base_recordswith each abstract base class (e.g.[ApplicationRecord, AnalyticsRecord]); the same hooks run per base.
Mutant's test runner uses dynamic work allocation: workers pull tests from a shared queue. This is particularly effective for Rails projects, where integration tests have widely variable runtimes — fast workers automatically pick up more work instead of sitting idle while a slow worker grinds through a long request spec. See Test Runner: differences from native test runners for details.
Consider isolation for any worker-shared state, not just databases:
- File system (temp directories, ActiveStorage roots, fixture uploads)
- Caches (Redis / Memcached — namespace per worker index)
- External services (use test doubles or per-worker endpoints)
- Background queues (separate Sidekiq / GoodJob namespaces)
The same hook events that isolate the database (test_worker_process_start, mutation_worker_process_start) are the right place to set these up.
If subjects are not matching or the application fails to load, see Debugging: loading issues. Quick checks:
# Confirm mutant sees the configuration you expect
bundle exec mutant environment show
# Drop into IRB with the full mutant environment loaded
bundle exec mutant environment irb
# Verify subjects are discoverable after eager loading
bundle exec mutant environment subject list MyNamespace*If subject list returns nothing for a namespace you know exists, the eager-load hook is almost certainly missing or not running.