on
I Love RSpec
As all of you know, I 🩷 Ruby. Some day, I’ll talk about my journey learning different programming languages until I found the best one for me. However, today, I want to talk about one of the best tools I’ve used: RSpec.
RSpec allows behavior-driven development for Ruby. Compared to other BDD tools like MOCHA, it does not seem special at first glance, but it really adds superpowers (even more if used with Rails). Let’s look at a simple example of creating an enumeration inside our model. Rails will automatically provide us with some utility methods.
class IncomingPayment < BaseModel
enum status: {
created: 0,
confirmed: 1,
rejected: 2,
expected: 3
}
}
# So we can use these methods
incoming_payment.created?
incoming_payment.created!
RSpec provides tons of utilities to make our tests more natural. For example, we can test all boolean values in this natural way:
describe "status" do
it "is optional" do
incoming_payment = build(:incoming_payment, status: nil)
# because there is a #valid? method, we can do:
expect(incoming_payment).to be_valid
end
end
RSpec also allows the creation of tests dynamically. Imagine we want to test the model can be moved to rejected status only if the current status is created. We could add the different scenarios explicitly:
describe "set status as rejected" do
context "with a created incoming payment" do
it "is allowed" do
incoming_payment = build(:incoming_payment, :created)
incoming_payment.rejected!
expect(incoming_payment).to be_rejected
end
end
context "with a confirmed incoming payment" do
it "is allowed" do
incoming_payment = build(:incoming_payment, :confirmed)
incoming_payment.rejected!
expect(incoming_payment).not_to be_rejected
end
end
end
Or, if we have many statuses, we can create tests dynamically and be sure everything is well tested when we add a new status.
describe "set status as rejected" do
%w[created rejected].tap do |rejectable_statuses|
rejectable_statuses.each do |status|
context "with a #{status} incoming payment" do
it "is allowed" do
incoming_payment = build(:incoming_payment, status)
incoming_payment.rejected!
expect(incoming_payment).to be_rejected
end
end
end
(IncomingPayment.statuses.keys - rejectable_statuses).each do |status|
context "with a #{status} incoming payment" do
it "is disallowed" do
incoming_payment = build(:incoming_payment, status)
incoming_payment.rejected!
expect(incoming_payment).not_to be_rejected
end
end
end
end
end
Et voilà! This is the output we see running this spec.
Another interesting feature I hadn’t seen in other testing tools is the memoize helper “let()”. It’s a kind of lazy variable that allows us to configure our tests better.
describe "amount_validation" do
let(:incoming_payment, amount: amount)
context "with a positive amount" do
let(:amount) { 100 }
it "is valid" do
expect(incoming_payment).to be_valid
end
end
context "with a negative amount" do
let(:amount) { -10 }
it "is not valid" do
expect(incoming_payment).not_to be_valid
end
end
end
Last but not least, it’s very straightforward to spy and mock objects. This is, again, thanks to the Ruby’s versatility. For example, if we want to force an error when a method is called:
describe "confirm a payment" do
context "when the payment cannot be rejected" do
it "raises an error" do
allow(payment).to receive(:reject!).and_raise_error MyCustomError
expect { payment_confirmer.invoke(payment) }.to raise_error MyCustomError
end
end
end
There are many other cool things I don’t want to publish now. These are just the basics. However, I’ll keep updating this blog with interesting scenarios we have in the @devengoapi codebase.
Have a good testing!