test_data 0.0.1 → 0.2.1

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