Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,36 @@ end
`Definition#expose(**records)`:
- Exposed names become repository methods.
- Duplicate exposed names raise `FixtureKit::DuplicateNameError`.
- Records are captured as class/id pairs at the moment `expose` is called, not
at the end of the definition. The record objects themselves are not retained.
- Exposing a record that is not persisted raises
`FixtureKit::UnpersistedRecordError`. This applies to records inside an
exposed collection as well, and to records that have been destroyed.

Because capture happens when `expose` is called, expose a record only once it
has been saved:

```ruby
FixtureKit.define do
user = User.new(name: "Alice")
expose(user: user) # raises FixtureKit::UnpersistedRecordError
user.save!
end
```

The same timing applies to collections, but an emptied or later-appended
collection cannot be detected — it is captured as-is:

```ruby
FixtureKit.define do
projects = []
expose(projects: projects) # captured as an empty collection
projects << Project.create!(...) # not reflected in `fixture.projects`
end
```

The conventional form -- `expose` as the last statement of the definition --
avoids both cases.

## Fixture Inheritance (`extends`)

Expand Down Expand Up @@ -337,6 +367,7 @@ Public error classes:
- `FixtureKit::FixtureDefinitionNotFound`
- `FixtureKit::RunnerAlreadyStartedError`
- `FixtureKit::CircularFixtureInheritance`
- `FixtureKit::UnpersistedRecordError`

## Requirements

Expand Down
1 change: 1 addition & 0 deletions lib/fixture_kit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class CacheMissingError < Error; end
class FixtureDefinitionNotFound < Error; end
class RunnerAlreadyStartedError < Error; end
class CircularFixtureInheritance < Error; end
class UnpersistedRecordError < Error; end

autoload :VERSION, File.expand_path("fixture_kit/version", __dir__)
autoload :Configuration, File.expand_path("fixture_kit/configuration", __dir__)
Expand Down
2 changes: 1 addition & 1 deletion lib/fixture_kit/cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def save
FixtureKit.runner.adapter.execute do |context|
@content = MemoryCache.new(
data: evaluate(FixtureKit.runner.coders, context),
exposed: file_cache.serialize_exposed(fixture.definition.exposed)
exposed: fixture.definition.exposed
)
end

Expand Down
21 changes: 20 additions & 1 deletion lib/fixture_kit/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,31 @@ def expose(**records)
raise FixtureKit::DuplicateNameError, "Name #{name} already exposed"
end

@exposed[name] = record
@exposed[name] = serialize(name, record)
end
end

private

def serialize(name, record)
if record.is_a?(Array)
record.map { |item| reference(name, item) }
else
reference(name, record)
end
end

def reference(name, record)
unless record.persisted?
raise FixtureKit::UnpersistedRecordError,
"cannot expose #{name.inspect}: the #{record.class} is not persisted. " \
"Exposed records are captured as class/id pairs when `expose` is called, " \
"so save the record before exposing it."
end

{ record.class => record.id }
end

def mixin(parent)
definition = self

Expand Down
10 changes: 0 additions & 10 deletions lib/fixture_kit/file_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,6 @@ def write(data)
File.write(path, JSON.pretty_generate(content))
end

def serialize_exposed(exposed)
exposed.each_with_object({}) do |(name, record), hash|
if record.is_a?(Array)
hash[name] = record.map { |record| { record.class => record.id } }
else
hash[name] = { record.class => record.id }
end
end
end

private

def coder_for(class_name)
Expand Down
104 changes: 93 additions & 11 deletions spec/unit/definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,41 +12,43 @@
end

describe "#evaluate" do
let(:alice) { User.create!(name: "Alice", email: "alice-definition@example.com") }

it "captures exposed records from the definition block" do
record = alice
definition = described_class.new do
expose(alice: "alice")
expose(alice: record)
end

definition.evaluate(Object.new)

expect(definition.exposed).to eq({ alice: "alice" })
expect(definition.exposed).to eq({ alice: { User => alice.id } })
end

it "supports helper methods from execution context" do
record = alice
helper_context = Class.new do
def fixture_name
"alice"
end
define_method(:fixture_record) { record }
end.new

definition = described_class.new do
expose(alice: fixture_name)
expose(alice: fixture_record)
end

definition.evaluate(helper_context)

expect(definition.exposed).to eq({ alice: "alice" })
expect(definition.exposed).to eq({ alice: { User => alice.id } })
end

it "supports parent helper in execution context" do
parent_repository = Struct.new(:owner).new("alice")
parent_repository = Struct.new(:owner).new(alice)
definition = described_class.new do
expose(owner: parent.owner)
end

definition.evaluate(Object.new, parent: parent_repository)

expect(definition.exposed).to eq({ owner: "alice" })
expect(definition.exposed).to eq({ owner: { User => alice.id } })
end
end

Expand All @@ -60,14 +62,94 @@ def fixture_name

describe "#expose" do
it "raises when the same name is exposed twice" do
alice = User.create!(name: "Alice", email: "alice-duplicate@example.com")
definition = described_class.new do
expose(alice: "alice")
expose(alice: "duplicate")
expose(alice: alice)
expose(alice: alice)
end

expect do
definition.evaluate(Object.new)
end.to raise_error(FixtureKit::DuplicateNameError, "Name alice already exposed")
end

it "stores a record as a class/id pair" do
alice = User.create!(name: "Alice", email: "alice-pair@example.com")
definition = described_class.new { expose(alice: alice) }

definition.evaluate(Object.new)

expect(definition.exposed).to eq({ alice: { User => alice.id } })
end

it "stores a collection as an array of class/id pairs" do
alice = User.create!(name: "Alice", email: "alice-collection@example.com")
bob = User.create!(name: "Bob", email: "bob-collection@example.com")
definition = described_class.new { expose(users: [alice, bob]) }

definition.evaluate(Object.new)

expect(definition.exposed).to eq({ users: [{ User => alice.id }, { User => bob.id }] })
end

it "stores the record's own class for single-table-inheritance records" do
sedan = Car.create!(name: "Sedan")
definition = described_class.new { expose(sedan: sedan) }

definition.evaluate(Object.new)

expect(definition.exposed).to eq({ sedan: { Car => sedan.id } })
end

it "raises when the record is not persisted" do
unsaved = User.new(name: "Alice", email: "alice-unsaved@example.com")
definition = described_class.new { expose(alice: unsaved) }

expect { definition.evaluate(Object.new) }.to raise_error(
FixtureKit::UnpersistedRecordError,
/cannot expose :alice: the User is not persisted/
)
end

it "raises when a record inside a collection is not persisted" do
saved = User.create!(name: "Alice", email: "alice-mixed@example.com")
unsaved = User.new(name: "Bob", email: "bob-mixed@example.com")
definition = described_class.new { expose(users: [saved, unsaved]) }

expect { definition.evaluate(Object.new) }.to raise_error(
FixtureKit::UnpersistedRecordError,
/cannot expose :users/
)
end

it "raises when the record has been destroyed" do
destroyed = User.create!(name: "Alice", email: "alice-destroyed@example.com")
destroyed.destroy!
definition = described_class.new { expose(alice: destroyed) }

expect { definition.evaluate(Object.new) }
.to raise_error(FixtureKit::UnpersistedRecordError)
end

it "allows an empty collection" do
definition = described_class.new { expose(users: []) }

definition.evaluate(Object.new)

expect(definition.exposed).to eq({ users: [] })
end

# The registry holds every fixture for the life of the process, so a
# definition that held its records would keep their whole object graph
# alive until the suite ends.
it "does not hold a reference to the exposed record" do
alice = User.create!(name: "Alice", email: "alice-noref@example.com")
definition = described_class.new { expose(alice: alice) }

definition.evaluate(Object.new)

expect(definition.exposed.values.flatten).to all(be_a(Hash))
expect(definition.exposed.values.flatten.flat_map(&:values)).to all(be_a(Integer))
end
end
end
19 changes: 0 additions & 19 deletions spec/unit/file_cache_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,23 +84,4 @@
expect(File.exist?(nested_path)).to be(true)
end
end

describe "#serialize_exposed" do
it "converts ActiveRecord instances to class/id pairs" do
user = User.create!(name: "Alice", email: "alice-file-cache@example.com")

result = file_cache.serialize_exposed({ alice: user })

expect(result).to eq({ alice: { User => user.id } })
end

it "converts arrays of ActiveRecord instances" do
alice = User.create!(name: "Alice", email: "alice-array@example.com")
bob = User.create!(name: "Bob", email: "bob-array@example.com")

result = file_cache.serialize_exposed({ users: [alice, bob] })

expect(result).to eq({ users: [{ User => alice.id }, { User => bob.id }] })
end
end
end
23 changes: 23 additions & 0 deletions spec/unit/fixture_cache_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,29 @@ def identifier_for(identifier)
expect(User.count).to eq(0)
end

# The registry holds every fixture for the life of the process, so a
# definition that kept its exposed records would keep their whole object
# graph alive until the suite ends.
it "caches exposed records as class/id pairs without retaining the records" do
fixture_definition = FixtureKit::Definition.new do
alice = User.create!(name: "Alice", email: "alice-release@example.com")
expose(alice: alice)
end
fixture_double = instance_double(
FixtureKit::Fixture,
identifier: fixture_name,
definition: fixture_definition,
parent: nil
)
fixture_cache = described_class.new(fixture_double)

fixture_cache.save

expect(fixture_definition.exposed.fetch(:alice).keys).to eq([User])
expect(fixture_definition.exposed.fetch(:alice).values.first).to be_a(Integer)
expect(fixture_cache.load.alice.name).to eq("Alice")
end

it "includes parent fixture model records when saving inherited fixtures" do
parent_cache_data = FixtureKit::MemoryCache.new(
data: { FixtureKit::ActiveRecordCoder => { User => nil } },
Expand Down
Loading