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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +45 -0
- data/CHANGELOG.md +8 -1
- data/Gemfile.lock +5 -3
- data/README.md +961 -17
- data/example/Gemfile +3 -0
- data/example/Gemfile.lock +32 -3
- data/example/README.md +2 -22
- data/example/config/credentials.yml.enc +2 -1
- data/example/config/database.yml +2 -0
- data/example/spec/rails_helper.rb +64 -0
- data/example/spec/requests/boops_spec.rb +21 -0
- data/example/spec/spec_helper.rb +94 -0
- data/example/test/factories.rb +4 -0
- data/example/test/integration/better_mode_switching_demo_test.rb +45 -0
- data/example/test/integration/boops_that_boop_boops_test.rb +17 -0
- data/example/test/integration/dont_dump_tables_test.rb +7 -0
- data/example/test/integration/load_rollback_truncate_test.rb +195 -0
- data/example/test/integration/mode_switching_demo_test.rb +48 -0
- data/example/test/integration/parallel_boops_with_fixtures_test.rb +14 -0
- data/example/test/integration/parallel_boops_without_fixtures_test.rb +13 -0
- data/example/test/integration/transaction_committing_boops_test.rb +25 -0
- data/example/test/test_helper.rb +3 -26
- data/lib/generators/test_data/database_yaml_generator.rb +1 -1
- data/lib/generators/test_data/environment_file_generator.rb +0 -14
- data/lib/generators/test_data/initializer_generator.rb +38 -0
- data/lib/generators/test_data/webpacker_yaml_generator.rb +1 -1
- data/lib/test_data.rb +5 -0
- data/lib/test_data/config.rb +25 -2
- data/lib/test_data/configurators.rb +1 -0
- data/lib/test_data/configurators/initializer.rb +25 -0
- data/lib/test_data/dumps_database.rb +31 -4
- data/lib/test_data/loads_database_dumps.rb +7 -7
- data/lib/test_data/log.rb +58 -0
- data/lib/test_data/rake.rb +7 -5
- data/lib/test_data/save_point.rb +34 -0
- data/lib/test_data/statistics.rb +26 -0
- data/lib/test_data/transactional_data_loader.rb +145 -32
- data/lib/test_data/verifies_dumps_are_loadable.rb +4 -4
- data/lib/test_data/version.rb +1 -1
- data/script/reset_example_app +17 -0
- data/script/test +54 -13
- metadata +19 -2
data/lib/test_data/rake.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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 "
|
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 "
|
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.
|
2
|
+
def self.load(transactions: true)
|
5
3
|
@transactional_data_loader ||= TransactionalDataLoader.new
|
6
|
-
@transactional_data_loader.
|
4
|
+
@transactional_data_loader.load(transactions: transactions)
|
7
5
|
end
|
8
6
|
|
9
|
-
def self.rollback(
|
10
|
-
|
11
|
-
|
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
|
-
|
15
|
-
|
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
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
33
|
-
|
34
|
-
|
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
|
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
|
-
|
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
|
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
|
-
|
163
|
+
!!@save_points.find { |sp| sp.name == name }&.active?
|
50
164
|
end
|
51
165
|
|
52
166
|
def create_save_point(name)
|
53
|
-
|
54
|
-
|
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
|
-
|
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.
|
178
|
+
save_point.active?
|
70
179
|
}
|
71
180
|
end
|
72
181
|
|
73
182
|
def execute(sql)
|
74
|
-
|
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
|
[
|
data/lib/test_data/version.rb
CHANGED
@@ -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 -
|
4
|
+
set -euo pipefail
|
5
5
|
set -x
|
6
6
|
|
7
|
-
#
|
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
|
-
|
17
|
-
|
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
|
-
#
|
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
|
-
#
|
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
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|