1
0
Fork 0
mirror of https://github.com/thoughtbot/factory_bot.git synced 2022-11-09 11:43:51 -05:00

Improve documentation for collection associations (#1420)

* Improve documentation for collection associations

This commit makes `Evaluator#association` and `Evaluator#instance`
officially part of the public API of factory\_bot. Folks were already
using these methods, and all of the examples here were inspired by
examples in the various issues mentioned below.

This commit also adds and updates some tests to match the examples in
the documentation, to ensure that these examples will continue to work
as expected.

Closes #1268 by adding documentation for both `Evaluator#association`
and `Evaluator#instance`. The documentation on interconnected
associations is also relevant to #1063, #1255, and #1309.

Closes #1304 by providing multiple alternative approaches to creating
collection associations, and adding a note about reloading the record in
the callback example. This had also come up back in #549.

Closes #458 by offering `Evaluator#association` as a more flexible way
to build collection associations. This has come up many times over the
years in many different forms, including #426, #487, #640, #1022, #1150,
and #1360.
This commit is contained in:
Daniel Colson 2020-07-09 12:26:23 -04:00 committed by GitHub
parent 158e0948e5
commit 9879060289
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 226 additions and 60 deletions

View file

@ -44,6 +44,7 @@ Getting Started
+ [`has_many` associations](#has_many-associations)
+ [`has_and_belongs_to_many` associations](#has_and_belongs_to_many-associations)
+ [Polymorphic associations](#polymorphic-associations)
+ [Interconnected associations](#interconnected-associations)
* [Sequences](#sequences)
+ [Global sequences](#global-sequences)
+ [With dynamic attributes](#with-dynamic-attributes)
@ -653,27 +654,51 @@ factory :post do
### `has_many` associations
Generating data for a `has_many` relationship is a bit more involved,
depending on the amount of flexibility desired, but here's a surefire example
of generating associated data.
There are a few ways to generate data for a `has_many` relationship. The
simplest approach is to write a helper method in plain Ruby to tie together the
different records:
```ruby
FactoryBot.define do
factory :post do
title { "Through the Looking Glass" }
user
end
factory :user do
name { "Rachel Sanchez" }
end
end
def user_with_posts(posts_count: 5)
FactoryBot.create(:user) do |user|
FactoryBot.create_list(:post, posts_count, user: user)
end
end
create(:user).posts.length # 0
user_with_posts.posts.length # 5
user_with_posts(posts_count: 15).posts.length # 15
```
If you prefer to keep the object creation fully within factory\_bot, you can
build the posts in an `after(:create)` callback.
```ruby
FactoryBot.define do
# post factory with a `belongs_to` association for the user
factory :post do
title { "Through the Looking Glass" }
user
end
# user factory without associated posts
factory :user do
name { "John Doe" }
# user_with_posts will create post data after the user has been created
factory :user_with_posts do
# posts_count is declared as a transient attribute and available in
# attributes on the factory, as well as the callback via the evaluator
# posts_count is declared as a transient attribute avialabile in the
# callback via the evaluator
transient do
posts_count { 5 }
end
@ -684,71 +709,122 @@ FactoryBot.define do
# to create and we make sure the user is associated properly to the post
after(:create) do |user, evaluator|
create_list(:post, evaluator.posts_count, user: user)
# You may need to reload the record here, depending on your application
user.reload
end
end
end
end
```
This allows us to do:
```ruby
create(:user).posts.length # 0
create(:user_with_posts).posts.length # 5
create(:user_with_posts, posts_count: 15).posts.length # 15
```
### `has_and_belongs_to_many` associations
Generating data for a `has_and_belongs_to_many` relationship is very similar
to the above `has_many` relationship, with a small change, you need to pass an
array of objects to the model's pluralized attribute name rather than a single
object to the singular version of the attribute name.
Here's an example with two models that are related via
`has_and_belongs_to_many`:
Or, for a solution that works with `build`, `build_stubbed`, and `create`
(although it doesn't work well with `attributes_for`), you can use inline
associations:
```ruby
FactoryBot.define do
# language factory with a `belongs_to` association for the profile
factory :language do
factory :post do
title { "Through the Looking Glass" }
profile
user
end
# profile factory without associated languages
factory :profile do
name { "John Doe" }
factory :user do
name { "Taylor Kim" }
# profile_with_languages will create language data after the profile has
# been created
factory :profile_with_languages do
# languages_count is declared as an ignored attribute and available in
# attributes on the factory, as well as the callback via the evaluator
factory :user_with_posts do
posts { [association(:post)] }
end
end
end
create(:user).posts.length # 0
create(:user_with_posts).posts.length # 1
build(:user_with_posts).posts.length # 1
build_stubbed(:user_with_posts).posts.length # 1
```
For more flexibility you can combine this with the `posts_count` transient
attribute from the callback example:
```ruby
FactoryBot.define do
factory :post do
title { "Through the Looking Glass" }
user
end
factory :user do
name { "Adiza Kumato" }
factory :user_with_posts do
transient do
languages_count { 5 }
posts_count { 5 }
end
# the after(:create) yields two values; the profile instance itself and
# the evaluator, which stores all values from the factory, including
# ignored attributes; `create_list`'s second argument is the number of
# records to create and we make sure the profile is associated properly
# to the language
after(:create) do |profile, evaluator|
create_list(:language, evaluator.languages_count, profiles: [profile])
posts do
Array.new(posts_count) { association(:post) }
end
end
end
end
create(:user_with_posts).posts.length # 5
create(:user_with_posts, posts_count: 15).posts.length # 15
build(:user_with_posts, posts_count: 15).posts.length # 15
build_stubbed(:user_with_posts, posts_count: 15).posts.length # 15
```
This allows us to do:
### `has_and_belongs_to_many` associations
Generating data for a `has_and_belongs_to_many` relationship is very similar
to the above `has_many` relationship, with a small change: you need to pass an
array of objects to the model's pluralized attribute name rather than a single
object to the singular version of the attribute name.
```ruby
create(:profile).languages.length # 0
create(:profile_with_languages).languages.length # 5
create(:profile_with_languages, languages_count: 15).languages.length # 15
def profile_with_languages(languages_count: 2)
FactoryBot.create(:profile) do |profile|
FactoryBot.create_list(:language, languages_count, profiles: [profile])
end
end
```
Or with the callback approach:
```ruby
factory :profile_with_languages do
transient do
languages_count { 2 }
end
after(:create) do |profile, evaluator|
create_list(:language, evaluator.languages_count, profiles: [profile])
profile.reload
end
end
```
Or the inline association approach (note the use of the `instance` method here
to refer to the profile being built):
```ruby
factory :profile_with_languages do
transient do
languages_count { 2 }
end
languages do
Array.new(languages_count) do
association(:language, profiles: [instance])
end
end
end
```
### Polymorphic associations
@ -782,6 +858,57 @@ create(:comment, :for_video)
create(:comment, :for_photo)
```
### Interconnected associations
There are limitless ways objects might be interconnected, and
factory\_bot may not always be suited to handle those relationships. In some
cases it makes sense to use factory\_bot to build each individual object, and
then to write helper methods in plain Ruby to tie those objects together.
That said, some more complex, interconnected relationships can be built in factory\_bot
using inline associations with reference to the `instance` being built.
Let's say your models look like this, where an associated `Student` and
`Profile` should both belong to the same `School`:
```ruby
class Student < ApplicationRecord
belongs_to :school
has_one :profile
end
class Profile < ApplicationRecord
belongs_to :school
belongs_to :student
end
class School < ApplicationRecord
has_many :students
has_many :profiles
end
```
We can ensure the student and profile are connected to each other and to the
same school with a factory like this:
```ruby
FactoryBot.define do
factory :student do
school
profile { association :profile, student: instance, school: school }
end
factory :profile do
school
student { association :student, profile: instance, school: school }
end
factory :school
end
```
Note that this approach works with `build`, `build_stubbed`, and `create`, but
the associations will return `nil` when using `attributes_for`.
Sequences
---------

View file

@ -59,48 +59,87 @@ describe "associations" do
end
it "connects records with interdependent relationships" do
define_model("User", school_id: :integer) do
define_model("Student", school_id: :integer) do
belongs_to :school
has_one :profile
end
define_model("Profile", school_id: :integer, user_id: :integer) do
define_model("Profile", school_id: :integer, student_id: :integer) do
belongs_to :school
belongs_to :user
belongs_to :student
end
define_model("School") do
has_many :users
has_many :students
has_many :profiles
end
FactoryBot.define do
factory :user do
factory :student do
school
profile { association :profile, user: instance, school: school }
profile { association :profile, student: instance, school: school }
end
factory :profile do
school
user { association :user, profile: instance, school: school }
student { association :student, profile: instance, school: school }
end
factory :school
end
user = FactoryBot.create(:user)
student = FactoryBot.create(:student)
expect(user.profile.school).to eq(user.school)
expect(user.profile.user).to eq(user)
expect(user.school.users.map(&:id)).to eq([user.id])
expect(user.school.profiles.map(&:id)).to eq([user.profile.id])
expect(student.profile.school).to eq(student.school)
expect(student.profile.student).to eq(student)
expect(student.school.students.map(&:id)).to eq([student.id])
expect(student.school.profiles.map(&:id)).to eq([student.profile.id])
profile = FactoryBot.create(:profile)
expect(profile.user.school).to eq(profile.school)
expect(profile.user.profile).to eq(profile)
expect(profile.student.school).to eq(profile.school)
expect(profile.student.profile).to eq(profile)
expect(profile.school.profiles.map(&:id)).to eq([profile.id])
expect(profile.school.users.map(&:id)).to eq([profile.user.id])
expect(profile.school.students.map(&:id)).to eq([profile.student.id])
end
end
context "when building collection associations" do
it "builds the association according to the given strategy" do
define_model("Photo", listing_id: :integer) do
belongs_to :listing
attr_accessor :name
end
define_model("Listing") do
has_many :photos
end
FactoryBot.define do
factory :photo
factory :listing do
photos { [association(:photo)] }
end
end
created_listing = FactoryBot.create(:listing)
expect(created_listing.photos.first).to be_a Photo
expect(created_listing.photos.first).to be_persisted
built_listing = FactoryBot.build(:listing)
expect(built_listing.photos.first).to be_a Photo
expect(built_listing.photos.first).not_to be_persisted
stubbed_listing = FactoryBot.build_stubbed(:listing)
expect(stubbed_listing.photos.first).to be_a Photo
expect(stubbed_listing.photos.first).to be_persisted
expect { stubbed_listing.photos.first.save! }.to raise_error(
"stubbed models are not allowed to access the database - Photo#save!()"
)
end
end
end