Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions lib/data_migrate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions lib/data_migrate/config.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module DataMigrate
include ActiveSupport::Configurable
class << self

class << self
def configure
yield config
end
Expand All @@ -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"

Expand All @@ -21,6 +21,7 @@ def initialize
@data_template_path = DEFAULT_DATA_TEMPLATE_PATH
@db_configuration = nil
@spec_name = nil
@test_support_enabled = false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick:

I've read your PR a few times so far. Decided to do one last round from the "does it make me confused if I jump straight into the code without reading the context?". So please consider my comments as ideas / suggestions, and not something authoritative.

In this instance, I feel like the name is a bit confusing. Because it's not that we turn on test support, but we turn on test generation. So I'd prefer to have test_generator_enabled, generate_tests, generate_specs, generate_test_files, etc.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this suggested change as it better represents what the configuration value achieves 💯

end

def data_template_path=(value)
Expand Down
15 changes: 15 additions & 0 deletions lib/data_migrate/helpers/infer_test_suite_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module DataMigrate
module Helpers
class InferTestSuiteType
def call
if File.exist?(Rails.root.join('spec', 'spec_helper.rb'))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could perform add to the conditionals here for Rails.configuration.generators.options[:rails][:test_framework] == :rspec and Rails.configuration.generators.options[:rails][:test_framework] == :test_unit respectively to look for both installation and configuration.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of getting the test framework from Rails's config. I've worked on projects where both rspec and minitest were used (migration happening but not over and very little bandwidth to finish it) and I think its easier for us to maintain compatibility with the configuration over the presence of files?

Perhaps adding a config line would work too, but I'd imagine projects running on sinatra for example to maybe not follow the conventions set elsewhere?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a simple configuration value to early return when set OR fallback to the inference logic.

: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
76 changes: 76 additions & 0 deletions lib/data_migrate/tasks/setup_tests.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

module DataMigrate
module Tasks
class SetupTests
INJECTION_MATCHER = Regexp.new(/require_relative ["|']\.\.\/config\/environment["|']/)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had mixed feeling on this. Basically, I want to find a target phrase in either the rails_helper.rb or test_helper.rb. It seemed best to inject the require loop https://github.com/ilyakatz/data-migrate/pull/355/files#diff-ffd91a3de47aa2b12e05f99c726f74f27c7181b3f2f987c24aa9af32f72ea2acR57 after the environment was loaded. Open to other suggestions.

The setup task is also optional. You could just individually require files per data migration test file.


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')
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rails_helper.rb contains the injection target BUT DataMigrate::Helpers::InferTestSuiteType checks spec_helper.rb (see: https://github.com/ilyakatz/data-migrate/pull/355/files#diff-e3eb6de888eae80ac87faa33be0419ff8abdc2bd7d14e23c243304834d53604eR5). I'm thinking these maybe should match in their conditional checks. Thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you were to remove the lines added to inject the require statement, this all disappears. It wouldn't be as full-fledged as this solution but avoids a bunch of complexity which users can just replace if they wished to do so.

A question that just came to mind, why not just inject the require statement inside the spec file generated?

Copy link
Author

@joshmfrankel joshmfrankel Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A question that just came to mind, why not just inject the require statement inside the spec file generated?

That would definitely work for projects that have RSpec / Minitest configured with data_migrate at the start. My original intent here was to allow existing projects to run a Rake task to easily add the line to the spec file. Though to be honest I agree with your first statement regarding the complexity of all this to essentially inject two lines of code. I'm going to change this to simply be part of the readme and see if it cleans things up

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 }"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/vprigent/data-migrate/blob/517328051687873c9f8412efe2d6a8f2cf7e9157/README.md?plain=1#L167

DataMigrate.config.data_migrations_path can be an array of locations in order to support engine-based data migrations...

We could reverse this logic and do something like this perhaps:

Suggested change
"Dir[Rails.root.join(DataMigrate.config.data_migrations_path, '*.rb')].each { |f| require f }"
"Dir[*Array.wrap(DataMigrate.config.data_migrations_path).map{|path| Rails.root.join(path, '*.rb')}].each { |f| require f }"

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I honestly think it would be easier to let the user require whichever file they are about to test within their spec file though?

Copy link
Author

@joshmfrankel joshmfrankel Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I honestly think it would be easier to let the user require whichever file they are about to test within their spec file though?

Definitely true to explicitly include these. I'm thinking for ease-of-use, having an automated way to include any & all data files might make things easier than multiple require lines. Not all data migrations would be tested, though, so might be a good case for an explicit approach.

Good catch on engines also

]
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
20 changes: 20 additions & 0 deletions lib/generators/data_migration/data_migration_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick:

... adding to my previous comment: would be nice if we could always rely on config here, instead of manually infering things each time

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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may have issues here too with data_migrations_path not being necessarily a single path either here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use the same logic here:
https://github.com/vprigent/data-migrate/blob/517328051687873c9f8412efe2d6a8f2cf7e9157/lib/generators/data_migration/data_migration_generator.rb#L61-L64

Though ideally we'd abstract that away and make a definitive location as being the location for new migrations while still allowing engines/libraries to integrate with data_migration in a way that's painless for users.

end

def set_local_assigns!
if file_name =~ /^(add|remove)_.*_(?:to|from)_(.*)/
@migration_action = $1
Expand Down
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions lib/generators/data_migration/templates/data_migration_test.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions spec/data_migrate/helpers/infer_test_suite_type_spec.rb
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions spec/data_migrate/tasks/setup_tests_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading