diff --git a/config/application.rb.dist b/config/application.rb.dist index f1be478..ea1f8b6 100644 --- a/config/application.rb.dist +++ b/config/application.rb.dist @@ -20,6 +20,10 @@ Bundler.require(*Rails.groups) module FixinMe class Application < Rails::Application + # Allow RAILS_DATABASE_YML to override the database config file path. + # Used by the multi-database test runner (lib/tasks/test_databases.rake). + config.paths['config/database'] = [ENV['RAILS_DATABASE_YML']] if ENV['RAILS_DATABASE_YML'] + # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.0 diff --git a/config/database.yml.dist b/config/database.yml.dist index 5d079fe..af8bff1 100644 --- a/config/database.yml.dist +++ b/config/database.yml.dist @@ -48,3 +48,24 @@ production: #test: # <<: *default # database: fixinme_test + +# Multi-database testing +# ---------------------- +# Any key starting with "test" is treated as a test database. +# When more than one is present, EVERY test task (rails test, rails test:models, +# rails test:system, …) automatically runs against all of them. +# +# The adapter gem must be available: +# bundle config --local with "mysql sqlite" # mysql + sqlite +# bundle config --local with "mysql pg" # mysql + postgresql +# +#test_sqlite: +# adapter: sqlite3 +# database: db/fixinme_test.sqlite3 +# +#test_pg: +# adapter: postgresql +# database: fixinme_test +# username: fixinme +# password: Some-password1% +# host: localhost diff --git a/lib/tasks/test_databases.rake b/lib/tasks/test_databases.rake new file mode 100644 index 0000000..2c26f1a --- /dev/null +++ b/lib/tasks/test_databases.rake @@ -0,0 +1,158 @@ +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