forked from fixin.me/fixin.me
When database.yml defines more than one top-level key starting with "test",
every standard test task (rails test, rails test:models, rails test:system,
rails test:controllers, …) is automatically rewritten at load time to run
the full suite against each configured database in turn.
Single-database setups are completely unaffected — the wrapping code
activates only when test_configs.size > 1, so existing behaviour is
preserved by default.
Convention: test: is the required primary; test_<name>: adds an adapter:
test_sqlite:
adapter: sqlite3
database: db/fixinme_test.sqlite3
test_pg:
adapter: postgresql
...
Mechanism:
- lib/tasks/test_databases.rake loads after railties/testing.rake (which
defines all test tasks). For each task in the wrapped set it calls
Rake::Task[name].clear_actions and re-enhances with a block that loops
over DB configs, writing a temporary database.yml per database and
running "rails <task_name>" as a subprocess with RAILS_DATABASE_YML set.
- config/application.rb(.dist): reads RAILS_DATABASE_YML and overrides
Rails' database config path before initialisation — no monkey-patching
of the test runner required.
- Adapter gem availability is checked before each run; missing adapters are
skipped with a hint on which bundle group to enable.
- A formatted summary (✓/✗ per database) is printed; exits non-zero on
any failure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
159 lines
4.9 KiB
Ruby
159 lines
4.9 KiB
Ruby
require 'yaml'
|
||
require 'erb'
|
||
require 'tmpdir'
|
||
|
||
# Multi-database test runner
|
||
# ==========================
|
||
# When database.yml contains more than one `test*` configuration, every
|
||
# standard test task (test, test:models, test:system, …) is automatically
|
||
# rewritten to run the full suite against EACH configured database in turn.
|
||
#
|
||
# Convention — any top-level key that starts with "test" and holds a Hash:
|
||
#
|
||
# test: ← required primary database
|
||
# adapter: mysql2
|
||
# database: fixinme_test
|
||
# ...
|
||
#
|
||
# test_sqlite: ← optional additional databases
|
||
# adapter: sqlite3
|
||
# database: db/fixinme_test.sqlite3
|
||
#
|
||
# test_pg:
|
||
# adapter: postgresql
|
||
# ...
|
||
#
|
||
# A single-database setup is unchanged: every task behaves exactly as before.
|
||
#
|
||
# The mechanism uses RAILS_DATABASE_YML — an env var read by
|
||
# config/application.rb(.dist) to override Rails' database config path before
|
||
# initialisation, giving each subprocess a clean, isolated database config.
|
||
|
||
module MultiDbTests
|
||
ADAPTER_GEMS = {
|
||
'mysql2' => 'mysql2',
|
||
'sqlite3' => 'sqlite3',
|
||
'postgresql' => 'pg',
|
||
'pg' => 'pg',
|
||
}.freeze
|
||
|
||
ADAPTER_BUNDLE_GROUPS = {
|
||
'mysql2' => 'mysql',
|
||
'sqlite3' => 'sqlite',
|
||
'postgresql' => 'postgresql',
|
||
'pg' => 'postgresql',
|
||
}.freeze
|
||
|
||
# Rake task names generated by railties/lib/rails/test_unit/testing.rake
|
||
# that use run_from_rake — these are the ones we rewrite.
|
||
WRAPPED_TASKS = (
|
||
['test'] +
|
||
Rails::TestUnit::Runner::TEST_FOLDERS.map { |f| "test:#{f}" } +
|
||
%w[test:all test:system test:generators test:units test:functionals]
|
||
).freeze
|
||
|
||
class << self
|
||
# Returns {name => config_hash} for every key starting with "test".
|
||
def test_configs
|
||
@test_configs ||= begin
|
||
db_file = Rails.root.join('config', 'database.yml')
|
||
all = YAML.safe_load(ERB.new(db_file.read).result, aliases: true) || {}
|
||
all.select { |k, v| k.to_s.start_with?('test') && v.is_a?(Hash) }
|
||
end
|
||
end
|
||
|
||
def non_test_configs
|
||
@non_test_configs ||= begin
|
||
db_file = Rails.root.join('config', 'database.yml')
|
||
all = YAML.safe_load(ERB.new(db_file.read).result, aliases: true) || {}
|
||
all.reject { |k, _| k.to_s.start_with?('test') }
|
||
end
|
||
end
|
||
|
||
# Run rails +task_name+ for every configured test database.
|
||
# Called from the rewritten rake task actions.
|
||
def run(task_name)
|
||
cfgs = test_configs
|
||
results = {}
|
||
|
||
cfgs.each do |db_name, config|
|
||
adapter = config['adapter'].to_s
|
||
puts "\n#{'─' * 64}"
|
||
puts " #{task_name} · #{db_name} (#{adapter})"
|
||
puts '─' * 64
|
||
|
||
unless adapter_available?(adapter)
|
||
warn " SKIPPED — '#{adapter}' gem not in bundle.\n" \
|
||
" Run: bundle config --local with \"#{current_with} #{adapter_group(adapter)}\""
|
||
results[db_name] = :skipped
|
||
next
|
||
end
|
||
|
||
Dir.mktmpdir('rails_test_') do |tmpdir|
|
||
tmp_yml = File.join(tmpdir, 'database.yml')
|
||
File.write(tmp_yml, non_test_configs.merge('test' => config).to_yaml)
|
||
env = { 'RAILS_DATABASE_YML' => tmp_yml }
|
||
|
||
if system(env, 'bundle exec rails db:test:prepare')
|
||
results[db_name] = system(env, 'rails', task_name) ? :pass : :fail
|
||
else
|
||
results[db_name] = :prepare_failed
|
||
end
|
||
end
|
||
end
|
||
|
||
print_summary(cfgs, results)
|
||
|
||
failed = results.count { |_, s| [:fail, :prepare_failed].include?(s) }
|
||
exit(false) if failed > 0
|
||
end
|
||
|
||
private
|
||
|
||
def adapter_available?(adapter)
|
||
require ADAPTER_GEMS.fetch(adapter, adapter)
|
||
true
|
||
rescue LoadError
|
||
false
|
||
end
|
||
|
||
def adapter_group(adapter)
|
||
ADAPTER_BUNDLE_GROUPS.fetch(adapter, adapter)
|
||
end
|
||
|
||
def current_with
|
||
(Bundler.settings[:with] || '').split(':').join(' ')
|
||
end
|
||
|
||
def print_summary(cfgs, results)
|
||
return if results.size <= 1 # no summary for single-DB runs
|
||
|
||
puts "\n#{'═' * 64}"
|
||
puts ' SUMMARY'
|
||
puts '═' * 64
|
||
results.each do |db_name, status|
|
||
adapter = cfgs.dig(db_name, 'adapter') || '?'
|
||
icon = status == :pass ? '✓' : (status == :skipped ? '–' : '✗')
|
||
label = { pass: 'PASS', fail: 'FAIL',
|
||
prepare_failed: 'PREPARE FAILED', skipped: 'SKIPPED' }[status]
|
||
puts " #{icon} #{db_name.ljust(26)} (#{adapter.ljust(12)}) #{label}"
|
||
end
|
||
puts '═' * 64
|
||
end
|
||
end
|
||
end
|
||
|
||
# Rewrite every standard test task to run against all configured databases.
|
||
# This file loads after railties/testing.rake, so all tasks already exist.
|
||
# Single-database setups are completely unaffected.
|
||
if MultiDbTests.test_configs.size > 1
|
||
MultiDbTests::WRAPPED_TASKS.each do |task_name|
|
||
next unless Rake::Task.task_defined?(task_name)
|
||
|
||
Rake::Task[task_name].clear_actions
|
||
Rake::Task[task_name].enhance do
|
||
MultiDbTests.run(task_name)
|
||
end
|
||
end
|
||
end
|