From 517328051687873c9f8412efe2d6a8f2cf7e9157 Mon Sep 17 00:00:00 2001 From: Josh Frankel Date: Wed, 11 Dec 2024 15:57:46 -0500 Subject: [PATCH] Feature: Add generator and rake task support for creating test coverage on data migrations * New Rake task `rake data:tests:setup` * New Config setting `config.test_support = true/false` (false by default) --- README.md | 17 +++- lib/data_migrate.rb | 2 + lib/data_migrate/config.rb | 5 +- .../helpers/infer_test_suite_type.rb | 15 +++ lib/data_migrate/tasks/setup_tests.rb | 76 +++++++++++++++ .../data_migration_generator.rb | 20 ++++ .../templates/data_migration_spec.rb | 9 ++ .../templates/data_migration_test.rb | 15 +++ .../helpers/infer_test_suite_type_spec.rb | 44 +++++++++ spec/data_migrate/tasks/setup_tests_spec.rb | 93 +++++++++++++++++++ .../data_migration_generator_spec.rb | 48 +++++++++- tasks/databases.rake | 8 ++ 12 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 lib/data_migrate/helpers/infer_test_suite_type.rb create mode 100644 lib/data_migrate/tasks/setup_tests.rb create mode 100644 lib/generators/data_migration/templates/data_migration_spec.rb create mode 100644 lib/generators/data_migration/templates/data_migration_test.rb create mode 100644 spec/data_migrate/helpers/infer_test_suite_type_spec.rb create mode 100644 spec/data_migrate/tasks/setup_tests_spec.rb diff --git a/README.md b/README.md index 9ca20950..bbc1824f 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ You can generate a data migration as you would a schema migration: rake data:migrate:up # Runs the "up" for a given migration VERSION rake data:rollback # Rolls the schema back to the previous version (specify steps w/ STEP=n) rake data:schema:load # Load data_schema.rb file into the database without running the data migrations + rake data:tests:setup # Setup data migrations for identified test suite rake data:version # Retrieves the current schema version number for data migrations rake db:abort_if_pending_migrations:with_data # Raises an error if there are pending migrations or data migrations rake db:forward:with_data # Pushes the schema to the next version (specify steps w/ STEP=n) @@ -125,10 +126,24 @@ DataMigrate.configure do |config| 'password' => nil, } config.spec_name = 'primary' -end + # Enable data_migration generator to create test files + config.test_support_enabled = true +end ``` +### Test Suite Support + +When `config.test_support_enabled = true`, the `data_migration` generator will create test files for your data migrations. This is dependent on +the test suite you are using. + +For example, if you run `rails g data_migration add_this_to_that`, the following files will be created: + +- `/spec/db/data/add_this_to_that_spec.rb` (for RSpec) +- `/test/db/data/add_this_to_that_test.rb` (for Minitest) + +You can also run the Rake task `rake data:tests:setup` to configure your test suite to load data migrations. + ## Capistrano Support The gem comes with a capistrano task that can be used instead of `capistrano/rails/migrations`. diff --git a/lib/data_migrate.rb b/lib/data_migrate.rb index d85d1639..22d3d199 100644 --- a/lib/data_migrate.rb +++ b/lib/data_migrate.rb @@ -9,7 +9,9 @@ require File.join(File.dirname(__FILE__), "data_migrate", "status_service") require File.join(File.dirname(__FILE__), "data_migrate", "migration_context") require File.join(File.dirname(__FILE__), "data_migrate", "railtie") +require File.join(File.dirname(__FILE__), "data_migrate", "helpers/infer_test_suite_type") require File.join(File.dirname(__FILE__), "data_migrate", "tasks/data_migrate_tasks") +require File.join(File.dirname(__FILE__), "data_migrate", "tasks/setup_tests") require File.join(File.dirname(__FILE__), "data_migrate", "config") require File.join(File.dirname(__FILE__), "data_migrate", "schema_migration") require File.join(File.dirname(__FILE__), "data_migrate", "database_configurations_wrapper") diff --git a/lib/data_migrate/config.rb b/lib/data_migrate/config.rb index 15abf276..0624f542 100644 --- a/lib/data_migrate/config.rb +++ b/lib/data_migrate/config.rb @@ -1,7 +1,7 @@ module DataMigrate include ActiveSupport::Configurable - class << self + class << self def configure yield config end @@ -12,7 +12,7 @@ def config end class Config - attr_accessor :data_migrations_path, :data_template_path, :db_configuration, :spec_name + attr_accessor :data_migrations_path, :data_template_path, :db_configuration, :spec_name, :test_support_enabled DEFAULT_DATA_TEMPLATE_PATH = "data_migration.rb" @@ -21,6 +21,7 @@ def initialize @data_template_path = DEFAULT_DATA_TEMPLATE_PATH @db_configuration = nil @spec_name = nil + @test_support_enabled = false end def data_template_path=(value) diff --git a/lib/data_migrate/helpers/infer_test_suite_type.rb b/lib/data_migrate/helpers/infer_test_suite_type.rb new file mode 100644 index 00000000..ac6c9702 --- /dev/null +++ b/lib/data_migrate/helpers/infer_test_suite_type.rb @@ -0,0 +1,15 @@ +module DataMigrate + module Helpers + class InferTestSuiteType + def call + if File.exist?(Rails.root.join('spec', 'spec_helper.rb')) + :rspec + elsif File.exist?(Rails.root.join('test', 'test_helper.rb')) + :minitest + else + raise StandardError.new('Unable to determine test suite') + end + end + end + end +end diff --git a/lib/data_migrate/tasks/setup_tests.rb b/lib/data_migrate/tasks/setup_tests.rb new file mode 100644 index 00000000..b6761bd8 --- /dev/null +++ b/lib/data_migrate/tasks/setup_tests.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module DataMigrate + module Tasks + class SetupTests + INJECTION_MATCHER = Regexp.new(/require_relative ["|']\.\.\/config\/environment["|']/) + + def call + return if injection_exists? + + if find_injection_location.nil? + puts 'data_migrate: config/environment.rb was not found in the test helper file.' + return + end + + add_newline + + lines_for_injection.reverse.each do |line| + file_contents.insert(find_injection_location, "#{line}\n") + end + + add_newline + + File.open(test_helper_file_path, 'w') do |file| + file.puts file_contents + end + + puts 'data_migrate: Test setup complete.' + end + + private + + def test_helper_file_path + case DataMigrate::Helpers::InferTestSuiteType.new.call + when :rspec + Rails.root.join('spec', 'rails_helper.rb') + when :minitest + Rails.root.join('test', 'test_helper.rb') + end + end + + def file_contents + @_file_contents ||= File.readlines(test_helper_file_path) + end + + def find_injection_location + @_find_injection_location ||= begin + index = file_contents.index { |line| line.match?(INJECTION_MATCHER) } + index.present? ? index + 1 : nil + end + end + + def add_newline + file_contents.insert(find_injection_location, "\n") + end + + def lines_for_injection + [ + "# data_migrate: Include data migrations for writing test coverage", + "Dir[Rails.root.join(DataMigrate.config.data_migrations_path, '*.rb')].each { |f| require f }" + ] + end + + def injection_exists? + file_contents.each_cons(lines_for_injection.length) do |content_window| + if content_window.map(&:strip) == lines_for_injection.map(&:strip) + puts 'data_migrate: Test setup already exists.' + return true + end + end + + false + end + end + end +end diff --git a/lib/generators/data_migration/data_migration_generator.rb b/lib/generators/data_migration/data_migration_generator.rb index 1bd23a17..9cf19314 100644 --- a/lib/generators/data_migration/data_migration_generator.rb +++ b/lib/generators/data_migration/data_migration_generator.rb @@ -15,10 +15,30 @@ class DataMigrationGenerator < Rails::Generators::NamedBase def create_data_migration set_local_assigns! migration_template template_path, data_migrations_file_path + create_data_migration_test end protected + def create_data_migration_test + return unless DataMigrate.config.test_support_enabled + + case DataMigrate::Helpers::InferTestSuiteType.new.call + when :rspec + template "data_migration_spec.rb", data_migrations_spec_file_path + when :minitest + template "data_migration_test.rb", data_migrations_test_file_path + end + end + + def data_migrations_test_file_path + File.join(Rails.root, 'test', DataMigrate.config.data_migrations_path, "#{file_name}_test.rb") + end + + def data_migrations_spec_file_path + File.join(Rails.root, 'spec', DataMigrate.config.data_migrations_path, "#{file_name}_spec.rb") + end + def set_local_assigns! if file_name =~ /^(add|remove)_.*_(?:to|from)_(.*)/ @migration_action = $1 diff --git a/lib/generators/data_migration/templates/data_migration_spec.rb b/lib/generators/data_migration/templates/data_migration_spec.rb new file mode 100644 index 00000000..f1a47aef --- /dev/null +++ b/lib/generators/data_migration/templates/data_migration_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +describe <%= migration_class_name %>, type: :data_migration do + let(:migration) { <%= migration_class_name %>.new } + + pending "should test `migration.up`" + + pending "should test `migration.down`" +end diff --git a/lib/generators/data_migration/templates/data_migration_test.rb b/lib/generators/data_migration/templates/data_migration_test.rb new file mode 100644 index 00000000..26faf5f8 --- /dev/null +++ b/lib/generators/data_migration/templates/data_migration_test.rb @@ -0,0 +1,15 @@ +require 'test_helper' + +class <%= migration_class_name %>Test < ActiveSupport::TestCase + def setup + @migration = <%= migration_class_name %>.new + end + + def test_migration_up + skip("Pending test coverage for @migration.up") + end + + def test_migration_down + skip("Pending test coverage for @migration.down") + end +end diff --git a/spec/data_migrate/helpers/infer_test_suite_type_spec.rb b/spec/data_migrate/helpers/infer_test_suite_type_spec.rb new file mode 100644 index 00000000..a2092244 --- /dev/null +++ b/spec/data_migrate/helpers/infer_test_suite_type_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe DataMigrate::Helpers::InferTestSuiteType do + subject(:infer_test_suite) { described_class.new } + + describe '#call' do + before do + allow(Rails).to receive(:root).and_return(Pathname.new('/fake/path')) + end + + context 'when RSpec is detected' do + before do + allow(File).to receive(:exist?).with(Rails.root.join('spec', 'spec_helper.rb')).and_return(true) + allow(File).to receive(:exist?).with(Rails.root.join('test', 'test_helper.rb')).and_return(false) + end + + it 'returns :rspec' do + expect(infer_test_suite.call).to eq(:rspec) + end + end + + context 'when Minitest is detected' do + before do + allow(File).to receive(:exist?).with(Rails.root.join('spec', 'spec_helper.rb')).and_return(false) + allow(File).to receive(:exist?).with(Rails.root.join('test', 'test_helper.rb')).and_return(true) + end + + it 'returns :minitest' do + expect(infer_test_suite.call).to eq(:minitest) + end + end + + context 'when no test suite is detected' do + before do + allow(File).to receive(:exist?).with(Rails.root.join('spec', 'spec_helper.rb')).and_return(false) + allow(File).to receive(:exist?).with(Rails.root.join('test', 'test_helper.rb')).and_return(false) + end + + it 'raises an error' do + expect { infer_test_suite.call }.to raise_error(StandardError, 'Unable to determine test suite') + end + end + end +end diff --git a/spec/data_migrate/tasks/setup_tests_spec.rb b/spec/data_migrate/tasks/setup_tests_spec.rb new file mode 100644 index 00000000..3c678b86 --- /dev/null +++ b/spec/data_migrate/tasks/setup_tests_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe DataMigrate::Tasks::SetupTests do + let(:file_contents_with_injection) do + <<~FILE_CONTENTS + # This file is copied to spec/ when you run 'rails generate rspec:install' + require 'spec_helper' + ENV['RAILS_ENV'] ||= 'test' + require_relative '../config/environment' + + # data_migrate: Include data migrations for writing test coverage + Dir[Rails.root.join(DataMigrate.config.data_migrations_path, '*.rb')].each { |f| require f } + FILE_CONTENTS + end + let(:file_contents_without_injection) do + <<~FILE_CONTENTS + # This file is copied to spec/ when you run 'rails generate rspec:install' + require 'spec_helper' + ENV['RAILS_ENV'] ||= 'test' + require_relative '../config/environment' + FILE_CONTENTS + end + let(:file_contents_without_injection_matcher) do + <<~FILE_CONTENTS + # This file is copied to spec/ when you run 'rails generate rspec:install' + require 'spec_helper' + ENV['RAILS_ENV'] ||= 'test' + FILE_CONTENTS + end + let(:rails_root) { Pathname.new('/fake/app') } + let(:test_suite_inferrer) { instance_double(DataMigrate::Helpers::InferTestSuiteType) } + + before do + allow(Rails).to receive(:root).and_return(rails_root) + allow(DataMigrate::Helpers::InferTestSuiteType).to receive(:new).and_return(test_suite_inferrer) + end + + describe "#call" do + context 'when the injected code already exists' do + it 'returns early' do + allow(test_suite_inferrer).to receive(:call).and_return(:rspec) + allow(File).to receive(:readlines).and_return(file_contents_with_injection.lines) + + expect(File).not_to receive(:open) + + expect { + DataMigrate::Tasks::SetupTests.new.call + }.to output(/data_migrate: Test setup already exists./).to_stdout + end + + context 'when the INJECTION_MATCHER is not found' do + it 'returns early' do + allow(test_suite_inferrer).to receive(:call).and_return(:rspec) + allow(File).to receive(:readlines).and_return(file_contents_without_injection_matcher.lines) + + expect(File).not_to receive(:open) + + expect { + DataMigrate::Tasks::SetupTests.new.call + }.to output(/data_migrate: config\/environment.rb was not found in the test helper file./).to_stdout + end + end + + context 'for RSpec' do + it 'calls File.open for writing to rails_helper.rb' do + allow(test_suite_inferrer).to receive(:call).and_return(:rspec) + allow(File).to receive(:readlines).and_return(file_contents_without_injection.lines) + + expect(File).to receive(:open).with(rails_root.join('spec', 'rails_helper.rb'), 'w') + + expect { + DataMigrate::Tasks::SetupTests.new.call + }.to output(/data_migrate: Test setup complete./).to_stdout + end + end + + context 'for Minitest' do + it 'calls File.open for writing to test_helper.rb' do + allow(test_suite_inferrer).to receive(:call).and_return(:minitest) + allow(File).to receive(:readlines).and_return(file_contents_without_injection.lines) + + expect(File).to receive(:open).with(rails_root.join('test', 'test_helper.rb'), 'w') + + expect { + DataMigrate::Tasks::SetupTests.new.call + }.to output(/data_migrate: Test setup complete./).to_stdout + end + end + end + end +end diff --git a/spec/generators/data_migration/data_migration_generator_spec.rb b/spec/generators/data_migration/data_migration_generator_spec.rb index 47547154..dabecc9b 100644 --- a/spec/generators/data_migration/data_migration_generator_spec.rb +++ b/spec/generators/data_migration/data_migration_generator_spec.rb @@ -30,9 +30,11 @@ end describe :create_data_migration do - subject { DataMigrate::Generators::DataMigrationGenerator.new(['my_migration']) } + let(:migration_name) { 'my_migration' } - let(:data_migrations_file_path) { 'abc/my_migration.rb' } + subject { DataMigrate::Generators::DataMigrationGenerator.new([migration_name]) } + + let(:data_migrations_file_path) { "abc/#{migration_name}.rb" } context 'when custom data migrations path has a trailing slash' do before do @@ -44,6 +46,8 @@ 'data_migration.rb', data_migrations_file_path ) + expect(subject).not_to receive(:template) + subject.create_data_migration end end @@ -58,9 +62,49 @@ 'data_migration.rb', data_migrations_file_path ) + expect(subject).not_to receive(:template) + subject.create_data_migration end end + + context 'when test support is enabled' do + let(:rails_root) { 'dummy-app' } + + before do + DataMigrate.config.data_migrations_path = 'db/data/' + DataMigrate.config.test_support_enabled = true + + allow(Rails).to receive(:root).and_return(Pathname.new(rails_root)) + expect(DataMigrate::Helpers::InferTestSuiteType).to receive(:new).and_return(infer_double) + end + + context 'and the test suite is RSpec' do + let(:infer_double) { instance_double(DataMigrate::Helpers::InferTestSuiteType, call: :rspec) } + + it 'creates a spec file' do + expect(subject).to receive(:template).with( + 'data_migration_spec.rb', + "#{rails_root}/spec/db/data/#{migration_name}_spec.rb" + ) + + subject.create_data_migration + end + end + + context 'and the test suite is Minitest' do + let(:infer_double) { instance_double(DataMigrate::Helpers::InferTestSuiteType, call: :minitest) } + + it 'creates a test file' do + expect(subject).to receive(:template).with( + 'data_migration_test.rb', + "#{rails_root}/test/db/data/#{migration_name}_test.rb" + ) + + subject.create_data_migration + end + end + end end describe ".source_root" do diff --git a/tasks/databases.rake b/tasks/databases.rake index 7c92515f..d064bc05 100644 --- a/tasks/databases.rake +++ b/tasks/databases.rake @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'data_migrate/tasks/data_migrate_tasks' +require 'data_migrate/tasks/setup_tests' namespace :db do namespace :migrate do @@ -251,4 +252,11 @@ namespace :data do ) end end + + namespace :tests do + desc "Setup hook for additional functionality" + task setup: :environment do + DataMigrate::Tasks::SetupTests.new.call + end + end end