DEV Community

Abraão Carvalho
Abraão Carvalho

Posted on

Good and Bad practices in writing tests using RSpec in Ruby on Rails

As previously stated, writing tests is a fundamental practice in software development, as it guarantees the quality and stability of the code over time. In the context of Ruby on Rails, a popular framework for developing web applications, RSpec is a widely used testing library. However, it is important to know good practices to write effective tests and avoid bad practices that can compromise code quality and system maintainability.

Write clear and readable tests: Tests must be understandable to any developer who reads them. Use descriptive names for tests and helper methods, and keep test logic simple and straightforward.

Example:

describe UserController do
  describe "#create" do
    it "creates a new user" do
      # Test implementation
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Test behaviors, not implementations

Focus on the expected behaviors of the code, not how those behaviors are implemented. This makes tests less brittle and easier to maintain as the code evolves.

Let's consider an example of how to test the behavior of a calculate_total method of an Order class. Suppose this method is responsible for calculating the total of an order based on the items present in it. Instead of testing specific details of the calculation implementation, we can focus on the expected behavior of the method, such as ensuring that the total is calculated correctly with different items and quantities.

# order.rb

class Order
  attr_reader :items

  def initialize
    @items = []
  end

  def add_item(item, quantity)
    @items << { item: item, quantity: quantity }
  end

  def calculate_total
    total = 0
    @items.each do |item|
      total += item[:item].price * item[:quantity]
    end
    total
  end
end
Enter fullscreen mode Exit fullscreen mode

Here is a test example that focuses on the expected behavior of the calculate_total method rather than implementation details:

# order_spec.rb

require 'order'

RSpec.describe Order do
  describe '#calculate_total' do
    it 'correctly calculates the total with a single item' do
      order = Order.new
      item = double('item', price: 10)
      order.add_item(item, 2)
      expect(order.calculate_total).to eq(20)
    end

    it 'correctly calculates total with multiple items' do
      order = Order.new
      item1 = double('item1', price: 10)
      item2 = double('item2', price: 5)
      order.add_item(item1, 2)
      order.add_item(item2, 3)
      expect(order.calculate_total).to eq(35)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We are testing the behavior of the calculate_total method when adding different items to the order and verifying that the total is calculated correctly. We are not testing the details of the calculation implementation, such as the specific calculations within the method. We are using mock objects (doubles) to represent the items, which allows us to isolate the testing of the method's behavior from the implementation details of the items.

Use contexts to organize tests

Divide tests into meaningful contexts that represent different situations or states of the system. This helps keep tests organized and makes it easier to identify failures.

Let's consider the example used above to test an Order class with methods for adding and removing items, and a method for calculating the order total. We will organize the tests in different contexts to represent different situations in an order.

Example:

# order.rb

class Order
  attr_reader :items

  def initialize
    @items = []
  end

  def add_item(item, quantity)
    @items << { item: item, quantity: quantity }
  end

  def remove_item(item)
    @items.delete(item)
  end

  def calculate_total
    total = 0
    @items.each do |item|
      total += item[:item].price * item[:quantity]
    end
    total
  end
end
Enter fullscreen mode Exit fullscreen mode

Here is an example of how we can use contexts to organize tests:

# order_spec.rb

require 'order'

RSpec.describe Order do
  describe 'with empty order' do
    let(:order) { Order.new }

    it 'has a total of 0' do
      expect(order.calculate_total).to eq(0)
    end

    it 'does not allow removing items' do
      item = { item: 'produto', quantity: 2 }
      order.add_item(item, 2)
      order.remove_item(item)
      expect(order.items).to eq([item])
    end
  end

  describe 'with order containing items' do
    let(:order) { Order.new }
    let(:item1) { { item: 'produto1', quantity: 2 } }
    let(:item2) { { item: 'produto2', quantity: 3 } }

    before do
      order.add_item(item1, 2)
      order.add_item(item2, 3)
    end

    it 'calculates the total correctly' do
      expect(order.calculate_total).to eq(2 * item1[:item].price + 3 * item2[:item].price)
    end

    it 'allows you to remove items' do
      order.remove_item(item1)
      expect(order.items).to eq([item2])
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We use two different contexts one for an empty order and another for an order containing items. Each context uses a separate describe block to group tests related to that specific context. We use let to define instance variables that are shared between tests within the same context. Each test within a context tests a specific aspect of the order's behavior, facilitating the understanding of the system's requirements and behaviors in different situations.

By using contexts to organize tests, we make tests clearer, more concise, and easier to maintain. This also helps you quickly identify where problems lie when tests fail, making troubleshooting easier.

Keep tests independent and isolated

Each test must be independent of the others and must not depend on shared state between tests. This ensures that tests can be run in any order and in any environment.

To keep tests independent and isolated, it is important to ensure that each test does not depend on the state created by other tests and that they can be executed in any order. Let's consider an example of a Calculator class with an add method that adds two numbers. We want to ensure that tests for this method are independent and isolated.

# calculator.rb

class Calculator
  def add(a, b)
    a + b
  end
end
Enter fullscreen mode Exit fullscreen mode

Here is an example test that demonstrates maintaining test independence and isolation:

# calculator_spec.rb

require 'calculator'

RSpec.describe Calculator do
  describe '#add' do
    it 'add two numbers correctly' do
      calculator = Calculator.new
      result = calculator.add(2, 3)
      expect(result).to eq(5)
    end
  end

  describe '#add' do
    it 'sums correctly when a number is zero' do
      calculator = Calculator.new
      result = calculator.add(5, 0)
      expect(result).to eq(5)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Each test is contained in a separate describe block. Even though both tests are testing the same add method, they are completely independent of each other. Each test creates a new Calculator instance, ensuring that they do not share state with each other.

There is no dependency between test results; a test does not depend on the result of another test to pass.

By keeping tests independent and isolated, we ensure that each test can be run independently and in any order, which makes it easier to identify and resolve issues when tests fail. This also makes the tests more robust and less likely to break with changes to the implementation or other tests.

Bad Practices

Fragile and brittle tests

Avoid tests that depend on internal implementation details, such as specific variable values or order of execution. These tests can break easily with small code changes.

RSpec.describe UserController do
  it 'deve criar um usuário com o nome fornecido' do
    post :create, params: { user: { name: 'John' } }
    expect(User.last.name).to eq('John')
  end
end
Enter fullscreen mode Exit fullscreen mode

In this example, the test is directly depending on the last user created in the database to ensure that the user was created correctly. This makes the test brittle as it can easily fail if there are other tests that create users or if the order of execution of tests changes.

Slow and cumbersome testing

Tests that involve slow operations, such as network calls or database access, can make the testing process slow and tedious. Look for ways to isolate these slow operations or replace them with faster simulators in your tests.

RSpec.describe UserController do
  it 'deve enviar um email de boas-vindas ao criar um usuário' do
    allow(UserMailer).to receive(:welcome_email).and_return(double(deliver_now: true))
    post :create, params: { user: { name: 'John', email: 'john@example.com' } }
    expect(UserMailer).to have_received(:welcome_email).with(User.last)
  end
end
Enter fullscreen mode Exit fullscreen mode

In this example, the test is checking whether a welcome email is sent when creating a user. However, it is actually triggering the email sending logic, which can make the test slow and dependent on the email server connection.

Duplicate and redundant tests

Avoid code duplication in tests. If multiple pieces of code require similar testing, consider creating helper methods or factories to reuse the test code.

RSpec.describe UserController do
  it 'deve criar um usuário com o nome fornecido' do
    post :create, params: { user: { name: 'John' } }
    expect(User.last.name).to eq('John')
  end

  it 'deve criar um usuário com o email fornecido' do
    post :create, params: { user: { email: 'john@example.com' } }
    expect(User.last.email).to eq('john@example.com')
  end
end
Enter fullscreen mode Exit fullscreen mode

In this example, we are repeating the user creation logic in multiple tests. This not only makes the tests more verbose, but also makes them more likely to break if the user creation implementation changes.

Conclusion

Writing effective tests is crucial to ensuring the quality and stability of code in a Ruby on Rails application. Following best practices, such as writing clear and readable tests, maintaining independence between tests, and testing behaviors rather than implementations, helps create robust, maintainable tests. Avoiding bad practices, such as fragile and slow testing, is equally important to ensure testing effectiveness over time. By applying these practices when using RSpec in Ruby on Rails, developers can improve code quality and make system maintenance easier.

Top comments (0)