test_data 0.0.1 → 0.2.1

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +41 -0
  3. data/.standard.yml +2 -0
  4. data/CHANGELOG.md +43 -0
  5. data/Gemfile.lock +17 -15
  6. data/LICENSE.txt +1 -6
  7. data/README.md +1232 -17
  8. data/example/.gitignore +1 -4
  9. data/example/Gemfile +3 -0
  10. data/example/Gemfile.lock +100 -71
  11. data/example/README.md +2 -22
  12. data/example/config/application.rb +3 -0
  13. data/example/config/credentials.yml.enc +1 -1
  14. data/example/config/database.yml +2 -0
  15. data/example/spec/rails_helper.rb +64 -0
  16. data/example/spec/requests/boops_spec.rb +17 -0
  17. data/example/spec/requests/rails_fixtures_override_spec.rb +106 -0
  18. data/example/spec/spec_helper.rb +94 -0
  19. data/example/test/factories.rb +4 -0
  20. data/example/test/integration/better_mode_switching_demo_test.rb +41 -0
  21. data/example/test/integration/boops_that_boop_boops_test.rb +17 -0
  22. data/example/test/integration/dont_dump_tables_test.rb +7 -0
  23. data/example/test/integration/load_rollback_truncate_test.rb +190 -0
  24. data/example/test/integration/mode_switching_demo_test.rb +38 -0
  25. data/example/test/integration/parallel_boops_with_fixtures_test.rb +10 -0
  26. data/example/test/integration/parallel_boops_without_fixtures_test.rb +9 -0
  27. data/example/test/integration/rails_fixtures_double_load_test.rb +10 -0
  28. data/example/test/integration/rails_fixtures_override_test.rb +110 -0
  29. data/example/test/integration/test_data_hooks_test.rb +89 -0
  30. data/example/test/integration/transaction_committing_boops_test.rb +27 -0
  31. data/example/test/test_helper.rb +4 -31
  32. data/lib/generators/test_data/cable_yaml_generator.rb +18 -0
  33. data/lib/generators/test_data/database_yaml_generator.rb +3 -4
  34. data/lib/generators/test_data/environment_file_generator.rb +7 -14
  35. data/lib/generators/test_data/initializer_generator.rb +51 -0
  36. data/lib/generators/test_data/secrets_yaml_generator.rb +19 -0
  37. data/lib/generators/test_data/webpacker_yaml_generator.rb +4 -3
  38. data/lib/test_data.rb +42 -1
  39. data/lib/test_data/active_record_ext.rb +11 -0
  40. data/lib/test_data/config.rb +57 -4
  41. data/lib/test_data/configurators.rb +3 -0
  42. data/lib/test_data/configurators/cable_yaml.rb +25 -0
  43. data/lib/test_data/configurators/environment_file.rb +3 -2
  44. data/lib/test_data/configurators/initializer.rb +26 -0
  45. data/lib/test_data/configurators/secrets_yaml.rb +25 -0
  46. data/lib/test_data/configurators/webpacker_yaml.rb +4 -3
  47. data/lib/test_data/custom_loaders/abstract_base.rb +25 -0
  48. data/lib/test_data/custom_loaders/rails_fixtures.rb +42 -0
  49. data/lib/test_data/dumps_database.rb +55 -5
  50. data/lib/test_data/generator_support.rb +3 -0
  51. data/lib/test_data/inserts_test_data.rb +25 -0
  52. data/lib/test_data/loads_database_dumps.rb +8 -8
  53. data/lib/test_data/log.rb +76 -0
  54. data/lib/test_data/manager.rb +187 -0
  55. data/lib/test_data/rake.rb +20 -9
  56. data/lib/test_data/save_point.rb +34 -0
  57. data/lib/test_data/statistics.rb +31 -0
  58. data/lib/test_data/truncates_test_data.rb +31 -0
  59. data/lib/test_data/verifies_dumps_are_loadable.rb +4 -4
  60. data/lib/test_data/version.rb +1 -1
  61. data/script/reset_example_app +18 -0
  62. data/script/test +78 -13
  63. data/test_data.gemspec +1 -1
  64. metadata +36 -4
  65. data/lib/test_data/transactional_data_loader.rb +0 -77
@@ -0,0 +1,3 @@
1
+ module TestData
2
+ BEFORE_TEST_STANZA_REGEX = /^$\n(?:^\#.*\n)*^test:/
3
+ end
@@ -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
@@ -16,17 +16,17 @@ module TestData
16
16
  )
17
17
 
18
18
  load_dump(
19
- name: "test data",
19
+ name: "non-test data",
20
20
  database_name: @config.database_name,
21
- relative_path: @config.data_dump_path,
22
- full_path: @config.data_dump_full_path
21
+ relative_path: @config.non_test_data_dump_path,
22
+ full_path: @config.non_test_data_dump_full_path
23
23
  )
24
24
 
25
25
  load_dump(
26
- name: "non-test data",
26
+ name: "test data",
27
27
  database_name: @config.database_name,
28
- relative_path: @config.non_test_data_dump_path,
29
- full_path: @config.non_test_data_dump_full_path
28
+ relative_path: @config.data_dump_path,
29
+ full_path: @config.data_dump_full_path
30
30
  )
31
31
  end
32
32
 
@@ -36,9 +36,9 @@ module TestData
36
36
  dump_pathname = Pathname.new(full_path)
37
37
  FileUtils.mkdir_p(File.dirname(dump_pathname))
38
38
  if system "psql -q -d #{database_name} < #{dump_pathname}"
39
- puts "Loaded #{name} from '#{relative_path}' into database '#{database_name}' "
39
+ TestData.log.info "Loaded #{name} from '#{relative_path}' into database '#{database_name}' "
40
40
  else
41
- raise "Failed while attempting to load #{name} from '#{relative_path}' into database '#{database_name}'"
41
+ raise Error.new("Failed while attempting to load #{name} from '#{relative_path}' into database '#{database_name}'")
42
42
  end
43
43
  end
44
44
  end
@@ -0,0 +1,76 @@
1
+ module TestData
2
+ def self.log
3
+ @log ||= Log.new
4
+ end
5
+
6
+ class Log
7
+ LEVELS = [:debug, :info, :warn, :error, :quiet]
8
+ DEFAULT_WRITER = ->(message, level) do
9
+ output = "[test_data:#{level}] #{message}"
10
+ if [:warn, :error].include?(level)
11
+ warn output
12
+ else
13
+ puts output
14
+ end
15
+ end
16
+ PLAIN_WRITER = ->(message, level) do
17
+ if [:warn, :error].include?(level)
18
+ warn message
19
+ else
20
+ puts message
21
+ end
22
+ end
23
+
24
+ attr_reader :level, :writer
25
+
26
+ def initialize
27
+ reset
28
+ end
29
+
30
+ LEVELS[0...4].each do |level|
31
+ define_method level.to_s do |message|
32
+ next unless message.strip.present?
33
+
34
+ @writer.call(message, level) if enabled?(level)
35
+ end
36
+ end
37
+
38
+ def reset
39
+ self.level = ENV["TEST_DATA_LOG_LEVEL"]&.to_sym || :info
40
+ @writer = DEFAULT_WRITER
41
+ end
42
+
43
+ def level=(level)
44
+ if LEVELS.include?(level)
45
+ @level = level
46
+ else
47
+ raise Error.new("Not a valid level")
48
+ end
49
+ end
50
+
51
+ def writer=(writer)
52
+ if writer.respond_to?(:call)
53
+ @writer = writer
54
+ else
55
+ raise Error.new("Log writer must be callable")
56
+ end
57
+ end
58
+
59
+ def with_writer(writer, &blk)
60
+ og_writer = self.writer
61
+ self.writer = writer
62
+ blk.call
63
+ self.writer = og_writer
64
+ end
65
+
66
+ def with_plain_writer(&blk)
67
+ with_writer(PLAIN_WRITER, &blk)
68
+ end
69
+
70
+ private
71
+
72
+ def enabled?(level)
73
+ LEVELS.index(level) >= LEVELS.index(@level)
74
+ end
75
+ end
76
+ 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
@@ -22,13 +22,15 @@ end
22
22
 
23
23
  desc "Verifies test_data environment looks good"
24
24
  task "test_data:verify_config" do
25
- config = TestData::VerifiesConfiguration.new.call
26
- unless config.looks_good?
27
- puts "The test_data gem is not configured correctly. Try 'rake test_data:install'?\n"
28
- config.problems.each do |problem|
29
- puts " - #{problem}"
25
+ TestData.log.with_plain_writer do
26
+ config = TestData::VerifiesConfiguration.new.call
27
+ unless config.looks_good?
28
+ TestData.log.warn "\nThe test_data gem is not configured correctly. Try running: rake test_data:configure\n\n"
29
+ config.problems.each do |problem|
30
+ TestData.log.warn " - #{problem}"
31
+ end
32
+ fail
30
33
  end
31
- fail
32
34
  end
33
35
  end
34
36
 
@@ -48,17 +50,26 @@ task "test_data:initialize" => ["test_data:verify_config", :environment] do
48
50
  ActiveRecord::Tasks::DatabaseTasks.load_schema_current(ActiveRecord::Base.schema_format, ENV["SCHEMA"], "test_data")
49
51
  ActiveRecord::Tasks::DatabaseTasks.load_seed
50
52
  end
53
+
54
+ TestData.log.info <<~MSG
55
+ 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:
56
+
57
+ $ RAILS_ENV=test_data bin/rails server
58
+
59
+ MSG
51
60
  end
52
61
 
53
62
  desc "Initialize test_data Rails environment & configure database"
54
63
  task "test_data:install" => ["test_data:configure", "test_data:initialize"]
55
64
 
56
65
  desc "Dumps the interactive test_data database"
57
- task "test_data:dump" => "test_data:verify_config" do
66
+ task "test_data:dump" => ["test_data:verify_config", :environment] do
67
+ next run_in_test_data_env("test_data:dump") if wrong_env?
68
+
58
69
  TestData::DumpsDatabase.new.call
59
70
  end
60
71
 
61
- desc "Dumps the interactive test_data database"
72
+ desc "Loads the schema and data SQL dumps into the test_data database"
62
73
  task "test_data:load" => ["test_data:verify_config", :environment] do
63
74
  next run_in_test_data_env("test_data:load") if wrong_env?
64
75
 
@@ -71,7 +82,7 @@ task "test_data:load" => ["test_data:verify_config", :environment] do
71
82
  TestData::LoadsDatabaseDumps.new.call
72
83
 
73
84
  if ActiveRecord::Base.connection.migration_context.needs_migration?
74
- warn "Warning: 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"
85
+ 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"
75
86
  end
76
87
  end
77
88
 
@@ -0,0 +1,34 @@
1
+ module TestData
2
+ class SavePoint
3
+ attr_reader :name, :transaction
4
+
5
+ def initialize(name)
6
+ @name = name
7
+ @transaction = connection.begin_transaction(joinable: false, _lazy: false)
8
+ end
9
+
10
+ def active?
11
+ !@transaction.state.finalized?
12
+ end
13
+
14
+ def rollback!
15
+ warn_if_not_rollbackable!
16
+ while active?
17
+ connection.rollback_transaction
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def connection
24
+ ActiveRecord::Base.connection
25
+ end
26
+
27
+ def warn_if_not_rollbackable!
28
+ return if active?
29
+ TestData.log.warn(
30
+ "Attempted to roll back transaction save point '#{name}', but its state was #{@transaction.state}"
31
+ )
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ module TestData
2
+ def self.statistics
3
+ @statistics ||= Statistics.new
4
+ end
5
+
6
+ class Statistics
7
+ attr_reader :load_count, :truncate_count, :load_rails_fixtures_count
8
+
9
+ def initialize
10
+ reset
11
+ end
12
+
13
+ def count_load!
14
+ @load_count += 1
15
+ end
16
+
17
+ def count_truncate!
18
+ @truncate_count += 1
19
+ end
20
+
21
+ def count_load_rails_fixtures!
22
+ @load_rails_fixtures_count += 1
23
+ end
24
+
25
+ def reset
26
+ @load_count = 0
27
+ @truncate_count = 0
28
+ @load_rails_fixtures_count = 0
29
+ end
30
+ end
31
+ end