test_data 0.0.2 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) 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 +41 -0
  5. data/Gemfile.lock +16 -16
  6. data/LICENSE.txt +1 -6
  7. data/README.md +864 -501
  8. data/example/.gitignore +1 -4
  9. data/example/Gemfile.lock +74 -74
  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 +6 -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 +2 -6
  20. data/example/test/integration/parallel_boops_without_fixtures_test.rb +2 -6
  21. data/example/test/integration/rails_fixtures_double_load_test.rb +10 -0
  22. data/example/test/integration/rails_fixtures_override_test.rb +110 -0
  23. data/example/test/integration/test_data_hooks_test.rb +89 -0
  24. data/example/test/integration/transaction_committing_boops_test.rb +5 -3
  25. data/example/test/test_helper.rb +2 -6
  26. data/lib/generators/test_data/cable_yaml_generator.rb +18 -0
  27. data/lib/generators/test_data/database_yaml_generator.rb +2 -3
  28. data/lib/generators/test_data/environment_file_generator.rb +7 -0
  29. data/lib/generators/test_data/initializer_generator.rb +20 -7
  30. data/lib/generators/test_data/secrets_yaml_generator.rb +19 -0
  31. data/lib/generators/test_data/webpacker_yaml_generator.rb +3 -2
  32. data/lib/test_data.rb +37 -1
  33. data/lib/test_data/active_record_ext.rb +11 -0
  34. data/lib/test_data/config.rb +33 -3
  35. data/lib/test_data/configurators.rb +2 -0
  36. data/lib/test_data/configurators/cable_yaml.rb +25 -0
  37. data/lib/test_data/configurators/environment_file.rb +3 -2
  38. data/lib/test_data/configurators/initializer.rb +3 -2
  39. data/lib/test_data/configurators/secrets_yaml.rb +25 -0
  40. data/lib/test_data/configurators/webpacker_yaml.rb +4 -3
  41. data/lib/test_data/custom_loaders/abstract_base.rb +25 -0
  42. data/lib/test_data/custom_loaders/rails_fixtures.rb +45 -0
  43. data/lib/test_data/dumps_database.rb +24 -1
  44. data/lib/test_data/generator_support.rb +3 -0
  45. data/lib/test_data/inserts_test_data.rb +25 -0
  46. data/lib/test_data/loads_database_dumps.rb +1 -1
  47. data/lib/test_data/log.rb +19 -1
  48. data/lib/test_data/{transactional_data_loader.rb → manager.rb} +78 -81
  49. data/lib/test_data/rake.rb +16 -7
  50. data/lib/test_data/statistics.rb +6 -1
  51. data/lib/test_data/truncates_test_data.rb +31 -0
  52. data/lib/test_data/version.rb +1 -1
  53. data/script/reset_example_app +1 -0
  54. data/script/test +31 -6
  55. data/test_data.gemspec +1 -1
  56. metadata +20 -4
@@ -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
@@ -40,12 +40,14 @@ module TestData
40
40
  def dump(type:, database_name:, relative_path:, full_path:, name: type, flags: "")
41
41
  dump_pathname = Pathname.new(full_path)
42
42
  FileUtils.mkdir_p(File.dirname(dump_pathname))
43
+ before_size = File.size?(dump_pathname)
43
44
  if execute("pg_dump #{database_name} --no-tablespaces --no-owner --inserts --#{type}-only #{flags} -f #{dump_pathname}")
44
45
  prepend_set_replication_role!(full_path) if type == :data
45
46
 
46
47
  TestData.log.info "Dumped '#{database_name}' #{name} to '#{relative_path}'"
48
+ log_size_info_and_warnings(before_size: before_size, after_size: File.size(dump_pathname))
47
49
  else
48
- raise "Failed while attempting to dump '#{database_name}' #{name} to '#{relative_path}'"
50
+ raise Error.new("Failed while attempting to dump '#{database_name}' #{name} to '#{relative_path}'")
49
51
  end
50
52
  end
51
53
 
@@ -72,5 +74,26 @@ module TestData
72
74
  COMMAND
73
75
  TestData.log.debug("Prepended replication role instruction to '#{data_dump_path}'")
74
76
  end
77
+
78
+ def log_size_info_and_warnings(before_size:, after_size:)
79
+ percent_change = percent_change(before_size, after_size)
80
+ TestData.log.info " Size: #{to_size(after_size)}#{" (#{percent_change}% #{before_size > after_size ? "decrease" : "increase"})" if percent_change}"
81
+ if after_size > 5242880
82
+ TestData.log.warn " WARNING: file size exceeds 5MB. Be sure to only persist what data you need to sufficiently test your application"
83
+ end
84
+ if before_size && (after_size - before_size) > 1048576
85
+ TestData.log.warn " WARNING: size of this dump increased by #{to_size(after_size - before_size)}. You may want to inspect the file to validate extraneous data was not committed"
86
+ end
87
+ end
88
+
89
+ def percent_change(before_size, after_size)
90
+ return unless before_size && before_size > 0 && after_size
91
+ ((before_size - after_size).abs / before_size * 100).round(2)
92
+ end
93
+
94
+ def to_size(bytes)
95
+ e = Math.log10(bytes).to_i / 3
96
+ "%.0f" % (bytes / 1000**e) + [" bytes", "KB", "MB", "GB"][e]
97
+ end
75
98
  end
76
99
  end
@@ -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
@@ -38,7 +38,7 @@ module TestData
38
38
  if system "psql -q -d #{database_name} < #{dump_pathname}"
39
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
data/lib/test_data/log.rb CHANGED
@@ -6,13 +6,20 @@ module TestData
6
6
  class Log
7
7
  LEVELS = [:debug, :info, :warn, :error, :quiet]
8
8
  DEFAULT_WRITER = ->(message, level) do
9
- output = "[test_data: #{level}] #{message}"
9
+ output = "[test_data:#{level}] #{message}"
10
10
  if [:warn, :error].include?(level)
11
11
  warn output
12
12
  else
13
13
  puts output
14
14
  end
15
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
16
23
 
17
24
  attr_reader :level, :writer
18
25
 
@@ -49,6 +56,17 @@ module TestData
49
56
  end
50
57
  end
51
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
+
52
70
  private
53
71
 
54
72
  def enabled?(level)
@@ -1,69 +1,25 @@
1
1
  module TestData
2
- def self.load(transactions: true)
3
- @transactional_data_loader ||= TransactionalDataLoader.new
4
- @transactional_data_loader.load(transactions: transactions)
5
- end
6
-
7
- def self.rollback(save_point_name = :after_data_load)
8
- @transactional_data_loader ||= TransactionalDataLoader.new
9
- case save_point_name
10
- when :before_data_load
11
- @transactional_data_loader.rollback_to_before_data_load
12
- when :after_data_load
13
- @transactional_data_loader.rollback_to_after_data_load
14
- when :after_data_truncate
15
- @transactional_data_loader.rollback_to_after_data_truncate
16
- else
17
- raise Error.new("No known save point named '#{save_point_name}'. Valid values are: [:before_data_load, :after_data_load, :after_data_truncate]")
18
- end
19
- end
20
-
21
- def self.truncate(transactions: true)
22
- @transactional_data_loader ||= TransactionalDataLoader.new
23
- @transactional_data_loader.truncate(transactions: transactions)
24
- end
25
-
26
- class TransactionalDataLoader
2
+ class Manager
27
3
  def initialize
4
+ @inserts_test_data = InsertsTestData.new
5
+ @truncates_test_data = TruncatesTestData.new
28
6
  @config = TestData.config
29
7
  @statistics = TestData.statistics
30
8
  @save_points = []
31
9
  end
32
10
 
33
- def load(transactions: true)
34
- return execute_data_load unless transactions
11
+ def load
35
12
  ensure_after_load_save_point_is_active_if_data_is_loaded!
36
13
  return rollback_to_after_data_load if save_point_active?(:after_data_load)
37
14
 
38
15
  create_save_point(:before_data_load)
39
- execute_data_load
16
+ @inserts_test_data.call
17
+ @config.after_test_data_load_hook.call
40
18
  record_ar_internal_metadata_that_test_data_is_loaded
41
19
  create_save_point(:after_data_load)
42
20
  end
43
21
 
44
- def rollback_to_before_data_load
45
- if save_point_active?(:before_data_load)
46
- rollback_save_point(:before_data_load)
47
- # No need to recreate the save point -- TestData.load will if called
48
- end
49
- end
50
-
51
- def rollback_to_after_data_load
52
- if save_point_active?(:after_data_load)
53
- rollback_save_point(:after_data_load)
54
- create_save_point(:after_data_load)
55
- end
56
- end
57
-
58
- def rollback_to_after_data_truncate
59
- if save_point_active?(:after_data_truncate)
60
- rollback_save_point(:after_data_truncate)
61
- create_save_point(:after_data_truncate)
62
- end
63
- end
64
-
65
- def truncate(transactions: true)
66
- return execute_data_truncate unless transactions
22
+ def truncate
67
23
  ensure_after_load_save_point_is_active_if_data_is_loaded!
68
24
  ensure_after_truncate_save_point_is_active_if_data_is_truncated!
69
25
  return rollback_to_after_data_truncate if save_point_active?(:after_data_truncate)
@@ -81,17 +37,75 @@ module TestData
81
37
  # should expect that the existence of :after_data_truncate save point
82
38
  # implies that it's safe to rollback to the :after_data_load save
83
39
  # point; since tests run in random order, it's likely to happen
84
- TestData.log.debug("TestData.truncate was called, but data was not loaded. Loading data before truncate to preserve the documents transaction save point ordering")
85
- load(transactions: true)
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
86
42
  end
87
43
 
88
- execute_data_truncate
44
+ @truncates_test_data.call
45
+ @config.after_test_data_truncate_hook.call
89
46
  record_ar_internal_metadata_that_test_data_is_truncated
90
47
  create_save_point(:after_data_truncate)
91
48
  end
92
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
+
93
82
  private
94
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
+
95
109
  def ensure_after_load_save_point_is_active_if_data_is_loaded!
96
110
  if !save_point_active?(:after_data_load) && ar_internal_metadata_shows_test_data_is_loaded?
97
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"
@@ -130,31 +144,22 @@ module TestData
130
144
  ActiveRecord::InternalMetadata.find_by(key: "test_data:truncated")&.value == "true"
131
145
  end
132
146
 
133
- def execute_data_load
134
- search_path = execute("show search_path").first["search_path"]
135
- connection.disable_referential_integrity do
136
- execute(File.read(@config.data_dump_full_path))
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)
137
151
  end
138
- execute <<~SQL
139
- select pg_catalog.set_config('search_path', '#{search_path}', false)
140
- SQL
141
- @statistics.count_load!
142
152
  end
143
153
 
144
- def execute_data_truncate
145
- connection.disable_referential_integrity do
146
- execute("TRUNCATE TABLE #{tables_to_truncate.map { |t| connection.quote_table_name(t) }.join(", ")}")
147
- end
148
- @statistics.count_truncate!
154
+ def ar_internal_metadata_shows_custom_operation_was_persisted?(name)
155
+ ActiveRecord::InternalMetadata.find_by(key: "test_data:#{name}")&.value == "true"
149
156
  end
150
157
 
151
- def tables_to_truncate
152
- if @config.truncate_these_test_data_tables.present?
153
- @config.truncate_these_test_data_tables
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?"
154
161
  else
155
- @tables_to_truncate ||= IO.foreach(@config.data_dump_path).grep(/^INSERT INTO/) { |line|
156
- line.match(/^INSERT INTO ([^\s]+)/)&.captures&.first
157
- }.compact.uniq
162
+ ActiveRecord::InternalMetadata.create!(key: "test_data:#{name}", value: "true")
158
163
  end
159
164
  end
160
165
 
@@ -178,13 +183,5 @@ module TestData
178
183
  save_point.active?
179
184
  }
180
185
  end
181
-
182
- def execute(sql)
183
- connection.execute(sql)
184
- end
185
-
186
- def connection
187
- ActiveRecord::Base.connection
188
- end
189
186
  end
190
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
- TestData.log.warn "The test_data gem is not configured correctly. Try 'rake test_data:configure'?\n"
28
- config.problems.each do |problem|
29
- TestData.log.warn " - #{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,6 +50,13 @@ 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"
@@ -73,7 +82,7 @@ task "test_data:load" => ["test_data:verify_config", :environment] do
73
82
  TestData::LoadsDatabaseDumps.new.call
74
83
 
75
84
  if ActiveRecord::Base.connection.migration_context.needs_migration?
76
- 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"
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"
77
86
  end
78
87
  end
79
88