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