fixture_kit 0.3.0 → 0.5.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23151d16a20be7d13a1c4a15ce4dca10a2c742ba68a1d43af339bdad65490679
4
- data.tar.gz: 1c9da63eae90bbe6843d204d138fafbf3b933f0ea4fd9f42c4108331509f472b
3
+ metadata.gz: c052323f7423cfeb258b137bc8372833eff2f4c8b826b3fa22b25a171b2b2c0f
4
+ data.tar.gz: '0728daee8814033465d2b93a67e15a00eff4abe5543967167077f81e68db7dde'
5
5
  SHA512:
6
- metadata.gz: 3b75d5d28b153bb082f8097309c39c6d350633faac76e1b30e5ea7413cf3bccfa9b93a3941f6eff607306f43cd53a7bc5420fd1d331fc99c66ede324f82e3902
7
- data.tar.gz: 92d48a55f2a92f4f1673a890e9bba8ce6695204b7a020e3240a9db21217747c3d0f7446297706cc634a15ea3246e29d0b0adcad870152cd449056c67b75cae45
6
+ metadata.gz: 82149fa2c3598b2af00b2cd9b8e3691e8694f18bdc40a0abf3a47540e7c319e0821250bec0e3dce4d8aaf692ea614a17cda09970a476503b0280c7a301b95e2f
7
+ data.tar.gz: f4fe1ea329c354aee57ad6f53d22682309b8db9704340f52d18c4b01b42a83a47701d36fe11e739d644e13b72d02171190a530df6847386770a5edc6b786cbf2
data/README.md CHANGED
@@ -106,6 +106,22 @@ end
106
106
 
107
107
  `fixture` returns a `Repository` and exposes records as methods (for example, `fixture.owner`).
108
108
 
109
+ You can also define fixtures anonymously inline:
110
+
111
+ ```ruby
112
+ RSpec.describe Book do
113
+ fixture do
114
+ owner = User.create!(name: "Alice", email: "alice@example.com")
115
+ featured = Book.create!(title: "Dune", owner: owner)
116
+ expose(owner: owner, featured: featured)
117
+ end
118
+
119
+ it "uses inline fixture data" do
120
+ expect(fixture.featured.owner).to eq(fixture.owner)
121
+ end
122
+ end
123
+ ```
124
+
109
125
  ### 3. Configure RSpec
110
126
 
111
127
  ```ruby
@@ -117,52 +133,85 @@ RSpec.configure do |config|
117
133
  end
118
134
  ```
119
135
 
120
- When you call `fixture "name"` in an example group, FixtureKit registers that fixture with its runner.
136
+ When you call `fixture "name"` or `fixture do ... end` in an example group, FixtureKit registers that fixture with its runner.
137
+
138
+ ### 4. Configure Minitest
139
+
140
+ ```ruby
141
+ # test/test_helper.rb
142
+ require "fixture_kit/minitest"
143
+
144
+ class ActiveSupport::TestCase
145
+ self.use_transactional_tests = true
146
+ end
147
+ ```
148
+
149
+ When you call `fixture "name"` or `fixture do ... end` in a test class, FixtureKit registers that fixture with its runner and mounts it during test setup.
121
150
 
122
151
  ## Configuration
123
152
 
124
153
  ```ruby
125
154
  # spec/support/fixture_kit.rb
126
155
  FixtureKit.configure do |config|
127
- # Where fixture definitions live (default: spec/fixture_kit)
156
+ # Where fixture definitions live (default: fixture_kit).
157
+ # Framework entrypoints set a framework-specific default:
158
+ # - fixture_kit/rspec -> spec/fixture_kit
159
+ # - fixture_kit/minitest -> test/fixture_kit
128
160
  config.fixture_path = Rails.root.join("spec/fixture_kit").to_s
129
161
 
130
162
  # Where cache files are stored (default: tmp/cache/fixture_kit)
131
163
  config.cache_path = Rails.root.join("tmp/cache/fixture_kit").to_s
132
164
 
133
- # Wrapper used to isolate generation work (default: FixtureKit::TestCase::Isolator)
134
- # config.isolator = FixtureKit::TestCase::Isolator
135
- # config.isolator = FixtureKit::RSpec::Isolator
165
+ # Wrapper used to isolate generation work (default: FixtureKit::MinitestIsolator)
166
+ # config.isolator = FixtureKit::MinitestIsolator
167
+ # config.isolator = FixtureKit::RSpecIsolator
136
168
 
137
- # Optional callback, called once when a fixture cache is first generated.
138
- # Receives the fixture name as a String.
139
- # config.on_cache = ->(fixture_name) { puts "cached #{fixture_name}" }
169
+ # Optional callback, called right before a fixture cache is generated.
170
+ # Called on first generation and forced regeneration.
171
+ # Receives the fixture identifier:
172
+ # - named fixtures: String (e.g. "teams/basic")
173
+ # - anonymous fixtures: scope class
174
+ # config.on_cache_save = ->(identifier) { puts "cached #{identifier}" }
175
+
176
+ # Optional callback, called right before a fixture cache is mounted.
177
+ # Receives the same fixture identifier shape as on_cache_save.
178
+ # config.on_cache_mount = ->(identifier) { puts "mounted #{identifier}" }
140
179
  end
141
180
  ```
142
181
 
143
182
  Custom isolators should subclass `FixtureKit::Isolator` and implement `#run`.
144
183
  `#run` receives the generation block and should execute it in whatever lifecycle you need.
145
184
 
146
- By default, FixtureKit uses `FixtureKit::TestCase::Isolator`, which runs generation inside an internal `ActiveSupport::TestCase` and removes that harness case from minitest runnables.
185
+ By default, FixtureKit uses `FixtureKit::MinitestIsolator`, which runs generation inside an internal `ActiveSupport::TestCase` and removes that harness case from minitest runnables.
147
186
 
148
- When using `fixture_kit/rspec`, FixtureKit sets `FixtureKit::RSpec::Isolator`. It runs generation inside an internal RSpec example, and uses a null reporter so harness runs do not count toward suite example totals.
187
+ When using `fixture_kit/rspec`, FixtureKit sets `FixtureKit::RSpecIsolator`. It runs generation inside an internal RSpec example, and uses a null reporter so harness runs do not count toward suite example totals.
149
188
 
150
189
  ## Lifecycle
151
190
 
152
191
  Fixture generation is managed by `FixtureKit::Runner`.
153
192
 
154
- With `fixture_kit/rspec`:
155
-
156
- 1. `fixture "name"` registers the fixture with the runner during spec file load.
157
- 2. In `before(:suite)`, runner `start`:
193
+ 1. Calling `fixture "name"` or `fixture do ... end` registers the fixture with the runner.
194
+ 2. Runner `start`:
158
195
  - clears `cache_path` (unless preserve-cache is enabled),
159
196
  - generates caches for all already-registered fixtures.
160
- 3. If new spec files are loaded later (for example, queue-mode CI runners), newly registered fixtures are generated immediately because the runner has already started.
161
- 4. At example runtime, fixture mounting loads from cache.
197
+ 3. If new tests are loaded after start (for example, queue-mode CI runners), newly registered fixtures are cached immediately.
198
+ 4. At test runtime, `fixture` mounts from cache and returns a `Repository`.
199
+
200
+ When runner start happens:
201
+
202
+ - `fixture_kit/rspec`: in `before(:suite)`.
203
+ - `fixture_kit/minitest`: lazily during test setup for the first test class that declares `fixture`.
204
+
205
+ ## Fixture Declaration Rules
206
+
207
+ - Only one `fixture` declaration is allowed per test context.
208
+ - Declaring a fixture twice in the same context raises `FixtureKit::MultipleFixtures`.
209
+ - Child contexts/classes can declare their own fixture and override parent declarations.
210
+ - Providing both a name and a block (or neither) raises `FixtureKit::InvalidFixtureDeclaration`.
162
211
 
163
212
  ### Preserving Cache Locally
164
213
 
165
- If you want to skip cache clearing at suite start (e.g., to reuse caches across test runs during local development), set the `FIXTURE_KIT_PRESERVE_CACHE` environment variable:
214
+ If you want to skip cache clearing when the runner starts (e.g., to reuse caches across test runs during local development), set the `FIXTURE_KIT_PRESERVE_CACHE` environment variable:
166
215
 
167
216
  ```bash
168
217
  FIXTURE_KIT_PRESERVE_CACHE=1 bundle exec rspec
@@ -187,15 +236,24 @@ end
187
236
  fixture "teams/sales"
188
237
  ```
189
238
 
239
+ ## Anonymous Fixture Cache Paths
240
+
241
+ Anonymous fixture caches are written under the `_anonymous/` directory inside `cache_path`.
242
+
243
+ - Minitest: class name is underscored into a path.
244
+ - `MyFeatureTest` -> `_anonymous/my_feature_test.json`
245
+ - RSpec: class name is underscored after removing `RSpec::ExampleGroups::`.
246
+ - `RSpec::ExampleGroups::Foo::WithFixtureKit::Hello` -> `_anonymous/foo/with_fixture_kit/hello.json`
247
+
190
248
  ## How It Works
191
249
 
192
- 1. **Cache generation**: FixtureKit executes your definition block inside the configured isolator, subscribes to `sql.active_record` notifications to track inserted models, queries those model tables, and generates batch INSERT statements with conflict handling (`INSERT OR IGNORE` for SQLite, `ON CONFLICT DO NOTHING` for PostgreSQL, `INSERT IGNORE` for MySQL).
250
+ 1. **Cache generation**: FixtureKit executes your definition block inside the configured isolator, subscribes to `sql.active_record` notifications to track created/updated/deleted models, queries those model tables, and caches SQL statements for current table contents.
193
251
 
194
- 2. **Mounting**: FixtureKit loads the cached JSON file and executes the raw SQL INSERT statements directly. No ORM instantiation, no callbacks.
252
+ 2. **Mounting**: FixtureKit loads the cached JSON file, clears each tracked table, and executes the raw SQL INSERT statements directly. No ORM instantiation, no callbacks.
195
253
 
196
254
  3. **Repository build**: FixtureKit resolves exposed records by model + id and returns a `Repository` for method-based access.
197
255
 
198
- 4. **Transaction isolation**: RSpec's `use_transactional_fixtures` wraps each test in a transaction that rolls back, so data doesn't persist between tests.
256
+ 4. **Transaction isolation**: Use framework transactions (`use_transactional_fixtures` in RSpec, `use_transactional_tests` in Minitest) so test writes roll back and cached data can be reused safely between tests.
199
257
 
200
258
  ### Cache Format
201
259
 
@@ -204,8 +262,8 @@ Caches are stored as JSON files in `tmp/cache/fixture_kit/`:
204
262
  ```json
205
263
  {
206
264
  "records": {
207
- "User": "INSERT OR IGNORE INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'), (2, 'Bob', 'bob@example.com')",
208
- "Project": "INSERT OR IGNORE INTO projects (id, name, user_id) VALUES (1, 'Website', 1)"
265
+ "User": "INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'), (2, 'Bob', 'bob@example.com')",
266
+ "Project": "INSERT INTO projects (id, name, user_id) VALUES (1, 'Website', 1)"
209
267
  },
210
268
  "exposed": {
211
269
  "alice": { "model": "User", "id": 1 },
@@ -7,15 +7,30 @@ require "active_support/inflector"
7
7
 
8
8
  module FixtureKit
9
9
  class Cache
10
+ ANONYMOUS_DIRECTORY = "_anonymous"
11
+
12
+ include ConfigurationHelper
13
+
10
14
  attr_reader :fixture
11
15
 
12
- def initialize(fixture, definition)
16
+ def initialize(fixture)
13
17
  @fixture = fixture
14
- @definition = definition
15
18
  end
16
19
 
17
20
  def path
18
- File.join(FixtureKit.configuration.cache_path, "#{fixture.name}.json")
21
+ File.join(configuration.cache_path, "#{identifier}.json")
22
+ end
23
+
24
+ def identifier
25
+ @identifier ||= begin
26
+ raw_identifier = fixture.identifier
27
+ if raw_identifier.is_a?(String)
28
+ raw_identifier
29
+ else
30
+ normalized_scope = raw_identifier.to_s.sub(/\ARSpec::ExampleGroups::/, "")
31
+ File.join(ANONYMOUS_DIRECTORY, ActiveSupport::Inflector.underscore(normalized_scope))
32
+ end
33
+ end
19
34
  end
20
35
 
21
36
  def exists?
@@ -24,27 +39,32 @@ module FixtureKit
24
39
 
25
40
  def load
26
41
  unless exists?
27
- raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.name}'"
42
+ raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.identifier}'"
28
43
  end
29
44
 
30
45
  @data ||= JSON.parse(File.read(path))
31
46
  @data.fetch("records").each do |model_name, sql|
32
47
  model = ActiveSupport::Inflector.constantize(model_name)
33
- model.connection.execute(sql)
48
+ connection = model.connection
49
+ connection.disable_referential_integrity do
50
+ # execute_batch is private in current supported Rails versions.
51
+ # This should be revisited when Rails 8.2 makes it public.
52
+ connection.__send__(:execute_batch, [build_delete_sql(model), sql].compact, "FixtureKit Load")
53
+ end
34
54
  end
35
55
 
36
56
  build_repository(@data.fetch("exposed"))
37
57
  end
38
58
 
39
59
  def save
40
- FixtureKit.configuration.isolator.run do
60
+ FixtureKit.runner.isolator.run do
41
61
  models = SqlSubscriber.capture do
42
- @definition.evaluate
62
+ fixture.definition.evaluate
43
63
  end
44
64
 
45
65
  @data = {
46
66
  "records" => generate_statements(models),
47
- "exposed" => build_exposed_mapping(@definition.exposed)
67
+ "exposed" => build_exposed_mapping(fixture.definition.exposed)
48
68
  }
49
69
  end
50
70
 
@@ -70,7 +90,7 @@ module FixtureKit
70
90
  model.find(id)
71
91
  rescue ActiveRecord::RecordNotFound
72
92
  raise FixtureKit::ExposedRecordNotFound,
73
- "Could not find #{model_name} with id=#{id} for exposed record '#{exposed_name}' in fixture '#{@fixture.name}'"
93
+ "Could not find #{model_name} with id=#{id} for exposed record '#{exposed_name}' in fixture '#{@fixture.identifier}'"
74
94
  end
75
95
 
76
96
  def generate_statements(models)
@@ -88,37 +108,22 @@ module FixtureKit
88
108
  rows << "(#{row_values.join(", ")})"
89
109
  end
90
110
 
91
- next if rows.empty?
92
-
93
- sql = build_insert_sql(model.table_name, columns, rows, model.connection)
111
+ sql = rows.empty? ? nil : build_insert_sql(model.table_name, columns, rows, model.connection)
94
112
  statements_by_model[model.name] = sql
95
113
  end
96
114
 
97
115
  statements_by_model
98
116
  end
99
117
 
118
+ def build_delete_sql(model)
119
+ "DELETE FROM #{model.quoted_table_name}"
120
+ end
121
+
100
122
  def build_insert_sql(table_name, columns, rows, connection)
101
123
  quoted_table = connection.quote_table_name(table_name)
102
124
  quoted_columns = columns.map { |c| connection.quote_column_name(c) }
103
125
 
104
- sql = "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}"
105
-
106
- add_conflict_handling(sql, connection)
107
- end
108
-
109
- def add_conflict_handling(sql, connection)
110
- adapter_name = connection.adapter_name.downcase
111
-
112
- case adapter_name
113
- when /sqlite/
114
- sql.sub(/\AINSERT INTO/i, "INSERT OR IGNORE INTO")
115
- when /postgresql/, /postgis/
116
- "#{sql} ON CONFLICT DO NOTHING"
117
- when /mysql/, /trilogy/
118
- sql.sub(/\AINSERT INTO/i, "INSERT IGNORE INTO")
119
- else
120
- sql
121
- end
126
+ "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}"
122
127
  end
123
128
 
124
129
  def build_exposed_mapping(exposed)
@@ -5,17 +5,19 @@ module FixtureKit
5
5
  attr_writer :fixture_path
6
6
  attr_writer :cache_path
7
7
  attr_accessor :isolator
8
- attr_accessor :on_cache
8
+ attr_accessor :on_cache_save
9
+ attr_accessor :on_cache_mount
9
10
 
10
11
  def initialize
11
- @fixture_path = nil
12
+ @fixture_path = "fixture_kit"
12
13
  @cache_path = nil
13
- @isolator = FixtureKit::TestCase::Isolator
14
- @on_cache = nil
14
+ @isolator = FixtureKit::MinitestIsolator
15
+ @on_cache_save = nil
16
+ @on_cache_mount = nil
15
17
  end
16
18
 
17
19
  def fixture_path
18
- @fixture_path ||= detect_fixture_path
20
+ @fixture_path
19
21
  end
20
22
 
21
23
  def cache_path
@@ -24,20 +26,6 @@ module FixtureKit
24
26
 
25
27
  private
26
28
 
27
- def detect_fixture_path
28
- if defined?(RSpec)
29
- "spec/fixture_kit"
30
- elsif defined?(Minitest)
31
- "test/fixture_kit"
32
- elsif Dir.exist?("spec")
33
- "spec/fixture_kit"
34
- elsif Dir.exist?("test")
35
- "test/fixture_kit"
36
- else
37
- "spec/fixture_kit"
38
- end
39
- end
40
-
41
29
  def detect_cache_path
42
30
  "tmp/cache/fixture_kit"
43
31
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ module ConfigurationHelper
5
+ private
6
+
7
+ def configuration
8
+ FixtureKit.runner.configuration
9
+ end
10
+ end
11
+ end
@@ -2,10 +2,11 @@
2
2
 
3
3
  module FixtureKit
4
4
  class Definition
5
- attr_reader :exposed
5
+ attr_reader :exposed, :source_location
6
6
 
7
7
  def initialize(&definition)
8
8
  @definition = definition
9
+ @source_location = definition.source_location
9
10
  @exposed = {}
10
11
  end
11
12
 
@@ -2,38 +2,30 @@
2
2
 
3
3
  module FixtureKit
4
4
  class Fixture
5
- attr_reader :name, :path
5
+ include ConfigurationHelper
6
6
 
7
- def initialize(name, path)
8
- @name = name
9
- @path = path
10
- @cache = Cache.new(self, definition)
7
+ attr_reader :identifier, :definition
8
+
9
+ def initialize(identifier, definition)
10
+ @identifier = identifier
11
+ @definition = definition
12
+ @cache = Cache.new(self)
11
13
  end
12
14
 
13
15
  def cache(force: false)
14
- already_cached = @cache.exists?
15
- return if already_cached && !force
16
+ return if @cache.exists? && !force
16
17
 
18
+ configuration.on_cache_save&.call(@cache.identifier)
17
19
  @cache.save
18
- FixtureKit.configuration.on_cache&.call(name) unless already_cached
19
20
  end
20
21
 
21
22
  def mount
22
23
  unless @cache.exists?
23
- raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{name}'"
24
+ raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{identifier}'"
24
25
  end
25
26
 
27
+ configuration.on_cache_mount&.call(@cache.identifier)
26
28
  @cache.load
27
29
  end
28
-
29
- private
30
-
31
- def definition
32
- @definition ||= begin
33
- definition = eval(File.read(@path), TOPLEVEL_BINDING.dup, @path)
34
- raise FixtureKit::FixtureDefinitionNotFound, "Could not find fixture definition at '#{@path}'" unless definition.is_a?(Definition)
35
- definition
36
- end
37
- end
38
30
  end
39
31
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/test_case"
4
+ require "active_record/fixtures"
5
+
6
+ module FixtureKit
7
+ class MinitestIsolator < FixtureKit::Isolator
8
+ TEST_NAME = "fixture kit cache pregeneration"
9
+
10
+ def run(&block)
11
+ test_class = build_test_class
12
+ test_method = test_class.test(TEST_NAME) do
13
+ block.call
14
+ pass
15
+ end
16
+
17
+ result = test_class.new(test_method).run
18
+ return if result.passed?
19
+
20
+ raise result.failures.first.error
21
+ end
22
+
23
+ private
24
+
25
+ def build_test_class
26
+ Class.new(ActiveSupport::TestCase) do
27
+ ::Minitest::Runnable.runnables.delete(self)
28
+ include(::ActiveRecord::TestFixtures)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ class RSpecIsolator < FixtureKit::Isolator
5
+ def run(&block)
6
+ previous_example = ::RSpec.current_example
7
+ previous_scope = ::RSpec.current_scope
8
+ example_group = build_example_group
9
+ example = example_group.example { block.call }
10
+ instance = example_group.new
11
+ succeeded =
12
+ begin
13
+ example.run(instance, ::RSpec::Core::NullReporter)
14
+ ensure
15
+ ::RSpec.current_example = previous_example
16
+ ::RSpec.current_scope = previous_scope
17
+ end
18
+
19
+ raise example.exception unless succeeded
20
+ end
21
+
22
+ private
23
+
24
+ def build_example_group
25
+ ::RSpec::Core::ExampleGroup.subclass(
26
+ ::RSpec::Core::ExampleGroup,
27
+ "FixtureKit",
28
+ [],
29
+ []
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fixture_kit"
4
+ require "active_support/lazy_load_hooks"
5
+
6
+ module FixtureKit
7
+ module Minitest
8
+ DECLARATION_CLASS_ATTRIBUTE = :fixture_kit_declaration
9
+
10
+ module ClassMethods
11
+ def fixture(name = nil, &definition_block)
12
+ self.fixture_kit_declaration = FixtureKit.runner.register(self, name, &definition_block)
13
+ end
14
+ end
15
+
16
+ module InstanceMethods
17
+ def fixture
18
+ @_fixture_kit_repository || raise("No fixture declared for this test class. Use `fixture \"name\"` in your test class.")
19
+ end
20
+ end
21
+
22
+ def self.configure!(test_case)
23
+ FixtureKit.runner.configuration.fixture_path = "test/fixture_kit"
24
+ FixtureKit.runner.configuration.isolator = FixtureKit::MinitestIsolator
25
+
26
+ test_case.class_attribute DECLARATION_CLASS_ATTRIBUTE, instance_accessor: false
27
+ test_case.extend ClassMethods
28
+ test_case.include InstanceMethods
29
+
30
+ test_case.setup do
31
+ declaration = self.class.fixture_kit_declaration
32
+ next unless declaration
33
+
34
+ FixtureKit.runner.start unless FixtureKit.runner.started?
35
+ @_fixture_kit_repository = declaration.mount
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ ActiveSupport.on_load(:active_support_test_case) do
42
+ FixtureKit::Minitest.configure!(self)
43
+ end
@@ -1,34 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pathname"
4
-
5
3
  module FixtureKit
6
4
  class Registry
7
- def initialize(configuration)
8
- @configuration = configuration
9
- @registry = {}
10
- end
5
+ include ConfigurationHelper
11
6
 
12
- def add(name)
13
- return @registry[name] if @registry.key?(name)
7
+ def initialize
8
+ @declarations = {}
9
+ @fixtures = {}
10
+ end
14
11
 
15
- file_path = fixture_file_path(name)
16
- unless File.file?(file_path)
17
- raise FixtureKit::FixtureDefinitionNotFound,
18
- "Could not find fixture definition file for '#{name}' at '#{file_path}'"
12
+ def add(scope, name_or_block)
13
+ if @declarations.key?(scope)
14
+ raise FixtureKit::MultipleFixtures, "cannot load multiple fixtures in the same context"
19
15
  end
20
16
 
21
- @registry[name] = Fixture.new(name, file_path)
17
+ @declarations[scope] =
18
+ case name_or_block
19
+ when String
20
+ fetch_named_fixture(name_or_block)
21
+ when Proc
22
+ fetch_anonymous_fixture(scope, name_or_block)
23
+ else
24
+ raise FixtureKit::InvalidFixtureDeclaration, "unsupported fixture declaration type: #{name_or_block.class}"
25
+ end
22
26
  end
23
27
 
24
28
  def fixtures
25
- @registry.values
29
+ @fixtures.values
26
30
  end
27
31
 
28
32
  private
29
33
 
30
- def fixture_file_path(name)
31
- File.expand_path(File.join(@configuration.fixture_path, "#{name}.rb"))
34
+ def fetch_named_fixture(name)
35
+ @fixtures[name] ||= Fixture.new(name, load_named_definition(name))
36
+ end
37
+
38
+ def fetch_anonymous_fixture(scope, definition)
39
+ @fixtures[scope] ||= Fixture.new(scope, Definition.new(&definition))
40
+ end
41
+
42
+ def load_named_definition(name)
43
+ file_path = File.expand_path(File.join(configuration.fixture_path, "#{name}.rb"))
44
+
45
+ unless File.file?(file_path)
46
+ raise FixtureKit::FixtureDefinitionNotFound,
47
+ "cannot find fixture definition file for '#{name}' at '#{file_path}'"
48
+ end
49
+
50
+ definition = eval(File.read(file_path), TOPLEVEL_BINDING.dup, file_path)
51
+ return definition if definition.is_a?(Definition)
52
+
53
+ raise FixtureKit::FixtureDefinitionNotFound, "cannot find fixture definition at '#{file_path}'"
32
54
  end
33
55
  end
34
56
  end
@@ -4,8 +4,6 @@ require "fixture_kit"
4
4
 
5
5
  module FixtureKit
6
6
  module RSpec
7
- autoload :Isolator, File.expand_path("rspec/isolator", __dir__)
8
-
9
7
  DECLARATION_METADATA_KEY = :fixture_kit_declaration
10
8
 
11
9
  # Class methods (extended via config.extend)
@@ -29,8 +27,8 @@ module FixtureKit
29
27
  # end
30
28
  # end
31
29
  # end
32
- def fixture(name)
33
- metadata[DECLARATION_METADATA_KEY] = ::RSpec.configuration.fixture_kit.register(name)
30
+ def fixture(name = nil, &definition_block)
31
+ metadata[DECLARATION_METADATA_KEY] = ::RSpec.configuration.fixture_kit.register(self, name, &definition_block)
34
32
  end
35
33
  end
36
34
 
@@ -39,13 +37,14 @@ module FixtureKit
39
37
  # Returns the Repository for the current example's fixture.
40
38
  # Access exposed records as methods: fixture.alice, fixture.posts
41
39
  def fixture
42
- @_fixture_kit_fixture_set || raise("No fixture declared for this example group. Use `fixture \"name\"` in your describe/context block.")
40
+ @_fixture_kit_repository || raise("No fixture declared for this example group. Use `fixture \"name\"` in your describe/context block.")
43
41
  end
44
42
  end
45
43
 
46
44
  def self.configure!(config)
45
+ FixtureKit.runner.configuration.fixture_path = "spec/fixture_kit"
47
46
  config.add_setting(:fixture_kit, default: FixtureKit.runner)
48
- FixtureKit.configuration.isolator = Isolator
47
+ FixtureKit.runner.configuration.isolator = FixtureKit::RSpecIsolator
49
48
 
50
49
  config.extend ClassMethods
51
50
  config.include InstanceMethods, DECLARATION_METADATA_KEY
@@ -53,7 +52,7 @@ module FixtureKit
53
52
  # Load declared fixtures at the beginning of each example.
54
53
  # Runs inside transactional fixtures and before user-defined before hooks.
55
54
  config.prepend_before(:example, DECLARATION_METADATA_KEY) do |example|
56
- @_fixture_kit_fixture_set = example.metadata[DECLARATION_METADATA_KEY].mount
55
+ @_fixture_kit_repository = example.metadata[DECLARATION_METADATA_KEY].mount
57
56
  end
58
57
 
59
58
  config.append_before(:suite) do
@@ -10,12 +10,13 @@ module FixtureKit
10
10
 
11
11
  def initialize
12
12
  @configuration = Configuration.new
13
- @registry = Registry.new(configuration)
13
+ @registry = Registry.new
14
+ @isolator = nil
14
15
  @started = false
15
16
  end
16
17
 
17
- def register(name)
18
- registry.add(name).tap do |fixture|
18
+ def register(scope, name = nil, &definition_block)
19
+ registry.add(scope, normalize_registration(name, definition_block)).tap do |fixture|
19
20
  fixture.cache if started?
20
21
  end
21
22
  end
@@ -28,6 +29,10 @@ module FixtureKit
28
29
  registry.fixtures.each(&:cache)
29
30
  end
30
31
 
32
+ def isolator
33
+ @isolator ||= configuration.isolator.new
34
+ end
35
+
31
36
  def started?
32
37
  @started
33
38
  end
@@ -41,5 +46,16 @@ module FixtureKit
41
46
  def preserve_cache?
42
47
  ENV[PRESERVE_CACHE_ENV_KEY].to_s.match?(/\A(1|true|yes)\z/i)
43
48
  end
49
+
50
+ def normalize_registration(name, definition_block)
51
+ if name && definition_block
52
+ raise FixtureKit::InvalidFixtureDeclaration, "cannot provide both fixture name and definition block"
53
+ end
54
+
55
+ name_or_block = name || definition_block
56
+ return name_or_block if name_or_block
57
+
58
+ raise FixtureKit::InvalidFixtureDeclaration, "must provide fixture name or definition block"
59
+ end
44
60
  end
45
61
  end
@@ -7,10 +7,6 @@ module FixtureKit
7
7
  self
8
8
  end
9
9
 
10
- def configuration
11
- runner.configuration
12
- end
13
-
14
10
  def runner
15
11
  @runner ||= Runner.new
16
12
  end
@@ -6,18 +6,15 @@ require "active_support/inflector"
6
6
  module FixtureKit
7
7
  class SqlSubscriber
8
8
  EVENT = "sql.active_record"
9
+ NAME_PATTERN = /\A(?<model_name>.+?) (?:(?:Bulk )?(?:Insert|Upsert)|Create|Destroy|(?:Update|Delete)(?: All)?)\z/
9
10
 
10
11
  def self.capture(&block)
11
12
  models = Set.new
12
13
  subscriber = lambda do |_event_name, _start, _finish, _id, payload|
13
- sql = payload[:sql]
14
- next unless sql =~ /\AINSERT INTO/i
14
+ name = payload[:name].to_s
15
+ model_name = name[NAME_PATTERN, :model_name]
16
+ next unless model_name
15
17
 
16
- # payload[:name] is like "User Create" - extract model name
17
- name = payload[:name]
18
- next unless name&.end_with?(" Create")
19
-
20
- model_name = name.sub(/ Create\z/, "")
21
18
  models.add(ActiveSupport::Inflector.constantize(model_name))
22
19
  end
23
20
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FixtureKit
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/fixture_kit.rb CHANGED
@@ -2,26 +2,28 @@
2
2
 
3
3
  module FixtureKit
4
4
  class Error < StandardError; end
5
- class DuplicateFixtureError < Error; end
6
5
  class DuplicateNameError < Error; end
6
+ class InvalidFixtureDeclaration < Error; end
7
+ class MultipleFixtures < Error; end
7
8
  class CacheMissingError < Error; end
8
- class PregenerationError < Error; end
9
9
  class FixtureDefinitionNotFound < Error; end
10
10
  class ExposedRecordNotFound < Error; end
11
11
  class RunnerAlreadyStartedError < Error; end
12
12
 
13
- autoload :VERSION, File.expand_path("fixture_kit/version", __dir__)
14
- autoload :Configuration, File.expand_path("fixture_kit/configuration", __dir__)
15
- autoload :Singleton, File.expand_path("fixture_kit/singleton", __dir__)
16
- autoload :Fixture, File.expand_path("fixture_kit/fixture", __dir__)
17
- autoload :Definition, File.expand_path("fixture_kit/definition", __dir__)
18
- autoload :Registry, File.expand_path("fixture_kit/registry", __dir__)
19
- autoload :Repository, File.expand_path("fixture_kit/repository", __dir__)
20
- autoload :SqlSubscriber, File.expand_path("fixture_kit/sql_subscriber", __dir__)
21
- autoload :Cache, File.expand_path("fixture_kit/cache", __dir__)
22
- autoload :Runner, File.expand_path("fixture_kit/runner", __dir__)
23
- autoload :Isolator, File.expand_path("fixture_kit/isolator", __dir__)
24
- autoload :TestCase, File.expand_path("fixture_kit/test_case", __dir__)
13
+ autoload :VERSION, File.expand_path("fixture_kit/version", __dir__)
14
+ autoload :Configuration, File.expand_path("fixture_kit/configuration", __dir__)
15
+ autoload :ConfigurationHelper, File.expand_path("fixture_kit/configuration_helper", __dir__)
16
+ autoload :Singleton, File.expand_path("fixture_kit/singleton", __dir__)
17
+ autoload :Fixture, File.expand_path("fixture_kit/fixture", __dir__)
18
+ autoload :Definition, File.expand_path("fixture_kit/definition", __dir__)
19
+ autoload :Registry, File.expand_path("fixture_kit/registry", __dir__)
20
+ autoload :Repository, File.expand_path("fixture_kit/repository", __dir__)
21
+ autoload :SqlSubscriber, File.expand_path("fixture_kit/sql_subscriber", __dir__)
22
+ autoload :Cache, File.expand_path("fixture_kit/cache", __dir__)
23
+ autoload :Runner, File.expand_path("fixture_kit/runner", __dir__)
24
+ autoload :Isolator, File.expand_path("fixture_kit/isolator", __dir__)
25
+ autoload :MinitestIsolator, File.expand_path("fixture_kit/isolators/minitest_isolator", __dir__)
26
+ autoload :RSpecIsolator, File.expand_path("fixture_kit/isolators/rspec_isolator", __dir__)
25
27
 
26
28
  extend Singleton
27
29
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fixture_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ngan Pham
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-22 00:00:00.000000000 Z
11
+ date: 2026-02-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -136,18 +136,19 @@ files:
136
136
  - lib/fixture_kit.rb
137
137
  - lib/fixture_kit/cache.rb
138
138
  - lib/fixture_kit/configuration.rb
139
+ - lib/fixture_kit/configuration_helper.rb
139
140
  - lib/fixture_kit/definition.rb
140
141
  - lib/fixture_kit/fixture.rb
141
142
  - lib/fixture_kit/isolator.rb
143
+ - lib/fixture_kit/isolators/minitest_isolator.rb
144
+ - lib/fixture_kit/isolators/rspec_isolator.rb
145
+ - lib/fixture_kit/minitest.rb
142
146
  - lib/fixture_kit/registry.rb
143
147
  - lib/fixture_kit/repository.rb
144
148
  - lib/fixture_kit/rspec.rb
145
- - lib/fixture_kit/rspec/isolator.rb
146
149
  - lib/fixture_kit/runner.rb
147
150
  - lib/fixture_kit/singleton.rb
148
151
  - lib/fixture_kit/sql_subscriber.rb
149
- - lib/fixture_kit/test_case.rb
150
- - lib/fixture_kit/test_case/isolator.rb
151
152
  - lib/fixture_kit/version.rb
152
153
  homepage: https://github.com/Gusto/fixture_kit
153
154
  licenses:
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module FixtureKit
4
- module RSpec
5
- class Isolator < FixtureKit::Isolator
6
- def run(&block)
7
- previous_example = ::RSpec.current_example
8
- previous_scope = ::RSpec.current_scope
9
- example_group = build_example_group
10
- example = build_example(example_group, &block)
11
- instance = example_group.new(example.inspect_output)
12
- succeeded =
13
- begin
14
- example.run(instance, ::RSpec::Core::NullReporter)
15
- ensure
16
- example_group.remove_example(example)
17
- ::RSpec.current_example = previous_example
18
- ::RSpec.current_scope = previous_scope
19
- end
20
-
21
- unless succeeded
22
- raise example.exception if example.exception
23
- raise FixtureKit::PregenerationError, "FixtureKit pregeneration failed"
24
- end
25
- end
26
-
27
- private
28
-
29
- def build_example(example_group, &block)
30
- example_group.example(
31
- "FixtureKit cache pregeneration"
32
- ) { block.call }
33
- end
34
-
35
- def build_example_group
36
- ::RSpec::Core::ExampleGroup.subclass(
37
- ::RSpec::Core::ExampleGroup,
38
- "FixtureKit::RSpec::Isolator",
39
- [],
40
- []
41
- )
42
- end
43
- end
44
- end
45
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/test_case"
4
- require "active_record/fixtures"
5
-
6
- module FixtureKit
7
- module TestCase
8
- class Isolator < FixtureKit::Isolator
9
- TEST_METHOD_NAME = "test_fixture_kit_cache_pregeneration"
10
-
11
- def run(&block)
12
- result = build_test_class(&block).run
13
- return if result.passed?
14
-
15
- failure = result.failures.first
16
- raise failure.error if failure.respond_to?(:error)
17
- raise failure if failure
18
-
19
- raise FixtureKit::PregenerationError, "FixtureKit pregeneration failed"
20
- end
21
-
22
- private
23
-
24
- def build_test_class(&block)
25
- Class.new(ActiveSupport::TestCase) do
26
- ::Minitest::Runnable.runnables.delete(self)
27
- include(::ActiveRecord::TestFixtures)
28
-
29
- define_method(TEST_METHOD_NAME) do
30
- block.call
31
- pass
32
- end
33
- end.new(TEST_METHOD_NAME)
34
- end
35
- end
36
- end
37
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module FixtureKit
4
- module TestCase
5
- autoload :Isolator, File.expand_path("test_case/isolator", __dir__)
6
- end
7
- end