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
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
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
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
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
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
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
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
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
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
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)