Compare commits

..

1 Commits

Author SHA1 Message Date
24539f236c Add multi-database test runner (test:all_databases)
Adds `bundle exec rails test:all_databases` which runs the full test
suite against every test database configured in database.yml in a single
command.

Convention: any top-level key starting with "test" that contains a Hash
is a test database. `test:` is the required primary; `test_<name>:` blocks
are optional additional adapters (e.g. test_sqlite, test_pg).

For each configured database the task:
  1. Checks the required adapter gem is available (skips with warning if not)
  2. Runs `rails db:test:prepare` to create and migrate the database
  3. Runs `rails test` and records pass/fail
  4. Prints a summary and exits non-zero if any database failed

Mechanism: a RAILS_DATABASE_YML env var points each subprocess to a
temporary database.yml that contains only the current test config.
config/application.rb(.dist) reads this var and overrides Rails'
database config path before initialisation, so no monkey-patching of
the test runner is required.

config/database.yml.dist is updated with documented examples for SQLite
and PostgreSQL additional test databases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:48:53 +00:00
3 changed files with 106 additions and 144 deletions

View File

@@ -21,7 +21,7 @@ Bundler.require(*Rails.groups)
module FixinMe module FixinMe
class Application < Rails::Application class Application < Rails::Application
# Allow RAILS_DATABASE_YML to override the database config file path. # Allow RAILS_DATABASE_YML to override the database config file path.
# Used by the multi-database test runner (lib/tasks/test_databases.rake). # Used by `rails test:all_databases` to test against multiple DB adapters.
config.paths['config/database'] = [ENV['RAILS_DATABASE_YML']] if ENV['RAILS_DATABASE_YML'] config.paths['config/database'] = [ENV['RAILS_DATABASE_YML']] if ENV['RAILS_DATABASE_YML']
# Initialize configuration defaults for originally generated Rails version. # Initialize configuration defaults for originally generated Rails version.

View File

@@ -49,20 +49,22 @@ production:
# <<: *default # <<: *default
# database: fixinme_test # database: fixinme_test
# Multi-database testing # Multi-database testing — `bundle exec rails test:all_databases`
# ---------------------- # ---------------------------------------------------------------
# Any key starting with "test" is treated as a test database. # Add any number of `test_<name>:` blocks to run the full test suite
# When more than one is present, EVERY test task (rails test, rails test:models, # against additional database adapters in a single command.
# rails test:system, …) automatically runs against all of them. # Each adapter's gem must be available in the bundle:
# bundle config --local with "mysql:sqlite" # mysql + sqlite
# bundle config --local with "mysql:pg" # mysql + postgresql
# #
# The adapter gem must be available: # Example — run tests against MySQL and SQLite:
# bundle config --local with "mysql sqlite" # mysql + sqlite
# bundle config --local with "mysql pg" # mysql + postgresql
# #
#test_sqlite: #test_sqlite:
# adapter: sqlite3 # adapter: sqlite3
# database: db/fixinme_test.sqlite3 # database: db/fixinme_test.sqlite3
# #
# Example — run tests against MySQL and PostgreSQL:
#
#test_pg: #test_pg:
# adapter: postgresql # adapter: postgresql
# database: fixinme_test # database: fixinme_test

View File

@@ -2,157 +2,117 @@ require 'yaml'
require 'erb' require 'erb'
require 'tmpdir' require 'tmpdir'
# Multi-database test runner namespace :test do
# ========================== desc <<~DESC
# When database.yml contains more than one `test*` configuration, every Run the full test suite against every test database configured in database.yml.
# 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 Any top-level key that starts with "test" and contains a Hash is treated as a
ADAPTER_GEMS = { test database configuration:
test: # always required — the primary test database
adapter: mysql2
database: fixinme_test
...
test_sqlite: # optional additional databases
adapter: sqlite3
database: db/fixinme_test.sqlite3
test_pg:
adapter: postgresql
database: fixinme_test
...
For each database the task will:
1. Check that the required adapter gem is available (skip with warning if not).
2. Run `rails db:test:prepare` to create/migrate the database.
3. Run `rails test` and record pass/fail.
A summary is printed at the end. The task exits non-zero if any database fails.
DESC
task :all_databases do
db_file = Rails.root.join('config', 'database.yml')
all = YAML.safe_load(ERB.new(db_file.read).result, aliases: true) || {}
test_cfgs = all.select { |k, v| k.to_s.start_with?('test') && v.is_a?(Hash) }
non_test = all.reject { |k, _| k.to_s.start_with?('test') }
abort "No test database configurations found in #{db_file}." if test_cfgs.empty?
results = {}
test_cfgs.each do |name, config|
adapter = config['adapter'].to_s
puts "\n#{'─' * 64}"
puts " #{name} (adapter: #{adapter})"
puts '─' * 64
unless adapter_available?(adapter)
puts " SKIPPED — gem for '#{adapter}' adapter not available in bundle."
puts " Add it to Gemfile (e.g. `bundle config --local with #{adapter_group(adapter)}`)"
results[name] = :skipped
next
end
Dir.mktmpdir('rails_test_db_') do |tmpdir|
tmp_yml = File.join(tmpdir, 'database.yml')
# Write a standalone database.yml with just this config as `test:`
# (non-test configs are preserved so boot doesn't fail on `production:` lookup)
File.write(tmp_yml, non_test.merge('test' => config).to_yaml)
env = { 'RAILS_DATABASE_YML' => tmp_yml }
if system(env, 'bundle exec rails db:test:prepare')
results[name] = system(env, 'bundle exec rails test') ? :pass : :fail
else
results[name] = :prepare_failed
end
end
end
puts "\n#{'═' * 64}"
puts " SUMMARY"
puts '═' * 64
results.each do |name, status|
adapter = test_cfgs[name]['adapter']
icon = { pass: '✓', fail: '✗', prepare_failed: '✗', skipped: '' }[status]
label = { pass: 'PASS', fail: 'FAIL',
prepare_failed: 'PREPARE FAILED', skipped: 'SKIPPED' }[status]
puts " #{icon} #{name.ljust(26)} (#{adapter.ljust(12)}) #{label}"
end
puts '═' * 64
failed = results.count { |_, s| s == :fail || s == :prepare_failed }
abort "\n #{failed} database(s) failed." if failed > 0
end
end
private
# Returns true if the gem required for this adapter is loadable.
ADAPTER_GEMS = {
'mysql2' => 'mysql2', 'mysql2' => 'mysql2',
'sqlite3' => 'sqlite3', 'sqlite3' => 'sqlite3',
'postgresql' => 'pg', 'postgresql' => 'pg',
'pg' => 'pg', 'pg' => 'pg',
}.freeze }.freeze
ADAPTER_BUNDLE_GROUPS = { def adapter_available?(adapter)
gem_name = ADAPTER_GEMS.fetch(adapter, adapter)
require gem_name
true
rescue LoadError
false
end
# Returns the Bundler group name that installs the adapter gem.
ADAPTER_GROUPS = {
'mysql2' => 'mysql', 'mysql2' => 'mysql',
'sqlite3' => 'sqlite', 'sqlite3' => 'sqlite',
'postgresql' => 'postgresql', 'postgresql' => 'postgresql',
'pg' => 'postgresql', 'pg' => 'postgresql',
}.freeze }.freeze
# Rake task names generated by railties/lib/rails/test_unit/testing.rake def adapter_group(adapter)
# that use run_from_rake — these are the ones we rewrite. ADAPTER_GROUPS.fetch(adapter, adapter)
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 end