test_data 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +1 -5
  3. data/.standard.yml +2 -0
  4. data/CHANGELOG.md +38 -1
  5. data/Gemfile.lock +15 -15
  6. data/LICENSE.txt +1 -1
  7. data/README.md +701 -712
  8. data/example/.gitignore +1 -4
  9. data/example/Gemfile.lock +1 -1
  10. data/example/config/application.rb +3 -0
  11. data/example/config/credentials.yml.enc +1 -2
  12. data/example/spec/rails_helper.rb +1 -1
  13. data/example/spec/requests/boops_spec.rb +1 -5
  14. data/example/spec/requests/rails_fixtures_override_spec.rb +106 -0
  15. data/example/test/integration/better_mode_switching_demo_test.rb +2 -10
  16. data/example/test/integration/fixture_load_count_test.rb +82 -0
  17. data/example/test/integration/load_rollback_truncate_test.rb +40 -45
  18. data/example/test/integration/mode_switching_demo_test.rb +4 -14
  19. data/example/test/integration/parallel_boops_with_fixtures_test.rb +1 -5
  20. data/example/test/integration/parallel_boops_without_fixtures_test.rb +1 -5
  21. data/example/test/integration/rails_fixtures_double_load_test.rb +2 -2
  22. data/example/test/integration/rails_fixtures_override_test.rb +18 -35
  23. data/example/test/integration/test_data_hooks_test.rb +89 -0
  24. data/example/test/integration/transaction_committing_boops_test.rb +1 -10
  25. data/example/test/test_helper.rb +1 -5
  26. data/lib/generators/test_data/environment_file_generator.rb +4 -0
  27. data/lib/generators/test_data/initializer_generator.rb +19 -13
  28. data/lib/test_data/config.rb +30 -12
  29. data/lib/test_data/custom_loaders/abstract_base.rb +25 -0
  30. data/lib/test_data/custom_loaders/rails_fixtures.rb +45 -0
  31. data/lib/test_data/detects_database_existence.rb +19 -0
  32. data/lib/test_data/determines_databases_associated_dump_time.rb +13 -0
  33. data/lib/test_data/determines_when_sql_dump_was_made.rb +24 -0
  34. data/lib/test_data/dumps_database.rb +3 -0
  35. data/lib/test_data/inserts_test_data.rb +25 -0
  36. data/lib/test_data/manager.rb +187 -0
  37. data/lib/test_data/railtie.rb +4 -0
  38. data/lib/test_data/rake.rb +41 -12
  39. data/lib/test_data/records_dump_metadata.rb +9 -0
  40. data/lib/test_data/truncates_test_data.rb +31 -0
  41. data/lib/test_data/version.rb +1 -1
  42. data/lib/test_data/warns_if_database_is_newer_than_dump.rb +32 -0
  43. data/lib/test_data/warns_if_dump_is_newer_than_database.rb +36 -0
  44. data/lib/test_data.rb +43 -1
  45. data/script/reset_example_app +1 -0
  46. data/script/test +54 -6
  47. metadata +17 -3
  48. data/lib/test_data/transactional_data_loader.rb +0 -300
@@ -0,0 +1,25 @@
1
+ module TestData
2
+ module CustomLoaders
3
+ class AbstractBase
4
+ def name
5
+ raise Error.new("#name must be defined by CustomLoader subclass")
6
+ end
7
+
8
+ def load_requested(**options)
9
+ end
10
+
11
+ def loaded?(**options)
12
+ # Check to see if the requested data is already loaded (if possible and
13
+ # detectable)
14
+ #
15
+ # Return true to prevent #load from being called, potentially avoiding an
16
+ # expensive operation
17
+ false
18
+ end
19
+
20
+ def load(**options)
21
+ raise Error.new("#load must be defined by CustomLoader subclass")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,45 @@
1
+ module TestData
2
+ module CustomLoaders
3
+ class RailsFixtures < AbstractBase
4
+ def initialize
5
+ @config = TestData.config
6
+ @statistics = TestData.statistics
7
+ @already_loaded_rails_fixtures = {}
8
+ end
9
+
10
+ def name
11
+ :rails_fixtures
12
+ end
13
+
14
+ def validate!(test_instance:)
15
+ if !test_instance.respond_to?(:setup_fixtures)
16
+ raise Error.new("'TestData.uses_rails_fixtures(self)' must be passed a test instance that has had ActiveRecord::TestFixtures mixed-in (e.g. `TestData.uses_rails_fixtures(self)` in an ActiveSupport::TestCase `setup` block), but the provided argument does not respond to 'setup_fixtures'")
17
+ elsif !test_instance.respond_to?(:__test_data_gem_setup_fixtures)
18
+ raise Error.new("'TestData.uses_rails_fixtures(self)' depends on Rails' default fixture-loading behavior being disabled by calling 'TestData.prevent_rails_fixtures_from_loading_automatically!' as early as possible (e.g. near the top of your test_helper.rb), but it looks like it was never called.")
19
+ end
20
+ end
21
+
22
+ def load_requested(test_instance:)
23
+ ActiveRecord::FixtureSet.reset_cache
24
+ test_instance.instance_variable_set(:@loaded_fixtures,
25
+ @already_loaded_rails_fixtures.slice(*test_instance.class.fixture_table_names))
26
+ test_instance.instance_variable_set(:@fixture_cache, {})
27
+ end
28
+
29
+ def loaded?(test_instance:)
30
+ test_instance.class.fixture_table_names.all? { |table_name|
31
+ @already_loaded_rails_fixtures.key?(table_name)
32
+ }
33
+ end
34
+
35
+ def load(test_instance:)
36
+ test_instance.pre_loaded_fixtures = false
37
+ test_instance.use_transactional_tests = false
38
+ test_instance.__test_data_gem_setup_fixtures
39
+ @already_loaded_rails_fixtures = test_instance.instance_variable_get(:@loaded_fixtures)
40
+ @statistics.count_load_rails_fixtures!
41
+ @config.after_rails_fixture_load_hook.call
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,19 @@
1
+ module TestData
2
+ class DetectsDatabaseExistence
3
+ def initialize
4
+ @config = TestData.config
5
+ end
6
+
7
+ def call
8
+ rows = ActiveRecord::Base.connection.execute <<~SQL
9
+ select datname database_name
10
+ from pg_catalog.pg_database
11
+ SQL
12
+ rows.any? { |row|
13
+ row["database_name"] == @config.database_name
14
+ }
15
+ rescue ActiveRecord::NoDatabaseError
16
+ false
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ module TestData
2
+ class DeterminesDatabasesAssociatedDumpTime
3
+ def call
4
+ if (last_dumped_at = ActiveRecord::InternalMetadata.find_by(key: "test_data:last_dumped_at")&.value)
5
+ Time.parse(last_dumped_at)
6
+ end
7
+ rescue ActiveRecord::StatementInvalid
8
+ # This will be raised if the DB exists but hasn't been migrated/schema-loaded
9
+ rescue ActiveRecord::NoDatabaseError
10
+ # This will be raised if the DB doesn't exist yet, which we don't need to warn about
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ module TestData
2
+ class DeterminesWhenSqlDumpWasMade
3
+ def initialize
4
+ @config = TestData.config
5
+ end
6
+
7
+ def call
8
+ if (last_dumped_at = find_last_dumped_value)
9
+ Time.zone.parse(last_dumped_at)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def find_last_dumped_value
16
+ return unless File.exist?(@config.non_test_data_dump_path)
17
+ File.open(@config.non_test_data_dump_path, "r").each_line do |line|
18
+ if (match = line.match(/INSERT INTO public\.ar_internal_metadata VALUES \('test_data:last_dumped_at', '([^']*)'/))
19
+ return match[1]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -6,9 +6,12 @@ module TestData
6
6
  class DumpsDatabase
7
7
  def initialize
8
8
  @config = TestData.config
9
+ @records_dump_metadata = RecordsDumpMetadata.new
9
10
  end
10
11
 
11
12
  def call
13
+ @records_dump_metadata.call
14
+
12
15
  dump(
13
16
  type: :schema,
14
17
  database_name: @config.database_name,
@@ -0,0 +1,25 @@
1
+ module TestData
2
+ class InsertsTestData
3
+ def initialize
4
+ @config = TestData.config
5
+ @statistics = TestData.statistics
6
+ end
7
+
8
+ def call
9
+ search_path = connection.execute("show search_path").first["search_path"]
10
+ connection.disable_referential_integrity do
11
+ connection.execute(File.read(@config.data_dump_full_path))
12
+ end
13
+ connection.execute <<~SQL
14
+ select pg_catalog.set_config('search_path', '#{search_path}', false)
15
+ SQL
16
+ @statistics.count_load!
17
+ end
18
+
19
+ private
20
+
21
+ def connection
22
+ ActiveRecord::Base.connection
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,187 @@
1
+ module TestData
2
+ class Manager
3
+ def initialize
4
+ @inserts_test_data = InsertsTestData.new
5
+ @truncates_test_data = TruncatesTestData.new
6
+ @config = TestData.config
7
+ @statistics = TestData.statistics
8
+ @save_points = []
9
+ end
10
+
11
+ def load
12
+ ensure_after_load_save_point_is_active_if_data_is_loaded!
13
+ return rollback_to_after_data_load if save_point_active?(:after_data_load)
14
+
15
+ create_save_point(:before_data_load)
16
+ @inserts_test_data.call
17
+ @config.after_test_data_load_hook.call
18
+ record_ar_internal_metadata_that_test_data_is_loaded
19
+ create_save_point(:after_data_load)
20
+ end
21
+
22
+ def truncate
23
+ ensure_after_load_save_point_is_active_if_data_is_loaded!
24
+ ensure_after_truncate_save_point_is_active_if_data_is_truncated!
25
+ return rollback_to_after_data_truncate if save_point_active?(:after_data_truncate)
26
+
27
+ if save_point_active?(:after_data_load)
28
+ # If a test that uses the test data runs before a test that starts by
29
+ # calling truncate, tables in the database that would NOT be truncated
30
+ # may have been changed. To avoid this category of test pollution, start
31
+ # the truncation by rolling back to the known clean point
32
+ rollback_to_after_data_load
33
+ else
34
+ # Seems silly loading data when the user asked us to truncate, but
35
+ # it's important that the state of the transaction stack matches the
36
+ # mental model we advertise, because any _other_ test in their suite
37
+ # should expect that the existence of :after_data_truncate save point
38
+ # implies that it's safe to rollback to the :after_data_load save
39
+ # point; since tests run in random order, it's likely to happen
40
+ TestData.log.debug("TestData.uses_clean_slate was called, but data was not loaded. Loading data before truncate to preserve the transaction save point ordering")
41
+ load
42
+ end
43
+
44
+ @truncates_test_data.call
45
+ @config.after_test_data_truncate_hook.call
46
+ record_ar_internal_metadata_that_test_data_is_truncated
47
+ create_save_point(:after_data_truncate)
48
+ end
49
+
50
+ def load_custom_data(loader, **options)
51
+ loader.validate!(**options)
52
+ snapshot_name = "user_#{loader.name}".to_sym
53
+
54
+ ensure_after_load_save_point_is_active_if_data_is_loaded!
55
+ ensure_after_truncate_save_point_is_active_if_data_is_truncated!
56
+ ensure_custom_save_point_is_active_if_memo_exists!(snapshot_name)
57
+
58
+ loader.load_requested(**options)
59
+ if save_point_active?(snapshot_name) && loader.loaded?(**options)
60
+ return rollback_to_custom_savepoint(snapshot_name)
61
+ end
62
+
63
+ if save_point_active?(:after_data_truncate)
64
+ rollback_to_after_data_truncate
65
+ else
66
+ truncate
67
+ end
68
+
69
+ loader.load(**options)
70
+ record_ar_internal_metadata_of_custom_save_point(snapshot_name)
71
+ create_save_point(snapshot_name)
72
+ end
73
+
74
+ def rollback_to_before_data_load
75
+ if save_point_active?(:before_data_load)
76
+ rollback_save_point(:before_data_load)
77
+ # No need to recreate the save point
78
+ # (TestData.uses_test_data will if called)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def rollback_to_after_data_load
85
+ if save_point_active?(:after_data_load)
86
+ rollback_save_point(:after_data_load)
87
+ create_save_point(:after_data_load)
88
+ end
89
+ end
90
+
91
+ def rollback_to_after_data_truncate
92
+ if save_point_active?(:after_data_truncate)
93
+ rollback_save_point(:after_data_truncate)
94
+ create_save_point(:after_data_truncate)
95
+ end
96
+ end
97
+
98
+ def rollback_to_custom_savepoint(name)
99
+ if save_point_active?(name)
100
+ rollback_save_point(name)
101
+ create_save_point(name)
102
+ end
103
+ end
104
+
105
+ def connection
106
+ ActiveRecord::Base.connection
107
+ end
108
+
109
+ def ensure_after_load_save_point_is_active_if_data_is_loaded!
110
+ if !save_point_active?(:after_data_load) && ar_internal_metadata_shows_test_data_is_loaded?
111
+ TestData.log.debug "Test data appears to be loaded, but the :after_data_load save point was rolled back (and not by this gem). Recreating the :after_data_load save point"
112
+ create_save_point(:after_data_load)
113
+ end
114
+ end
115
+
116
+ def record_ar_internal_metadata_that_test_data_is_loaded
117
+ if ar_internal_metadata_shows_test_data_is_loaded?
118
+ TestData.log.warn "Attempted to record that test data is loaded in ar_internal_metadata, but record already existed. Perhaps a previous test run committed your test data?"
119
+ else
120
+ ActiveRecord::InternalMetadata.create!(key: "test_data:loaded", value: "true")
121
+ end
122
+ end
123
+
124
+ def ar_internal_metadata_shows_test_data_is_loaded?
125
+ ActiveRecord::InternalMetadata.find_by(key: "test_data:loaded")&.value == "true"
126
+ end
127
+
128
+ def ensure_after_truncate_save_point_is_active_if_data_is_truncated!
129
+ if !save_point_active?(:after_data_truncate) && ar_internal_metadata_shows_test_data_is_truncated?
130
+ TestData.log.debug "Test data appears to be loaded, but the :after_data_truncate save point was rolled back (and not by this gem). Recreating the :after_data_truncate save point"
131
+ create_save_point(:after_data_truncate)
132
+ end
133
+ end
134
+
135
+ def record_ar_internal_metadata_that_test_data_is_truncated
136
+ if ar_internal_metadata_shows_test_data_is_truncated?
137
+ TestData.log.warn "Attempted to record that test data is truncated in ar_internal_metadata, but record already existed. Perhaps a previous test run committed the truncation of your test data?"
138
+ else
139
+ ActiveRecord::InternalMetadata.create!(key: "test_data:truncated", value: "true")
140
+ end
141
+ end
142
+
143
+ def ar_internal_metadata_shows_test_data_is_truncated?
144
+ ActiveRecord::InternalMetadata.find_by(key: "test_data:truncated")&.value == "true"
145
+ end
146
+
147
+ def ensure_custom_save_point_is_active_if_memo_exists!(name)
148
+ if !save_point_active?(name) && ar_internal_metadata_shows_custom_operation_was_persisted?(name)
149
+ TestData.log.debug "#{name} appears to have been loaded by test_data, but the #{name} save point was rolled back (and not by this gem). Recreating the #{name} save point"
150
+ create_save_point(name)
151
+ end
152
+ end
153
+
154
+ def ar_internal_metadata_shows_custom_operation_was_persisted?(name)
155
+ ActiveRecord::InternalMetadata.find_by(key: "test_data:#{name}")&.value == "true"
156
+ end
157
+
158
+ def record_ar_internal_metadata_of_custom_save_point(name)
159
+ if ar_internal_metadata_shows_custom_operation_was_persisted?(name)
160
+ TestData.log.warn "Attempted to record that test_data had loaded #{name} in ar_internal_metadata, but record already existed. Perhaps a previous test run committed it?"
161
+ else
162
+ ActiveRecord::InternalMetadata.create!(key: "test_data:#{name}", value: "true")
163
+ end
164
+ end
165
+
166
+ def save_point_active?(name)
167
+ purge_closed_save_points!
168
+ !!@save_points.find { |sp| sp.name == name }&.active?
169
+ end
170
+
171
+ def create_save_point(name)
172
+ raise Error.new("Could not create test_data savepoint '#{name}', because it was already active!") if save_point_active?(name)
173
+ @save_points << SavePoint.new(name)
174
+ end
175
+
176
+ def rollback_save_point(name)
177
+ @save_points.find { |sp| sp.name == name }&.rollback!
178
+ purge_closed_save_points!
179
+ end
180
+
181
+ def purge_closed_save_points!
182
+ @save_points = @save_points.select { |save_point|
183
+ save_point.active?
184
+ }
185
+ end
186
+ end
187
+ end
@@ -8,5 +8,9 @@ module TestData
8
8
  rake_tasks do
9
9
  load Pathname.new(__dir__).join("rake.rb")
10
10
  end
11
+
12
+ initializer "test_data.validate_data_up_to_date" do
13
+ WarnsIfDumpIsNewerThanDatabase.new.call
14
+ end
11
15
  end
12
16
  end
@@ -10,17 +10,18 @@ def run_in_test_data_env(task_name)
10
10
  end
11
11
 
12
12
  def create_database_or_else_blow_up_if_its_not_empty!
13
+ raise unless Rails.env.test_data?
14
+
15
+ unless TestData::DetectsDatabaseExistence.new.call
16
+ Rake::Task["test_data:create_database"].invoke
17
+ end
18
+
13
19
  unless TestData::DetectsDatabaseEmptiness.new.empty?
14
20
  raise TestData::Error.new("Database '#{TestData.config.database_name}' already exists and is not empty. To re-initialize it, drop it first (e.g. `rake test_data:drop_database`)")
15
21
  end
16
- rescue TestData::Error => e
17
- raise e
18
- rescue
19
- # Only (anticipated) cause for raise here is DB did not exist
20
- Rake::Task["test_data:create_database"].invoke
21
22
  end
22
23
 
23
- desc "Verifies test_data environment looks good"
24
+ desc "Verifies that the test_data environment looks good"
24
25
  task "test_data:verify_config" do
25
26
  TestData.log.with_plain_writer do
26
27
  config = TestData::VerifiesConfiguration.new.call
@@ -34,12 +35,12 @@ task "test_data:verify_config" do
34
35
  end
35
36
  end
36
37
 
37
- desc "Install default configuration files and snippets"
38
+ desc "Installs default configuration files and snippets"
38
39
  task "test_data:configure" do
39
40
  TestData::InstallsConfiguration.new.call
40
41
  end
41
42
 
42
- desc "Initialize test_data's interactive database"
43
+ desc "Initializes test_data's interactive database"
43
44
  task "test_data:initialize" => ["test_data:verify_config", :environment] do
44
45
  next run_in_test_data_env("test_data:initialize") if wrong_env?
45
46
 
@@ -52,15 +53,43 @@ task "test_data:initialize" => ["test_data:verify_config", :environment] do
52
53
  end
53
54
 
54
55
  TestData.log.info <<~MSG
55
- Your test_data environment and database are ready for use! You can now run
56
- your server (or any command) to create some test data like so:
56
+ Your test_data environment and database are ready for use! You can now run your server (or any command) to create some test data like so:
57
57
 
58
58
  $ RAILS_ENV=test_data bin/rails server
59
59
 
60
60
  MSG
61
61
  end
62
62
 
63
- desc "Initialize test_data Rails environment & configure database"
63
+ desc "Re-initializes test_data's interactive database (by dropping and reloading it)"
64
+ task "test_data:reinitialize" => ["test_data:verify_config", :environment] do
65
+ next run_in_test_data_env("test_data:reinitialize") if wrong_env?
66
+
67
+ # Take caution only if the test_data database exists
68
+ if TestData::DetectsDatabaseExistence.new.call
69
+ TestData::WarnsIfDatabaseIsNewerThanDump.new.call
70
+
71
+ unless ENV["TEST_DATA_CONFIRM"].present?
72
+ confirmed = if $stdin.isatty
73
+ puts "This will DROP test_data database '#{TestData.config.database_name}'. Are you sure you want to re-initialize it? [yN]"
74
+ $stdin.gets.chomp.downcase.start_with?("y")
75
+ else
76
+ puts "'#{TestData.config.database_name}' exists. Set TEST_DATA_CONFIRM=true to drop the database and re-initialize it."
77
+ false
78
+ end
79
+
80
+ unless confirmed
81
+ puts "Exiting without making any changes"
82
+ exit 1
83
+ end
84
+ end
85
+
86
+ Rake::Task["test_data:drop_database"].invoke
87
+ end
88
+
89
+ Rake::Task["test_data:initialize"].invoke
90
+ end
91
+
92
+ desc "Initializes test_data Rails environment & configure database"
64
93
  task "test_data:install" => ["test_data:configure", "test_data:initialize"]
65
94
 
66
95
  desc "Dumps the interactive test_data database"
@@ -83,7 +112,7 @@ task "test_data:load" => ["test_data:verify_config", :environment] do
83
112
  TestData::LoadsDatabaseDumps.new.call
84
113
 
85
114
  if ActiveRecord::Base.connection.migration_context.needs_migration?
86
- TestData.log.warn "There are pending migrations for database '#{TestData.config.database_name}'. To run them, run:\n\n RAILS_ENV=test_data bin/rake db:migrate\n\n"
115
+ TestData.log.warn "There are pending migrations for database '#{TestData.config.database_name}'. To run them, run:\n\n $ RAILS_ENV=test_data bin/rake db:migrate\n\n"
87
116
  end
88
117
  end
89
118