FactoryBot::Blueprint
factory_bot-blueprint
is a FactoryBot extension for building structured objects using a declarative DSL.
Installation
FactoryBot::Blueprint provides three gems.
-
factrey
- core implementation of the declarative DSL (FactoryBot independent) -
factory_bot-blueprint
- the main gem -
factory_bot-blueprint-rspec
- helper to makefactory_bot-blueprint
easier to use in RSpec
If you use RSpec, it is recommended (but not required) to install factory_bot-blueprint-rspec
.
# at Gemfile
gem "factory_bot-blueprint-rspec"
Usage (factory_bot-blueprint
)
This document assumes an understanding of FactoryBot. You can learn about FactoryBot in the factory_bot book.
Getting started
The entry point of this gem is FactoryBot::Blueprint.plan
. You can pass a block to this method, in which you describe the plan for creating objects in DSL.
bp = FactoryBot::Blueprint.plan { user(name: "John") }
In the DSL, each method call, except for certain reserved keywords, corresponds to the creation of an object. The above example creates an object of type user
. The type is automatically defined from the FactoryBot's pre-defined factory of the same name:
FactoryBot.define do
# This factory corresponds to the `user` type
factory :user
end
The result of FactoryBot::Blueprint.plan
is called a blueprint. Blueprints represent a plan for creating a set of objects, which can be passed to FactoryBot::Blueprint.create
or FactoryBot::Blueprint.build
to create the actual set of objects. (these methods are corresponding to FactoryBot.build
and FactoryBot.create
respectively). Method call arguments (ex. name: "John"
) are passed to the FactoryBot's build
or create
method.
FactoryBot::Blueprint.build(bp) # or FactoryBot::Blueprint.create
#=>
#{:_anon_9ea309fe2cd1 => #<User name="John">,
# :_result_ => #<User name="John">}
As you can see, the creation result is a Hash
, and objects are given random names start with _anon_
. It is also notice that the :_result_
(Factrey::Blueprint::Node::RESULT_NAME
) holds the DSL code block result.
The DSL, described in detail below, supports declaring multiple objects and naming.
# FactoryBot::Blueprint.build can also take a DSL code block directly
FactoryBot::Blueprint.build do
let.kevin = user(name: "Kevin")
user(name: "User 1")
user(name: "User 2")
end
#=>
#{:kevin => #<User name="Kevin">,
# :_anon_d3461b354de6 => #<User name="Kevin">,
# :_anon_ee5f94e77718 => #<User name="User 1">,
# :_anon_2a70dd71bdac => #<User name="User 2">,
# :_result_ => #<User name="User 2">}
The Blueprint DSL
This section will go through the primary features of the Blueprint DSL. All DSL APIs can be found in the factrey
API Doc.
Objects, references, and tree structures
For example, with these factories:
FactoryBot.define do
factory(:author)
factory(:blog) { author }
factory(:blog_article) { blog }
end
You can create an author, a blog, and three articles in plain FactoryBot like this:
author = FactoryBot.create(:author, name: "John")
blog = FactoryBot.create(:blog, name: "John's Blog", author:)
FactoryBot.create(:blog_article, title: "Article 1", blog:)
FactoryBot.create(:blog_article, title: "Article 2", blog:)
FactoryBot.create(:blog_article, title: "Article 3", blog:)
This can be rewritten in FactoryBot::Blueprint as follows:
objects = FactoryBot::Blueprint.create do
let.author = author(name: "John")
let.blog = blog(name: "John's Blog", author: ref.author)
blog_article(title: "Article 1", blog: ref.blog)
blog_article(title: "Article 2", blog: ref.blog)
blog_article(title: "Article 3", blog: ref.blog)
end
objects => { author:, blog: }
It's not that interesting, but
- By using the notation
let.name =
, you can declare a named node. - You can refer to nodes in the DSL with the notation
ref.name
.
From here, several simplifications can be made.
First, we can use simplify let.name = name(...)
to let.name(...)
.
objects = FactoryBot::Blueprint.create do
let.author(name: "John")
let.blog(name: "John's Blog", author: ref.author)
blog_article(title: "Article 1", blog: ref.blog)
blog_article(title: "Article 2", blog: ref.blog)
blog_article(title: "Article 3", blog: ref.blog)
end
objects => { author:, blog: }
Next, object declarations can take a block. Within the block, objects can be declared in the same way, but if a proper association can be made here from the object in the block to the parent object 1, this gem will automatically add references to them:
objects = FactoryBot::Blueprint.create do
let.author(name: "John") do
let.blog(name: "John's Blog") do # adds { author: ref.author }
blog_article(title: "Article 1") # adds { blog: ref.blog }
blog_article(title: "Article 2") # adds { blog: ref.blog }
blog_article(title: "Article 3") # adds { blog: ref.blog }
end
end
end
objects => { author:, blog: }
This auto-reference will work automatically for any association of any traits in the FactoryBot's factory definition. 2 3
Finally, we can omit part of the object name based on the ancestor objects.
objects = FactoryBot::Blueprint.create do
let.author(name: "John") do
let.blog(name: "John's Blog") do
article(title: "Article 1") # We have a `blog` in the ancestors, so we can omit `blog_`
article(title: "Article 2")
article(title: "Article 3")
end
end
end
objects => { author:, blog: }
Extending the existing blueprints
FactoryBot::Blueprint.plan
(and .build
and .create
) optionally takes a blueprint as an argument. In this case, instead of creating a new blueprint, the passed blueprint is extended.
bp = FactoryBot::Blueprint.plan { user(name: "Some User") }
FactoryBot::Blueprint.plan(bp) { user(name: "More User") }
FactoryBot::Bluepirnt.build(bp)
#=>
#{:_anon_e1f15f805023 => #<User name="Some User">,
# :_anon_b26e69c8d36b => #<User name="More User">,
# :_result_ => #<User name="Some User">}
Notice that the :_result_
is not overwritten when extending the blueprint.
It is also possible to add arguments and child objects to the existing object declaration in the blueprint, by on.name
notation.
bp = FactoryBot::Blueprint.plan do
let.user(name: "John") do
let.blog(name: "John's Blog") do
article(title: "Article 1")
article(title: "Article 2")
end
end
end
FactoryBot::Blueprint.build(bp) do
on.blog(category: "Daily log") do # adds an argument (category: "Daily log")
article(title: "New article") # adds an article
end
end
#=>
#{:user => #<User name="John">,
# :blog => #<Blog title="John's Blog", category="Daily log", user=...>,
# ...
# :_result_ => #<User name="John">}
External references
In the DSL, method calls are interpreted as object declarations.
def user_id = 123
def bp = FactoryBot::Blueprint.plan { user(id: user_id) } # ERROR: Unknown type: user_id
To avoid this, you can use the ext:
option to refer to it as ext
from the DSL. 4
def user_id = 123
def bp = FactoryBot::Blueprint.plan(ext: self) { user(id: ext.user_id) }
Extending the DSL itself
TODO
RSpec helper methods (provided by factory_bot-blueprint-rspec
)
When trying to use FactoryBot::Blueprint on RSpec, the following patterns are frequent.
# 1. define a blueprint
let(:blog_blueprint) do
FactoryBot::Blueprint.plan(ext: self) do
blog(title: "Daily log") do
let.article(title: "Article 1") { let.comment(text: "foo") }
article(title: "Article 2")
article(title: "Article 3")
end
end
end
# 2. build (or create) it
let(:blog_blueprint_instance) { FactoryBot::Blueprint.build(blog_blueprint) }
# 3. define the result and each named object using let
let(:blog) { blog_blueprint_instance[:_result_] }
let(:article) { blog_blueprint_instance[:article] }
let(:comment) { blog_blueprint_instance[:comment] }
factory_bot-blueprint-rspec
gem provides an all-in-one helper method letbp
to do this.
# Define a blog, an article, and a comment from the instance of the blueprint described in the block
letbp(:blog, %i[article comment]) do
blog(title: "Daily log") do
let.article(title: "Article 1") { let.comment(text: "foo") }
article(title: "Article 2")
article(title: "Article 3")
end
end
letbp
can be broken down into separate helper methods: let_blueprint
and let_blueprint_build
(or let_blueprint_create
). For more details, see factory_bot-blueprint-rspec
API Doc.
letbp
also accepts the options inherit
and strategy
:
RSpec.describe "something" do
letbp(:blog, strategy: :build) do # By default letbp uses :create strategy, :strategy option overwrites it
blog(title: "Daily log") do
let.article(title: "Article 1")
article(title: "Article 2")
article(title: "Article 3")
end
end
context "with some comments on the article 1" do
letbp(:blog, inherit: true) do # Extends super() blueprint by set :inherit option to true
on.article do
comment(text: "Comment 1")
comment(text: "Comment 2")
end
end
end
end
If you are using rubocop-rspec
, you can have these helpers recognized as a group of let
's by the following configuration in .rubocop.yml
.
RSpec:
Language:
Helpers:
- let_blueprint
- let_blueprint_build
- let_blueprint_create
- letbp
- let_blueprint!
- let_blueprint_build!
- let_blueprint_create!
- letbp!
Development
git clone https://github.com/yubrot/factory_bot-blueprint
cd gems/factory_bot-blueprint
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-blueprint. 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::Blueprint project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.
Footnotes
-
or some proper ancestor object ↩
-
Except inline associations. It seems that it is difficult to support this ↩
-
See blueprint_spec.rb (together with factories.rb) for detailed behavior ↩
-
Or you can use local variables alternatively ↩