test_data 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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