FactoryBot::With
FactoryBot::With is a FactoryBot extension that enhances usability by wrapping factory methods.
FactoryBot における関連の扱いと、factory_bot-with gem を作った話 (Japanese)
For example, given these factories:
FactoryBot.define do
factory(:blog)
factory(:article) { blog }
factory(:comment) { article }
end
Instead of writing like this:
create(:blog) do |blog|
create(:article, blog:) { |article| create(:comment, article:) }
create(:article, blog:) { |article| create_list(:comment, 3, article:) }
end
FactoryBot::With allows you to write like this:
create.blog do
create.article { create.comment }
create.article { create_list.comment(3) }
end
Installation
Add the following line to your Gemfile:
gem "factory_bot-with"
Then, instead of including FactoryBot::Syntax::Methods
, include FactoryBot::With::Methods
:
# RSpec example
RSpec.configure do |config|
# ...
config.include FactoryBot::With::Methods
# ...
end
Alternatively, these factory methods are also provided as class methods of FactoryBot::With
.
What differs from FactoryBot::Syntax::Methods
?
Method-style syntax
FactoryBot::With overrides the behavior of factory methods called without arguments.
create(:foo, ...) # normal usage
create # returns a Proxy (an intermediate) object
create.foo(...) # is equivalent to create(:foo, ...)
# This also applies to other factory methods:
build_stubbed.foo(...)
create_list.foo(10, ...)
Smarter interpretation of positional arguments
FactoryBot::With allows factory methods to accept Hash
, Array
, and falsy values (false
or nil
) as positional arguments1.
create.foo({ title: "Recipe" }, is_new && %i[latest hot])
#=> create(:foo, :latest, :hot, title: "Recipe") if is_new
# create(:foo, title: "Recipe") otherwise
with
, with_pair
, and with_list
operator
FactoryBot::With introduces new operators: with
(and its family).
with(:factory_name, ...)
with_pair(:factory_name, ...)
with_list(:factory_name, number_of_items, ...)
These operators produce a With
instance. This instance can be passed as an argument to factory methods such as build
or create
:
create.blog(with.article(with.comment))
When the factory method is called, it first collects and removes With
arguments, then delegates the actual object creation to the standard FactoryBot factory method, and finally creates additional objects based on the factory definition. Above example is equivalent to:
_tmp1 = FactoryBot.create(:blog)
_tmp2 = FactoryBot.create(:article, blog: _tmp1)
_tmp3 = FactoryBot.create(:comment, article: _tmp2)
# Here, `blog: _tmp1` and `article: _tmp2` are automatically completed by AAR (described later)
with
automatically resolves references to ancestor objects based on the definition in your FactoryBot factories.
This automatic resolution takes into account any traits, aliases, and factory specifications in the definition.
FactoryBot.define do
factory(:video)
factory(:photo)
factory(:tag) do
# `tag` potentially has an association on `taggable` field. `taggable` is either `video` or `photo`.
trait(:for_video) { taggable factory: :video }
trait(:for_photo) { taggable factory: :photo }
end
end
create.video(with.tag(text: "latest")) # resolved as `taggable: <created video object>`
create.photo(with.tag(text: "latest")) # resolved as `taggable: <created photo object>`
Due to technical limitations, inline associations are not taken into account.
For a factory name that is prefixed by the ancestor object's factory name, the prefix can be omitted.
FactoryBot.define do
factory(:blog)
factory(:blog_article) { blog }
end
create.blog(with.article) # completes to :blog_article
Implicit context scope
FactoryBot::With factory methods can accept a block argument, just like standard FactoryBot. However, in FactoryBot::With, nested factory method calls within a block recognize ancestor objects. This means that nested factory method calls perform AAR and FNC in the same way as the with
operator.
# Instead of writing:
create.blog(with.article(with.comment))
# You can write:
create.blog { create.article { create.comment } }
# ^ This works in the same way as:
create(:blog) do |blog|
create(:article, blog:) do |article|
create(:comment, article:)
end
end
_list
or _pair
factory methods with a blockTo align the behavior with the with_list
operator, there is an incompatible behavior compared to standard FactoryBot:
# This code creates a blog with 2 articles, each with a comment in standard FactoryBot:
# This does not work in FactoryBot::With!
create(:blog) do |blog|
create_list(:article, 2, blog:) do |articles| # yielded *once* with an array of articles
articles.each { |article| create(:comment, article:) }
end
end
# In FactoryBot::With, blocks are yielded for each object. So we must write like this:
create.blog do |blog|
create_list.article(2, blog:) do |article| # yielded *for each article*
create.comment(article:)
end
end
# Again, you can simplify this by (1)omitting the block or (2)using the `with` operator:
create.blog { create_list.article(2) { create.comment } } # (1)
create.blog(with_list.article(2, with.comment)) # (2)
If you want to avoid this incompatibility, you can use Object#tap
.
Additional features
Implicit context scope with existing objects
By calling with
without positional arguments, but with keyword arguments that define the relationship between factory names and objects, along with a block, it creates a context scope where those objects become candidates for AAR and FNC.
let(:blog) { create.blog }
before do
with(blog:) do
# Just like `create.blog { ... }`,
# the `blog` object is available for AAR and FNC in the following `create.article` calls:
create.article(with.comment)
create.article(with_list.comment(3))
end
end
with_list
works similarly to with
, except that it accepts arrays as its values:
blog = create.blog
articles = create_list.article(2, blog:)
with_list(article: articles) { create.comment } # yielded *for each article*
with
as a factory method call template
A With
instance can also be used as a template for factory method calls.
Instead of writing:
let(:story) { create(:story, *story_args, **story_kwargs) }
let(:story_args) { [] }
let(:story_kwargs) { { category: "SF" } }
context "when published more than one year ago" do
let(:story_args) { [*super(), :published] }
let(:story_kwargs) { { **super(), start_at: 2.year.ago } }
# ...
end
You can write like this:
# Factory methods accept a With instance as a first argument:
let(:story) { create(story_template) }
let(:story_template) { with.story(category: "SF") }
context "when published more than one year ago" do
let(:story_template) { with(super(), :published, start_at: 2.year.ago) }
# ...
end
Development
git clone https://github.com/yubrot/factory_bot-with
cd gems/factory_bot-with
bin/setup
bundle exec rake --tasks
bundle exec rake
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/yubrot/factory_bot-with. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the FactoryBot::With project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.
Footnotes
-
The idea for this behavior came from JavaScript libraries such as clsx. ↩