test_data 0.0.1 → 0.0.2

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +45 -0
  3. data/CHANGELOG.md +8 -1
  4. data/Gemfile.lock +5 -3
  5. data/README.md +961 -17
  6. data/example/Gemfile +3 -0
  7. data/example/Gemfile.lock +32 -3
  8. data/example/README.md +2 -22
  9. data/example/config/credentials.yml.enc +2 -1
  10. data/example/config/database.yml +2 -0
  11. data/example/spec/rails_helper.rb +64 -0
  12. data/example/spec/requests/boops_spec.rb +21 -0
  13. data/example/spec/spec_helper.rb +94 -0
  14. data/example/test/factories.rb +4 -0
  15. data/example/test/integration/better_mode_switching_demo_test.rb +45 -0
  16. data/example/test/integration/boops_that_boop_boops_test.rb +17 -0
  17. data/example/test/integration/dont_dump_tables_test.rb +7 -0
  18. data/example/test/integration/load_rollback_truncate_test.rb +195 -0
  19. data/example/test/integration/mode_switching_demo_test.rb +48 -0
  20. data/example/test/integration/parallel_boops_with_fixtures_test.rb +14 -0
  21. data/example/test/integration/parallel_boops_without_fixtures_test.rb +13 -0
  22. data/example/test/integration/transaction_committing_boops_test.rb +25 -0
  23. data/example/test/test_helper.rb +3 -26
  24. data/lib/generators/test_data/database_yaml_generator.rb +1 -1
  25. data/lib/generators/test_data/environment_file_generator.rb +0 -14
  26. data/lib/generators/test_data/initializer_generator.rb +38 -0
  27. data/lib/generators/test_data/webpacker_yaml_generator.rb +1 -1
  28. data/lib/test_data.rb +5 -0
  29. data/lib/test_data/config.rb +25 -2
  30. data/lib/test_data/configurators.rb +1 -0
  31. data/lib/test_data/configurators/initializer.rb +25 -0
  32. data/lib/test_data/dumps_database.rb +31 -4
  33. data/lib/test_data/loads_database_dumps.rb +7 -7
  34. data/lib/test_data/log.rb +58 -0
  35. data/lib/test_data/rake.rb +7 -5
  36. data/lib/test_data/save_point.rb +34 -0
  37. data/lib/test_data/statistics.rb +26 -0
  38. data/lib/test_data/transactional_data_loader.rb +145 -32
  39. data/lib/test_data/verifies_dumps_are_loadable.rb +4 -4
  40. data/lib/test_data/version.rb +1 -1
  41. data/script/reset_example_app +17 -0
  42. data/script/test +54 -13
  43. metadata +19 -2
@@ -24,9 +24,9 @@ desc "Verifies test_data environment looks good"
24
24
  task "test_data:verify_config" do
25
25
  config = TestData::VerifiesConfiguration.new.call
26
26
  unless config.looks_good?
27
- puts "The test_data gem is not configured correctly. Try 'rake test_data:install'?\n"
27
+ TestData.log.warn "The test_data gem is not configured correctly. Try 'rake test_data:configure'?\n"
28
28
  config.problems.each do |problem|
29
- puts " - #{problem}"
29
+ TestData.log.warn " - #{problem}"
30
30
  end
31
31
  fail
32
32
  end
@@ -54,11 +54,13 @@ desc "Initialize test_data Rails environment & configure database"
54
54
  task "test_data:install" => ["test_data:configure", "test_data:initialize"]
55
55
 
56
56
  desc "Dumps the interactive test_data database"
57
- task "test_data:dump" => "test_data:verify_config" do
57
+ task "test_data:dump" => ["test_data:verify_config", :environment] do
58
+ next run_in_test_data_env("test_data:dump") if wrong_env?
59
+
58
60
  TestData::DumpsDatabase.new.call
59
61
  end
60
62
 
61
- desc "Dumps the interactive test_data database"
63
+ desc "Loads the schema and data SQL dumps into the test_data database"
62
64
  task "test_data:load" => ["test_data:verify_config", :environment] do
63
65
  next run_in_test_data_env("test_data:load") if wrong_env?
64
66
 
@@ -71,7 +73,7 @@ task "test_data:load" => ["test_data:verify_config", :environment] do
71
73
  TestData::LoadsDatabaseDumps.new.call
72
74
 
73
75
  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"
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"
75
77
  end
76
78
  end
77
79
 
@@ -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,26 @@
1
+ module TestData
2
+ def self.statistics
3
+ @statistics ||= Statistics.new
4
+ end
5
+
6
+ class Statistics
7
+ attr_reader :load_count, :truncate_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 reset
22
+ @load_count = 0
23
+ @truncate_count = 0
24
+ end
25
+ end
26
+ end
@@ -1,77 +1,190 @@
1
- require "fileutils"
2
-
3
1
  module TestData
4
- def self.load_data_dump
2
+ def self.load(transactions: true)
5
3
  @transactional_data_loader ||= TransactionalDataLoader.new
6
- @transactional_data_loader.load_data_dump
4
+ @transactional_data_loader.load(transactions: transactions)
7
5
  end
8
6
 
9
- def self.rollback(to: :after_data_load)
10
- raise Error.new("rollback called before load_data_dump") unless @transactional_data_loader.present?
11
- @transactional_data_loader.rollback(to: to)
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
12
19
  end
13
20
 
14
- class TransactionalDataLoader
15
- SavePoint = Struct.new(:name, :transaction, keyword_init: true)
21
+ def self.truncate(transactions: true)
22
+ @transactional_data_loader ||= TransactionalDataLoader.new
23
+ @transactional_data_loader.truncate(transactions: transactions)
24
+ end
16
25
 
26
+ class TransactionalDataLoader
17
27
  def initialize
18
28
  @config = TestData.config
29
+ @statistics = TestData.statistics
19
30
  @save_points = []
20
- @dump_count = 0
21
31
  end
22
32
 
23
- def load_data_dump
24
- create_save_point(:before_data_load) unless save_point?(:before_data_load)
25
- unless save_point?(:after_data_load)
26
- execute_data_dump
27
- @dump_count += 1
33
+ def load(transactions: true)
34
+ return execute_data_load unless transactions
35
+ ensure_after_load_save_point_is_active_if_data_is_loaded!
36
+ return rollback_to_after_data_load if save_point_active?(:after_data_load)
37
+
38
+ create_save_point(:before_data_load)
39
+ execute_data_load
40
+ record_ar_internal_metadata_that_test_data_is_loaded
41
+ create_save_point(:after_data_load)
42
+ end
43
+
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)
28
54
  create_save_point(:after_data_load)
29
55
  end
30
56
  end
31
57
 
32
- def rollback(to:)
33
- return unless save_point?(to)
34
- rollback_save_point(to)
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
67
+ ensure_after_load_save_point_is_active_if_data_is_loaded!
68
+ ensure_after_truncate_save_point_is_active_if_data_is_truncated!
69
+ return rollback_to_after_data_truncate if save_point_active?(:after_data_truncate)
70
+
71
+ if save_point_active?(:after_data_load)
72
+ # If a test that uses the test data runs before a test that starts by
73
+ # calling truncate, tables in the database that would NOT be truncated
74
+ # may have been changed. To avoid this category of test pollution, start
75
+ # the truncation by rolling back to the known clean point
76
+ rollback_to_after_data_load
77
+ else
78
+ # Seems silly loading data when the user asked us to truncate, but
79
+ # it's important that the state of the transaction stack matches the
80
+ # mental model we advertise, because any _other_ test in their suite
81
+ # should expect that the existence of :after_data_truncate save point
82
+ # implies that it's safe to rollback to the :after_data_load save
83
+ # 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)
86
+ end
87
+
88
+ execute_data_truncate
89
+ record_ar_internal_metadata_that_test_data_is_truncated
90
+ create_save_point(:after_data_truncate)
35
91
  end
36
92
 
37
93
  private
38
94
 
39
- def execute_data_dump
95
+ def ensure_after_load_save_point_is_active_if_data_is_loaded!
96
+ if !save_point_active?(:after_data_load) && ar_internal_metadata_shows_test_data_is_loaded?
97
+ 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"
98
+ create_save_point(:after_data_load)
99
+ end
100
+ end
101
+
102
+ def record_ar_internal_metadata_that_test_data_is_loaded
103
+ if ar_internal_metadata_shows_test_data_is_loaded?
104
+ 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?"
105
+ else
106
+ ActiveRecord::InternalMetadata.create!(key: "test_data:loaded", value: "true")
107
+ end
108
+ end
109
+
110
+ def ar_internal_metadata_shows_test_data_is_loaded?
111
+ ActiveRecord::InternalMetadata.find_by(key: "test_data:loaded")&.value == "true"
112
+ end
113
+
114
+ def ensure_after_truncate_save_point_is_active_if_data_is_truncated!
115
+ if !save_point_active?(:after_data_truncate) && ar_internal_metadata_shows_test_data_is_truncated?
116
+ 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"
117
+ create_save_point(:after_data_truncate)
118
+ end
119
+ end
120
+
121
+ def record_ar_internal_metadata_that_test_data_is_truncated
122
+ if ar_internal_metadata_shows_test_data_is_truncated?
123
+ 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?"
124
+ else
125
+ ActiveRecord::InternalMetadata.create!(key: "test_data:truncated", value: "true")
126
+ end
127
+ end
128
+
129
+ def ar_internal_metadata_shows_test_data_is_truncated?
130
+ ActiveRecord::InternalMetadata.find_by(key: "test_data:truncated")&.value == "true"
131
+ end
132
+
133
+ def execute_data_load
40
134
  search_path = execute("show search_path").first["search_path"]
41
- execute(File.read(@config.data_dump_full_path))
135
+ connection.disable_referential_integrity do
136
+ execute(File.read(@config.data_dump_full_path))
137
+ end
42
138
  execute <<~SQL
43
139
  select pg_catalog.set_config('search_path', '#{search_path}', false)
44
140
  SQL
141
+ @statistics.count_load!
142
+ end
143
+
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!
45
149
  end
46
150
 
47
- def save_point?(name)
151
+ def tables_to_truncate
152
+ if @config.truncate_these_test_data_tables.present?
153
+ @config.truncate_these_test_data_tables
154
+ 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
158
+ end
159
+ end
160
+
161
+ def save_point_active?(name)
48
162
  purge_closed_save_points!
49
- @save_points.any? { |sp| sp.name == name }
163
+ !!@save_points.find { |sp| sp.name == name }&.active?
50
164
  end
51
165
 
52
166
  def create_save_point(name)
53
- save_point = SavePoint.new(
54
- name: name,
55
- transaction: ActiveRecord::Base.connection.begin_transaction(joinable: false, _lazy: false)
56
- )
57
- @save_points << save_point
167
+ raise Error.new("Could not create test_data savepoint '#{name}', because it was already active!") if save_point_active?(name)
168
+ @save_points << SavePoint.new(name)
58
169
  end
59
170
 
60
171
  def rollback_save_point(name)
61
- if (save_point = @save_points.find { |sp| sp.name == name }) && save_point.transaction.open?
62
- save_point.transaction.rollback
63
- end
172
+ @save_points.find { |sp| sp.name == name }&.rollback!
64
173
  purge_closed_save_points!
65
174
  end
66
175
 
67
176
  def purge_closed_save_points!
68
177
  @save_points = @save_points.select { |save_point|
69
- save_point.transaction.open?
178
+ save_point.active?
70
179
  }
71
180
  end
72
181
 
73
182
  def execute(sql)
74
- ActiveRecord::Base.connection.execute(sql)
183
+ connection.execute(sql)
184
+ end
185
+
186
+ def connection
187
+ ActiveRecord::Base.connection
75
188
  end
76
189
  end
77
190
  end
@@ -8,22 +8,22 @@ module TestData
8
8
  def call(quiet: false)
9
9
  schema_dump_looks_good = Pathname.new(@config.schema_dump_full_path).readable?
10
10
  if !quiet && !schema_dump_looks_good
11
- warn "Warning: Database schema dump '#{@config.schema_dump_path}' not readable"
11
+ log.warn "Warning: Database schema dump '#{@config.schema_dump_path}' not readable"
12
12
  end
13
13
 
14
14
  data_dump_looks_good = Pathname.new(@config.data_dump_full_path).readable?
15
15
  if !quiet && !data_dump_looks_good
16
- warn "Warning: Database data dump '#{@config.data_dump_path}' not readable"
16
+ log.warn "Warning: Database data dump '#{@config.data_dump_path}' not readable"
17
17
  end
18
18
 
19
19
  non_test_data_dump_looks_good = Pathname.new(@config.non_test_data_dump_full_path).readable?
20
20
  if !quiet && !non_test_data_dump_looks_good
21
- warn "Warning: Database non-test data dump '#{@config.non_test_data_dump_path}' not readable"
21
+ log.warn "Warning: Database non-test data dump '#{@config.non_test_data_dump_path}' not readable"
22
22
  end
23
23
 
24
24
  database_empty = @detects_database_emptiness.empty?
25
25
  unless quiet || database_empty
26
- warn "Warning: Database '#{@config.database_name}' is not empty"
26
+ log.warn "Warning: Database '#{@config.database_name}' is not empty"
27
27
  end
28
28
 
29
29
  [
@@ -1,3 +1,3 @@
1
1
  module TestData
2
- VERSION = "0.0.1"
2
+ VERSION = "0.0.2"
3
3
  end
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+
3
+ PS4='[script/test:${LINENO}] $ '
4
+ set -euo pipefail
5
+ set -x
6
+
7
+ cd example
8
+
9
+ # Reset database:
10
+ bin/rake db:drop
11
+ dropdb example_test_data 2>/dev/null || true
12
+
13
+ # Reset files:
14
+ git checkout app/models/boop.rb
15
+ git checkout config/database.yml
16
+ git checkout db/schema.rb
17
+ git clean -xdf .
data/script/test CHANGED
@@ -1,25 +1,30 @@
1
1
  #!/usr/bin/env bash
2
2
 
3
3
  PS4='[script/test:${LINENO}] $ '
4
- set -e
4
+ set -euo pipefail
5
5
  set -x
6
6
 
7
- # Make sure the main project installs & passes its own rake
7
+ # Install deps and make sure gem passes its own rake
8
8
  bundle
9
9
  bundle exec rake
10
-
11
- # Exercise the example app
12
10
  cd example
13
11
  bundle
14
12
 
15
13
  # Avoid test pollution by clearing out any initial state that might be lingering
16
- rm -rf test/support/test_data
17
- bin/rake db:reset
14
+ cd ..
15
+ ./script/reset_example_app
16
+
17
+ # Exercise the example app
18
+ cd example
19
+ bin/rake db:setup
18
20
 
19
21
  # Test basic initial usage
20
22
  bin/rake test_data:install
21
23
  bin/rake test_data:dump
22
24
  bin/rails test test/integration/basic_boops_test.rb
25
+ bundle exec rspec spec/requests/boops_spec.rb
26
+ bin/rails test test/integration/mode_switching_demo_test.rb
27
+ bin/rails test test/integration/better_mode_switching_demo_test.rb
23
28
  bin/rails test test/integration/parallel_boops_with_fixtures_test.rb
24
29
  bin/rails test test/integration/parallel_boops_without_fixtures_test.rb
25
30
 
@@ -38,12 +43,48 @@ RAILS_ENV=test_data bin/rake db:migrate
38
43
  bin/rake test_data:dump
39
44
  bin/rails test test/integration/migrated_boops_test.rb
40
45
 
41
- # Cleanup
46
+ # Run a test that commits test data thru to the database
47
+ bin/rails test test/integration/transaction_committing_boops_test.rb
48
+
49
+ # Add a second migration, this time without wiping the test_data db and with a table we want to ignore
50
+ cp ../test/fixtures/20210423114916_add_table_we_want_to_ignore.rb db/migrate
51
+ cp ../test/fixtures/chatty_audit_log.rb app/models
52
+ bin/rake db:migrate
53
+ RAILS_ENV=test_data bin/rake db:migrate
54
+ RAILS_ENV=test_data rails runner "50.times { ChattyAuditLog.create!(message: 'none of this matters') }"
55
+ # Gsub config file and uncomment + add table to excluded table list
56
+ ruby -e '
57
+ path = "config/initializers/test_data.rb"
58
+ IO.write(path, File.open(path) { |f|
59
+ f.read.gsub("# config.dont_dump_these_tables = []", "config.dont_dump_these_tables = [\"chatty_audit_logs\"]")
60
+ })
61
+ '
62
+ bin/rake test_data:dump
63
+ if grep -q "INSERT INTO public.chatty_audit_logs" "test/support/test_data/data.sql"; then
64
+ echo "Dump contained excluded table 'chatty_audit_logs'"
65
+ exit 1
66
+ fi
67
+ bin/rake db:test:prepare
68
+ bin/rails test test/integration/dont_dump_tables_test.rb
69
+ bin/rails test test/integration/load_rollback_truncate_test.rb
42
70
 
43
- # Delete the test_data database
71
+ # Test circular FK constraints
72
+ cp ../test/fixtures/20210423190737_add_foreign_keys.rb db/migrate/
73
+ cp ../test/fixtures/boop_with_other_boops.rb app/models/boop.rb
74
+ RAILS_ENV=test_data bin/rake db:migrate
75
+ bin/rake test_data:dump
76
+ bin/rake db:migrate
77
+ bin/rake db:test:prepare
78
+ bin/rails test test/integration/boops_that_boop_boops_test.rb
79
+
80
+ # Make sure it loads cleanly again
44
81
  bin/rake test_data:drop_database
45
- # Unset file changes
46
- git checkout config/database.yml
47
- git checkout db/schema.rb
48
- # Delete all untracked files (e.g. dumps, env config)
49
- git clean -xdf .
82
+ if [ ! bin/rake test_data:load | grep -q "ERROR" ]; then
83
+ echo "Running test_data:load after adding FK constraints led to errors"
84
+ exit 1
85
+ fi
86
+ bin/rails test test/integration/boops_that_boop_boops_test.rb
87
+
88
+ # Cleanup
89
+ cd ..
90
+ ./script/reset_example_app