fixpoints 0.1.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d08387f9cae75a24a2f735821531a38182f2ac3d918a82184f8315577139fa19
4
- data.tar.gz: 5b3397f68f5573c48c7a2fa60a5ba492c52b235e6496bfb0651902be7764cb76
3
+ metadata.gz: fad78acf149e5b3740844ca088969ddcf7e377a10d06f224be8b4f6c400a2b9c
4
+ data.tar.gz: 9f3cf9819a8e4d549b3e17b6556888070dbcb2d091bde9b8d27b104246c183f2
5
5
  SHA512:
6
- metadata.gz: 19e6f881162c9a1e2f13421ef5df5a0877e954545adda1eb6611e27072fbebdb9407d78e430cd86cb872916f127a9b43c106c7a3c1de17de38dcf3ae40bba04c
7
- data.tar.gz: c899d637681f49ea8500f3bdc3d80490e991a2be929edf4e26ad7a789bab2c04c372dc3a9c4af2dce51cf444d709980a9997821c2cc6f1179f488cd1465ad831
6
+ metadata.gz: 8f9618522b5e02bbcff8d06fc94df4da23f7dc35bf8ff0536c91483a66f06375f3a9e203b06e92707c1a1f0e2693a34b3a88c641eda650b3511d92539eadb985
7
+ data.tar.gz: 3a7294db258a4cae2e790e435ee0d2796914d77a877d24a6c354716a9ef87402622ada27734b9f360f7d5dae6e6d45571b9e3af44093ca391834c6732f6263a1
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- fixpoints (0.1.0)
4
+ fixpoints (0.2.2)
5
5
  activerecord (>= 5.0.0)
6
+ rspec
6
7
 
7
8
  GEM
8
9
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -2,46 +2,138 @@
2
2
 
3
3
  Fixpoints enables saving, restoring and comparing the database state before & after tests.
4
4
 
5
+ This gem came about during my time at [Netskin GmbH](https://www.netskin.com/en). Check it out, we do great (Rails) work there.
6
+
5
7
  ## Motivation
6
8
 
7
- TODO
9
+ When running behavior tests, we seed the database with a defined snapshot called fixpoint.
10
+ We do run the behavior test and save the resulting database state as another fixpoint.
11
+ This method allows testing complex business processes in legacy applications without having to implement fixtures/factories upfront.
12
+ By building one fixpoint on top of another, we can ensure that the process chain works without any gaps.
13
+ Comparing each resulting database state at the end of a test with a previously recorded state ensures that refactoring did not have unintended side effects.
14
+
15
+ **Advantages**
16
+
17
+ - No need to write fixtures or factories
18
+ - discover which records were created/changed by the test’s actions by reading the fixpoint file (YAML)
19
+ - get notified about differences in database state (i.e. unintended side effects) after refactoring something
20
+ - allow version control to save the "ground truth" at the end of a test
21
+
22
+ Please check out the full article: [Behavior-Driven Test Data](https://tomrothe.de/posts/behaviour-driven-test-data.html).
8
23
 
9
- Link to `https://tomrothe.de/posts/behaviour-driven-test-data.html`
24
+ ## Installation
25
+
26
+ Add this line to your application's Gemfile: `gem 'fixpoints'` and make sure to add:
27
+
28
+ ```ruby
29
+ # rails_helper.rb
30
+ RSpec.configure do |config|
31
+ # ...
32
+ config.include FixpointTestHelpers
33
+ end
34
+ ```
10
35
 
11
36
  ## Usage
12
37
 
13
- Add this line to your application's Gemfile: `gem 'fixpoints'`
38
+ We save the fixpoint (database snapshot) after the test. Other tests can build on them.
14
39
 
15
- TODO: Write usage instructions here
40
+ A fixpoint is a snapshot of the database contents as YAML file.
41
+ It is saved to the `spec/fixpoints` folder.
42
+ The file contains a mapping of table names to a list if their records.
43
+ Empty tables are stripped from files.
16
44
 
45
+ **Order & Bootstrapping** We need to mind the order though.
46
+ When bootstrapping (when there is no fixpoints saved to the disk yet), we need to make sure that all tests that depend on a certain fixpoint run _after_ it was stored.
47
+ In a single RSpec file, you can use the order in which the tests are defined (`RSpec.describe 'MyFeature', order: :defined do`).
48
+ However, tests in groups might follow a slightly different order (see [RSpec Docs](https://relishapp.com/rspec/rspec-core/docs/configuration/overriding-global-ordering))
17
49
 
18
50
  ```ruby
19
- # TODO: update this code
20
- it 'registers a user' do
21
- visit new_user_path
22
- fill_in 'Name', with: 'Hans'
23
- click_on 'Save'
24
-
25
- store_fixpoint :registred_user
26
- # creates YAML files containing all records (/spec/fixpoints/[table_name].yml)
51
+ RSpec.describe 'User Flow', order: :defined do # !!! mind the order here !!!
52
+ it 'registers a user' do
53
+ visit new_user_path
54
+ fill_in 'Name', with: 'Tom'
55
+ click_on 'Save'
56
+
57
+ store_fixpoint_unless_present :registered_user
58
+ # creates a YAML file containing all records (/spec/fixpoints/registred_user.yml)
59
+ end
60
+
61
+ it 'posts an item' do
62
+ restore_fixpoint :registered_user
63
+
64
+ user = User.find_by(name: 'Hans')
65
+ visit new_item_path(user)
66
+ fill_in 'Item', with: '...'
67
+ click_on 'Post'
68
+
69
+ compare_fixpoint(:item_posted, store_fixpoint_and_fail: true)
70
+ # compares the database state with the previously saved fixpoint and
71
+ # raises if there is a difference. when there is no previous fixpoint,
72
+ # it writes the fixpoint and fails the test (so it can be re-run)
73
+ end
27
74
  end
75
+ ```
76
+
77
+ **Changes** If you did a lot of changes to a test, you can remove a fixpoint file from its directory.
78
+ It will be recreated when the test producing it runs again.
79
+ Don't forget re-running the tests based on it because their fixpoints might have to change too.
80
+ Example: You need to add something to the database's `seeds.rb`. All subsequent fixpoints are missing the required entry.
81
+ To update all fixpoints, just remove the whole `spec/fixpoints` folder and re-run all tests. Now all fixpoints should be updated.
82
+ Be careful though, don't just remove the fixpoints if you are not sure what is going on.
83
+ A change in a fixpoint might point to an unintended change in code.
28
84
 
29
- it 'posts an item' do
85
+ We need to be be careful to use `let` and `let!` with factories.
86
+ Records might be created twice when using create in there (once by the fixpoint and once by the factory).
87
+
88
+ **Ignoring columns** Often you might want to add more columns to ignore (e.g. login time stamps):
89
+
90
+ ```ruby
91
+ let(:ignored_fixpoint_columns) { [:updated_at, :created_at, users: [:last_login_at] }
92
+ # ignores timestamps for all tables, and last_login_at for the users table
93
+
94
+ it 'logs in' do
30
95
  restore_fixpoint :registered_user
31
-
32
- user = User.find_by(name: 'Hans')
33
- visit new_item_path(user)
34
- fill_in 'Item', with: '...'
35
- click_on 'Post'
36
-
37
- compare_fixpoint(:posted_item, ignore_columns: [:release_date], store_fixpoint_and_fail: true)
38
- # compares the database state with the previously saved fixpoint and
39
- # raises if there is a difference. when there is no previous fixpoint,
40
- # it writes it and fails the test (so it can be re-run)
96
+ # ...
97
+ compare_fixpoint(:registered_user, ignored_fixpoint_columns)
98
+ # asserts that there is no change
41
99
  end
42
100
  ```
43
101
 
44
- ## Development
102
+ **Incremental** By the default the `FixpointTestHelpers` use the `IncrementalFixpoint` instead of the more verbose `Fixpoint` version.
103
+ This means that only changes are saved to the YAML file.
104
+ In order to achieve this, we must make sure that we let the store function know who daddy is.
105
+
106
+ ```ruby
107
+ it 'posts an item' do
108
+ restore_fixpoint :registered_user
109
+ # ...
110
+ compare_fixpoint(fixname, store_fixpoint_and_fail: true, parent_fixname: :registered_user)
111
+ # now only changes to compared to the previous fixpoint are stored
112
+ # instead of using the name of the last restored fixpoint, you can also use `:last_restored`
113
+ end
114
+ ```
115
+
116
+ **Multiple Databases** If an application uses multiple databases, you can use the optional `connection` parameter
117
+ to specify the database connection to use.
118
+
119
+ ```ruby
120
+ it 'posts an item' do
121
+ restore_fixpoint :registered_user, connection: ActiveRecord::Base.connection
122
+ # ...
123
+ end
124
+ ```
125
+
126
+ ## Limitations & Known issues
127
+
128
+ - The records in tables are ordered by their id.
129
+ If there is no id for a table, we use database's order (what the SELECT query returns).
130
+ This order may be instable.
131
+ - We do not clean the database after each test, depending on your cleaning strategy (e.g. transaction), we might leak primary key sequence counters from one test to another.
132
+ If you have problems try running `Fixpoint.reset_pk_sequences!` and create am issue, so we can investigate.
133
+ - Under certain conditions you may get `duplicate key value violates unique constraint` because the primary key sequences are not updated correctly.
134
+ If this happens, just add a `Fixpoint.reset_pk_sequences!` at the beginning of your test. We need to dig a little deeper here at some point...
135
+
136
+ # Development
45
137
 
46
138
  ```bash
47
139
  docker run --rm -ti -v (pwd):/app -w /app ruby:2.7 bash
@@ -53,12 +145,9 @@ gem build
53
145
  gem install fixpoints-0.1.0.gem
54
146
  pry -r fixpoints
55
147
  gem uninstall fixpoints
56
- gem push
148
+ gem push fixpoints
57
149
  ```
58
150
 
59
-
60
- ## Development
61
-
62
151
  ## Contributing
63
152
 
64
153
  Bug reports and pull requests are welcome on GitHub at https://github.com/motine/fixpoints.
@@ -45,8 +45,8 @@ class Fixpoint
45
45
  end
46
46
 
47
47
  # Creates a Fixpoint from the database contents. Empty tables are skipped.
48
- def from_database
49
- new(read_database_records)
48
+ def from_database(conn)
49
+ new(read_database_records(conn))
50
50
  end
51
51
 
52
52
  def remove(fixname)
@@ -56,7 +56,7 @@ class Fixpoint
56
56
  # reset primary key sequences for all tables
57
57
  # useful when tests sometimes run before the storing the first fixpoint.
58
58
  # these test might have incremented the id sequence already, so the ids in the fixpoints chance (which leads to differences).
59
- def reset_pk_sequences!
59
+ def reset_pk_sequences!(conn)
60
60
  return unless conn.respond_to?(:reset_pk_sequence!)
61
61
  conn.tables.each { |table_name| conn.reset_pk_sequence!(table_name) }
62
62
  end
@@ -69,10 +69,6 @@ class Fixpoint
69
69
  File.join(fspath, "#{fixname}.yml")
70
70
  end
71
71
 
72
- def conn
73
- ActiveRecord::Base.connection
74
- end
75
-
76
72
  protected
77
73
 
78
74
  def fixpoints_path
@@ -85,17 +81,21 @@ class Fixpoint
85
81
  File.join(spec_path, FIXPOINT_FOLDER)
86
82
  end
87
83
 
88
- def read_database_records
84
+ def read_database_records(conn)
89
85
  # adapted from: https://yizeng.me/2017/07/16/generate-rails-test-fixtures-yaml-from-database-dump/
90
86
  tables = conn.tables
91
87
  tables.reject! { |table_name| TABLES_TO_SKIP.include?(table_name) }
92
88
 
93
89
  tables.each_with_object({}) do |table_name, acc|
94
- result = conn.select_all("SELECT * FROM #{table_name}")
90
+ result = conn.select_all("SELECT * FROM #{conn.quote_table_name(table_name)}")
95
91
  next if result.count.zero?
96
92
 
97
93
  rows = result.to_a
98
94
  rows.sort_by! { |row| row['id'] } if result.columns.include?('id') # let's make the order of items stable
95
+ # fix jsonb columns by re-parsing them, so they are not saved as string to the yaml file
96
+ jsonb_columns = result.column_types.select { |_, col_type| col_type.type == :jsonb }.collect { |col_name, _| col_name }
97
+ rows.collect! { |row| jsonb_columns.each {|jcol| row[jcol] = JSON.parse(row[jcol] || 'null') }; row }
98
+
99
99
  acc[table_name] = rows
100
100
  end
101
101
  end
@@ -107,7 +107,7 @@ class Fixpoint
107
107
  @records_in_tables = records_in_tables
108
108
  end
109
109
 
110
- def load_into_database
110
+ def load_into_database(conn)
111
111
  # Here some more pointers on implementation details of fixtures:
112
112
  # - https://github.com/rails/rails/blob/2998672fc22f0d5e1a79a29ccb60d0d0e627a430/activerecord/lib/active_record/fixtures.rb#L612
113
113
  # - http://api.rubyonrails.org/v5.2.4/classes/ActiveRecord/FixtureSet.html#method-c-create_fixtures
@@ -123,7 +123,7 @@ class Fixpoint
123
123
 
124
124
  # actually insert
125
125
  conn.insert_fixtures_set(@records_in_tables)
126
- self.class.reset_pk_sequences!
126
+ self.class.reset_pk_sequences!(conn)
127
127
  end
128
128
 
129
129
  def save_to_file(fixname)
@@ -146,8 +146,6 @@ class Fixpoint
146
146
 
147
147
  protected
148
148
 
149
- delegate :conn, to: :class
150
-
151
149
  def contents_for_file
152
150
  YAML.dump(@records_in_tables)
153
151
  end
@@ -0,0 +1,62 @@
1
+ # Helper methods to be included into RSpec
2
+ module FixpointTestHelpers
3
+ def restore_fixpoint(fixname, connection: default_connection)
4
+ @last_restored = fixname
5
+ IncrementalFixpoint.from_file(fixname).load_into_database(connection)
6
+ end
7
+
8
+ # Compares the fixpoint with the records in the database.
9
+ # If there is no such fixpoint yet, it will write a new one to the file system.
10
+ # The latter is useful if the fixpoint was deleted to accommodate changes to it (see example in class description).
11
+ #
12
+ # +tables_to_compare+ can either be +:all+ or a list of table names (e.g. ['users', 'posts'])
13
+ # +ignored_columns+ see Fixnum#records_for_table
14
+ # +store_fixpoint_and_fail+ when given and the fixpoint does not already exist, a new fixpoint is created an the test will be marked pending/failed
15
+ # +parent_fixname+ when storing a new fixpoint, use this as parent fixpoint (you can specify `:last_restored` then the last given to restore_fixpoint is used; not thread safe)
16
+ # ---
17
+ # If we refactor this to a gem, we should rely on rspec (e.g. use minitest or move comparison logic to Fixpoint class).
18
+ # Anyhow, we keep it like this for now, because the expectations give much nicer output than the minitest assertions.
19
+ def compare_fixpoint(fixname, ignored_columns=[:updated_at, :created_at], tables_to_compare: :all, store_fixpoint_and_fail: false, parent_fixname: nil, connection: default_connection)
20
+ if !IncrementalFixpoint.exists?(fixname)
21
+ if store_fixpoint_and_fail
22
+ store_fixpoint(fixname, parent_fixname, connection: connection)
23
+ pending("Fixpoint \"#{fixname}\" did not exist yet. Skipping comparison, but created fixpoint from database. Try re-running the test.")
24
+ fail
25
+ else
26
+ raise Fixpoint::Error, "Fixpoint #{fixname} does not exist"
27
+ end
28
+ end
29
+
30
+ database_fp = IncrementalFixpoint.from_database(nil, connection)
31
+ fixpoint_fp = IncrementalFixpoint.from_file(fixname)
32
+
33
+ tables_to_compare = (database_fp.table_names + fixpoint_fp.table_names).uniq if tables_to_compare == :all
34
+ tables_to_compare.each do |table_name|
35
+ db_records = database_fp.records_for_table(table_name, ignored_columns)
36
+ fp_records = fixpoint_fp.records_for_table(table_name, ignored_columns)
37
+
38
+ # if a table is present in a fixpoint, there must be records in it because empty tables are stripped from fixpoints
39
+ expect(db_records).not_to be_empty, "#{table_name} not in database, but in fixpoint"
40
+ expect(fp_records).not_to be_empty, "#{table_name} not in fixpoint, but in database"
41
+ # we assume that the order of records returned by SELECT is stable (so we do not do any sorting)
42
+ expect(db_records).to eq(fp_records), "Database records for table \"#{table_name}\" did not match fixpoint \"#{fixname}\". Consider removing the fixpoint and re-running the test if the change is intended."
43
+ end
44
+ end
45
+
46
+ # it is not a good idea to overwrite the fixpoint each time because timestamps may change (which then shows up in version control).
47
+ # Hence we only provide a method to write to it if it does not exist.
48
+ def store_fixpoint_unless_present(fixname, parent_fixname = nil, connection: default_connection)
49
+ store_fixpoint(fixname, parent_fixname, connection: connection) unless IncrementalFixpoint.exists?(fixname)
50
+ end
51
+
52
+ # +parent_fixname+ when given, only the (incremental) changes to the parent are saved
53
+ # please see store_fixpoint_unless_present for note on why not to use this method
54
+ def store_fixpoint(fixname, parent_fixname = nil, connection: default_connection)
55
+ parent_fixname = @last_restored if parent_fixname == :last_restored
56
+ IncrementalFixpoint.from_database(parent_fixname, connection).save_to_file(fixname)
57
+ end
58
+
59
+ private def default_connection
60
+ ActiveRecord::Base.connection
61
+ end
62
+ end
@@ -3,6 +3,7 @@ require_relative "fixpoint_diff"
3
3
 
4
4
  require_relative "fixpoint"
5
5
  require_relative "incremental_fixpoint"
6
+ require_relative "fixpoint_test_helpers"
6
7
 
7
8
  if defined?(RSpec) && RSpec.respond_to?(:configure)
8
9
  RSpec.configure { |c| c.add_setting :fixpoints_path }
@@ -1,3 +1,3 @@
1
1
  module Fixpoints
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.2"
3
3
  end
@@ -33,11 +33,11 @@ class IncrementalFixpoint < Fixpoint
33
33
  end
34
34
 
35
35
  # Creates a Fixpoint from the database contents. Empty tables are skipped.
36
- def self.from_database(parent_fixname=nil)
37
- return super() if parent_fixname.nil?
36
+ def self.from_database(parent_fixname=nil, conn)
37
+ return super(conn) if parent_fixname.nil?
38
38
 
39
39
  parent = from_file(parent_fixname)
40
- changes_in_tables = FixpointDiff.extract_changes(parent.records_in_tables, read_database_records)
40
+ changes_in_tables = FixpointDiff.extract_changes(parent.records_in_tables, read_database_records(conn))
41
41
  new(changes_in_tables, parent_fixname)
42
42
  end
43
43
 
@@ -49,61 +49,3 @@ class IncrementalFixpoint < Fixpoint
49
49
  return YAML.dump(file_contents)
50
50
  end
51
51
  end
52
-
53
- # Helper methods to be included into RSpec
54
- module FixpointTestHelpers
55
- def restore_fixpoint(fixname)
56
- IncrementalFixpoint.from_file(fixname).load_into_database
57
- end
58
-
59
- # Compares the fixpoint with the records in the database.
60
- # If there is no such fixpoint yet, it will write a new one to the file system.
61
- # The latter is useful if the fixpoint was deleted to accommodate changes to it (see example in class description).
62
- #
63
- # +tables_to_compare+ can either be +:all+ or a list of table names (e.g. ['users', 'posts'])
64
- # +ignored_columns+ see Fixnum#records_for_table
65
- # +not_exists_handler+ when given and the fixpoint does not exists, it will be called with the fixname as argument
66
- #
67
- # ---
68
- # If we refactor this to a gem, we should rely on rspec (e.g. use minitest or move comparison logic to Fixpoint class).
69
- # Anyhow, we keep it like this for now, because the expectations give much nicer output than the minitest assertions.
70
- def compare_fixpoint(fixname, ignored_columns=[:updated_at, :created_at], tables_to_compare=:all, &not_exists_handler)
71
- if !IncrementalFixpoint.exists?(fixname)
72
- not_exists_handler.call(fixname) if not_exists_handler
73
- return
74
- end
75
-
76
- database_fp = IncrementalFixpoint.from_database
77
- fixpoint_fp = IncrementalFixpoint.from_file(fixname)
78
-
79
- tables_to_compare = (database_fp.table_names + fixpoint_fp.table_names).uniq if tables_to_compare == :all
80
- tables_to_compare.each do |table_name|
81
- db_records = database_fp.records_for_table(table_name, ignored_columns)
82
- fp_records = fixpoint_fp.records_for_table(table_name, ignored_columns)
83
-
84
- # if a table is present in a fixpoint, there must be records in it because empty tables are stripped from fixpoints
85
- expect(db_records).not_to be_empty, "#{table_name} not in database, but in fixpoint"
86
- expect(fp_records).not_to be_empty, "#{table_name} not in fixpoint, but in database"
87
- # we assume that the order of records returned by SELECT is stable (so we do not do any sorting)
88
- expect(db_records).to eq(fp_records), "Database records for table \"#{table_name}\" did not match fixpoint \"#{fixname}\". Consider removing the fixpoint and re-running the test if the change is intended."
89
- end
90
- end
91
-
92
- def store_fixpoint_and_fail(fixname, parent_fixname = nil)
93
- store_fixpoint(fixname, parent_fixname)
94
- pending("Fixpoint \"#{fixname}\" did not exist yet. Skipping comparison, but created fixpoint from database")
95
- fail
96
- end
97
-
98
- # it is not a good idea to overwrite the fixpoint each time because timestamps may change (which then shows up in version control).
99
- # Hence we only provide a method to write to it if it does not exist.
100
- def store_fixpoint_unless_present(fixname, parent_fixname = nil)
101
- store_fixpoint(fixname, parent_fixname) unless IncrementalFixpoint.exists?(fixname)
102
- end
103
-
104
- # +parent_fixname+ when given, only the (incremental) changes to the parent are saved
105
- # please see store_fixpoint_unless_present for note on why not to use this method
106
- def store_fixpoint(fixname, parent_fixname = nil)
107
- IncrementalFixpoint.from_database(parent_fixname).save_to_file(fixname)
108
- end
109
- end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fixpoints
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Rothe
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-09-14 00:00:00.000000000 Z
11
+ date: 2020-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -55,6 +55,7 @@ files:
55
55
  - fixpoints.gemspec
56
56
  - lib/fixpoint.rb
57
57
  - lib/fixpoint_diff.rb
58
+ - lib/fixpoint_test_helpers.rb
58
59
  - lib/fixpoints.rb
59
60
  - lib/fixpoints/version.rb
60
61
  - lib/incremental_fixpoint.rb