Test which reminded me why I don't really like RSpec
… and check why 5600+ Rails engineers read also this
Test which reminded me why I don’t really like RSpec
Recently, our friend from a different software company asked us for some help with mutant setup. We asked to share a sample test to discover what can be wrong. When I read the snippet on a slack channel, I had immediately written:
— Oh man, this example reminds me why I don’t like RSpec.
— Feel free to review it, it’s written by one of our juniors and I need to give him some feedback soon. — he responded.
Just to be clear: I have no problem with the RSpec library itself. It’s a great tool, I’ve used it in several projects, but I don’t like how a lot of people utilise it. Let’s have a look at it.
Let’s have a look at the original example:
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Api::Students::Update do
let(:user) { create(:user) }
let(:params) { { last_name: "something" } }
subject(:result) { described_class.call(current_user: user, params: params) }
context "when user is a student" do
it { is_expected.to be_success }
end
context "when user is teacher" do
let(:user) { create(:user, :teacher) }
it { is_expected.to be_failure }
end
describe "checking Address update" do
let(:new_zip_code) { Faker::Address.zip_code }
before { params[:zip_code] = new_zip_code }
context "when user has address and want to change something in their address" do
let!(:address) { create(:address, owner: user) }
it "will succeed" do
expect(result.success?).to eq(true)
expect(address.reload.zip_code).to eq(new_zip_code)
end
end
context "when user has not have any address and want to change something in their address" do
let(:new_zip_code) { Faker::Address.zip_code }
before { params[:zip_code] = new_zip_code }
it { is_expected.to be_success }
it "create address with given params" do
expect { result }.to change(Address.all, :count).from(0).to(1)
expect(Address.last.zip_code).to eq(new_zip_code)
expect(user.address).to eq(Address.last)
end
end
end
describe "checking parent update" do
let(:parent_first_name) { Faker::Name.female_first_name }
let(:params) { { parent: { first_name: parent_first_name } } }
context "when user has parent and want to change something" do
let(:parent) { create(:parent) }
let(:user) { create(:user, profession: student) }
let(:student) { create(:student, parent: parent) }
it { is_expected.to be_success }
it "will succeed" do
result
expect(parent.reload.first_name).to eq(parent_first_name)
end
end
context "when user has not have any parent and want to change something in their parent" do
it { is_expected.to be_success }
it "will create parent with given params" do
expect { result }.to change(Parent.all, :count).from(0).to(1)
expect(Parent.last.reload.first_name).to eq(parent_first_name)
expect(user.profession.parent).to eq(Parent.last)
end
end
end
end
Here’s a list of my thoughts after reading test above:
- RSpec specific syntax sugar to actual test code ratio is too d*mn high
- Tests should be verbose about their subject, not about the plumbing around.
- I don’t get what the test is about, it’s unreadable because of the nested
itincontext
— hey, but we have IDEs which can fold the blocks of code…
— Cool, but not here on Slack, nor on GitHub where you usually make your code reviews. I simply don’t want to jump around the file to see what’s the input to a service call.
- Setup is done through
FactoryBotwhich sets some artificial database state, often not following the business rules (if your business rules live inActiveRecordmodels — I’m sorry, we’re past that since years). It’s better to use domain services to setup the initial state. I’ve seen a lot of codebases struggling with gigantic test execution time because of too many database object being created because of howFactoryBotwas used. contextis only used to overwritelet, so there’s different setup in different examples. Why not keep the structure flat and do the setup explicit in every example? If you need something different that declared inlet, just use local variable in the example.letis great for specifying dependencies and things that don’t change per each test case.- The main input to this class giving different outcomes are
params— this should be clearly visible how they differ in the input and what output they give, why not pass them explicitly tocall? - Scope the test expectations, making assertion on
Parent.allwon’t give any guarantee that the service assigned data to a desiredParentobject.
Talk is cheap, so I did a 5 minutes refactoring resulting in this:
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Api::Students::Update do
specify "when user is a student" do
expect(Api::Students::Update.call(current_user: user, params: { last_name: "something" })).to be_success
end
specify "when user is a teacher" do
expect(Api::Students::Update.call(current_user: teacher, params: { last_name: "something" })).to be_failure
end
specify "when user has address and want to change something in their address will succeed" do
address = create(:address, owner: user)
result = Api::Students::Update.call(current_user: user, params: { last_name: "something", zip_code: new_zip_code })
expect(result.success?).to eq(true)
expect(address.reload.zip_code).to eq(new_zip_code)
end
specify "when user has not have any address and want to change something in their address create address with given params" do
result =
expect {
Api::Students::Update.call(current_user: user, params: { last_name: "something", zip_code: new_zip_code })
}.to change(Address.all, :count).from(0).to(1)
expect(result.success?).to eq(true)
expect(Address.last.zip_code).to eq(new_zip_code)
expect(user.address).to eq(Address.last)
end
specify "when user has parent and want to change something" do
user = create(:user, profession: student)
result = Api::Students::Update.call(current_user: user, params: { parent: { first_name: parent_first_name } })
expect(result).to be_success
expect(parent.reload.first_name).to eq(parent_first_name)
end
specify "when user has not have any parent and want to change something in their parent" do
user = create(:user, profession: student)
result =
expect {
Api::Students::Update.call(current_user: user, params: { parent: { first_name: parent_first_name } })
}.to change(Parent.all, :count).from(0).to(1)
expect(result).to be_success
expect(Parent.last.reload.first_name).to eq(parent_first_name)
expect(user.profession.parent).to eq(Parent.last)
end
let(:user) { create(:user) }
let(:teacher) { create(:user, :teacher) }
let(:student) { create(:student, parent: parent) }
let(:parent) { create(:parent) }
let(:new_zip_code) { Faker::Address.zip_code }
let(:parent_first_name) { Fake::Name.female_first_name }
end
What are your thoughts? Which version is more readable to you?