test_data 0.0.1 → 0.0.2

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