From 24539f236c21ad0b2dfa9396be3906599fb7bf37 Mon Sep 17 00:00:00 2001 From: barbie-bot Date: Sun, 5 Apr 2026 10:48:53 +0000 Subject: [PATCH] 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_:` 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 --- config/application.rb.dist | 4 ++ config/database.yml.dist | 23 +++++++ lib/tasks/test_databases.rake | 118 ++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 lib/tasks/test_databases.rake diff --git a/config/application.rb.dist b/config/application.rb.dist index f1be478..f4817b8 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 `rails test:all_databases` to test against multiple DB adapters. + 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..4ff921e 100644 --- a/config/database.yml.dist +++ b/config/database.yml.dist @@ -48,3 +48,26 @@ production: #test: # <<: *default # database: fixinme_test + +# Multi-database testing — `bundle exec rails test:all_databases` +# --------------------------------------------------------------- +# Add any number of `test_:` blocks to run the full test suite +# against additional database adapters in a single command. +# 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 +# +# Example — run tests against MySQL and SQLite: +# +#test_sqlite: +# adapter: sqlite3 +# database: db/fixinme_test.sqlite3 +# +# Example — run tests against MySQL and PostgreSQL: +# +#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..639d283 --- /dev/null +++ b/lib/tasks/test_databases.rake @@ -0,0 +1,118 @@ +require 'yaml' +require 'erb' +require 'tmpdir' + +namespace :test do + desc <<~DESC + Run the full test suite against every test database configured in database.yml. + + Any top-level key that starts with "test" and contains a Hash is treated as a + 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', + 'sqlite3' => 'sqlite3', + 'postgresql' => 'pg', + 'pg' => 'pg', +}.freeze + +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', + 'sqlite3' => 'sqlite', + 'postgresql' => 'postgresql', + 'pg' => 'postgresql', +}.freeze + +def adapter_group(adapter) + ADAPTER_GROUPS.fetch(adapter, adapter) +end