Aotokitsuruya
Aotokitsuruya
Senior Software Developer
Published at

Write a suitable RSpec test

Include me, write test is many people’s nightmare. Many junior programmers feel it is hard to define which should be tested. So, I decided to share my experience after I tech my colleague today.

Before we start talking about how to write a test, let us stop thinking about anything about TDD or BDD or any you may read about it.

And ask yourself, what is “test”? Why we need a “test”?

The target we add the tests to our project usually to prevent human mistake, that means we try to let compute help us to confirm our code matches the “spec”.

But we have to know, the test still is human write code and the spec is human to decide. When you have the wrong spec and wrong way to test, we still get the wrong result.

So, let us try to keep everything simple, and you will feel happy when writing the test.

The Pure Ruby example

In my experience, the test is related to your code. If you have bad code, and you will hard to test it. So, no matter you write the test before implementing anything or after it. The most important thing is the double-check which you want to test and fit your necessities.

Let’s write a Calculator class, and try to test it.

1class Calculator
2  def initialize
3    @inputs = []
4  end
5end

At first, we have a Calculator class with initialized @inputs array.

And we can create a simple RSpec skeleton.

1RSpec.describe Calculator do
2  let(:calculator) { Calculator.new }
3end

And next, let us add the #add method to the calculator to allow it to add something.

 1class Calculator
 2  def initialize
 3    @inputs = []
 4  end
 5
 6  def add(number)
 7    @inputs << number
 8  end
 9
10  def perform
11    @inputs.sum
12  end
13end

And let’s update our test

 1RSpec.describe Calculator do
 2  let(:calculator) { Calculator.new }
 3
 4  describe '#add' do
 5    let(:number) { 1 }
 6    subject { calculator.add(number) }
 7
 8    it { is_expected.to include(number) }
 9  end
10
11  describe '#perform' do
12    subject { calculator.perform }
13
14    before { calculator.add(1) }
15
16    it { is_expected.to eq(1) }
17  end
18end

In my experience, the best case is you can simply define a subject which is the target you want to test for, and we can use one line to test it. So I usually try to let my code can be tested like the above example.

In the real world, it may not usually ideal. But this post we didn’t discuss these case, maybe we can discuss in the future.

Real-world example

After we have an imagination about the ideal test, let us try to apply it in the real world.

This morning we are discussing a legacy object which is the order’s payment processor with my team member.

 1class PaymentService
 2  def initialize(payment)
 3    @order = payment.order
 4    @payment = payment
 5     # ...
 6    setup
 7  end
 8
 9  def setup
10    @payment.amount = amount
11    @payment.currency = @order.currency
12    # ...
13  end
14
15  def perform
16    return false unless @payment.valid?
17
18    ActiveRecord::Base.transaction do
19       @payment.save
20       VendorAPI.payment.create(amount: @payment.amount)
21       # ...
22    end
23  end
24
25  private
26  def amount
27    @order.items.sum(&:subtotal)
28  end
29end

When we try to test this class, we notice it is very hard to add any test for it. Because the information is encapsulation inside the @payment but we cannot access it.

You may want to expose the @payment as an attribute like service.payment.amount

But if we try to check for the amount is correct, our test code does not make sense.

1subject { service.payment.amount }
2it { is_expected.to eq(100) }

We test for the “Service Object” not the “Payment Model” inside it. According to this rule, the test should be like below.

1subject { service.amount }
2it { is_expected.to eq(100) }

At this moment, the “subject” correctly refers to the service’s amount.

Let’s refactor the PaymentService class to fit our expectations.

 1class PaymentService
 2  def initialize(order)
 3    @order = order
 4  end
 5
 6  def amount
 7    @order.items.sum(&:subtotal)
 8  end
 9
10  def perform
11    payment = build_payment
12    return false unless payment.valid?
13
14    ActiveRecord::Base.transaction do
15      payment.save
16      VendorAPI.payment.create(amount: amount)
17      # ...
18    end
19  end
20
21  private
22
23  def build_payment
24    @order.payments.build(
25      amount: amount,
26      currency: @order.currency
27    )
28  end
29end

After refactoring, the PaymentService is becoming more straight and we can focus tests on the PaymentService.

This is my experience when I design an object and I usually follow this rule in my work.

More example of Rails

The Rails is the popular framework in Rubyist, I use it almost every workday. How can we use the above skills in Rails?

Just keep your class simple, and everything will be easier to test.

 1# Model
 2RSpec.describe User do
 3  it { should validate_presence_of(:email) }
 4  # ...
 5
 6  describe "#avatar_url" do
 7    let(:email) { "[email protected]" }
 8    let(:user) { create(:user, email: email) }
 9    subject { user.avatar_url }
10
11    it "returns Gravatar URL" do
12       digest = OpenSSL::Digest::MD5.hexdigest(email)
13       should eq("https://www.gravatar.com/avatar/#{hash}")
14    end
15  end
16end

For the model, I usually prevent logic inside it. If your project is small and simple, it is ok to do this. But when your project is complex, we usually have to take several steps to process one thing. And that may be a signal to us to split it into an independent class to focus on this process (we usually call them service object)

 1# Request
 2RSpec.describe "/api/users", type: :request do
 3   describe "GET /api/users" do
 4     let(:users) { create_list(:user, 5) }
 5     before { get api_users_path }
 6     subject { response.code }
 7     it { should eq("200") }
 8
 9     describe "body" do
10       subject { JSON.parse(response.body) }
11       it { should_not be_empty }
12       # ...
13     end
14   end
15end

If it is possible, I usually try to make my test more simple. It will help us to think about how to design our class is more clear and easier to use.

The above examples only cover very small parts of tests, but I think it is enough to show the suitable test usually depend on your code.

I still not used to write the test before I start work, and I also skip some tests if I have no time to write it.

But according to my experience, even you didn’t write the test you still need to think about “when I try to test my code, which is easier?”

And then, you will notice the best practice we read from the net if we follow it and usually let our code easier to be tested.

For example, the junior will define a method mix different type return values.

1def sum
2  return false if summable?
3
4  @items.sum
5end

It will cause it hard to predict which type will be returned, and we need to write more test cases to confirm it.

Conclusion

This may not an advanced skill, but I spend a lot of years to learn and try to write a test suitable.

And I notice my company’s junior also has the same problem and feeling confusing when I ask them to try to refactor some legacy code.

They feel lost their way and didn’t know where they can start to refactor the code.

So, when you feel confusing, just check for your code about:

  • Is the test can focus on my class without depending on others?
  • Is my behavior is focused on one thing? (ex. Read and write, validate value, send an API request)
  • Is my method returns is expectable? (ex. the only number, object have the same interface)

That sounds very simple and you may read about some object-oriented article about SOLID rules. But it still is hard to design it to a suitable state which didn’t have too many over design.

Anyway, hope my article can help you find some inspiration when you try to write some tests.