Debugging Rails Applications Made Easy

Learn more about debugging techniques with a hands-on guide

Dealing with bugs in Rails applications

Bugs are common in the programming world. Every project, even with the development team’s best practices, is susceptible to bugs. In this article, we’ll learn more about bugs and debugging techniques.

A bug

A bug occurs when the software does not behave as expected. It could be an error, a crash, or even the wrong information being output. In 1947, engineers at Harvard University discovered a moth in one of the MARK II components, which caused computer problems. This incident led to the name bug being used to indicate the cause of a problem in computing. Having the ability to identify and resolve bugs is very important. Sometimes, they cause minor inconveniences, but more serious bugs can make a piece of software unusable and unreliable.

Debugging

Debugging is the act of investigating and solving a bug in an application. A simple and basic path to debug could be:

Steps to Debug

  1. Identify the bug
  2. Reproduce the bug
  3. Identify the bug causes
  4. Fix the bug
  5. Test the bug to ensure that the fix works
  6. Creating regression tests

To aid us in debugging, we have access to some tools:

Debugger

A debugger is a tool that helps us inspect code while running. With it, you can create breakpoints that will stop the execution of the code. During this time, you can inspect the application’s current state, such as variables and methods. The debugger also gives us access to watchpoints, making it possible to check the value of a specific variable or pause the code when a specific variable changes. On Rails, we have the gems byebug and pry, and code editors such as JetBrains and VS Code have debuggers integrated into the platform.

REPL’s

REPL (Read-Eval-Print Loop) is a user-interactive interface where you can:

  • Read: Insert a command or code
  • Eval: The command is executed
  • Print: The result is exhibited
  • Loop: The process repeats

On Rails, we have the IRB, pry, and rails c. REPLs allow us to execute and explore our application in a safe space. We can call a method with different parameters and analyze the output without having to reproduce the entire application flow.

Client/Browser

You can see how the bug is happening through the client/browser. This is very useful when dealing with a visual bug on the front, for example. Most browsers have tools to help developers debug, such as Chrome DevTools and Firefox Developer Tools.

  • API clients such as Postman can also be beneficial when debugging requests.

Log

Logs are a registry of events or messages generated by the application. They are a great tool for tracking bugs. Syslog defines different log levels, but we can stick to the debug level for debugging. Logs are saved in files like log/development.log or log/production.log. We can use commands like Rails.logger.info and Rails.logger.error to include a message using Rails.

Tracking the bug

  • Reproducing the bug

The most important thing when dealing with a bug is reproducing it. This way, we can see the context and analyze what is causing it. This is why providing context when reporting bugs on applications is very important. You can have two bugs that return the same error to the user but have different causes, so, with context, you can understand and solve that specific case. When the application has a frontend, the tab network on the browser is very useful. There, you can see all the parameters that were sent on the request and what the application returned.

  • Tracking the application path

When you reproduce your bug, the next step is to see the code’s path. Starting with the route, we can see which controller and method were called. This action also helps us decide where to put our breakpoints when using a debugger.

  • Regression testing

When dealing with bugs, any modification will change the existing flow of the application. In the long run, without testing, this can lead to two different problems:

  • Insertion of a new bug: When fixing a bug, you can modify an existing flow that is not tested on the project and accidentally create a new bug. Since this hypothetical flow is not tested, the bug will pass as unseen.
  • Reverting the correction of a bug: If a bug occurs on a specific flow, if after being solved there are no new tests for it, on a future modification, another person can make a change on the code that will bring that bug back, with testing if someone changes the code and make the same bug happens again, it will fail the test suite.

These are two cases that fall under regression tests, but what are regression tests? Regression tests are a type of tests that ensure that the existing features still work well after new features (or changes) are introduced in the code. It will ensure the latest changes don’t cause “regressions” on the previously stable code. Thinking about our previous examples, we can think of regression changes in two ways:

  • The first one is about not breaking anything while we solve our bug, and the second one is to ensure that no one in the future will bring our bug back on the application!
  • The difference between regression testing and regular testing done during development is that regression testing is focused on testing unplanned side effects that can result from changes while testing on the development of new features is focused on ensuring the correct behavior of the code.

So, when we are solving a bug we must include tests to ensure that at least the code will not revert that bug. We usually do that by creating a setup on our tests that is similar to the context of the bug, and we ensure that the application returns the correct results.

  • Monitoring

When setting up an application in production, it is also very important to use a tool that monitors bugs and errors. This way, when something happens, it’s easier to have a context for the situation instead of a generic “something is broken on the application.” Rollbar is a well-known tool for getting those logs, but others are available on the market.

Let’s debug one application!

This is the application we will use to test our knowledge. It is a simple application that receives a spreadsheet and creates or updates employee information on our database. The user provided us with the information that they tried to update the data of one employee and received the message “Validation failed: Email has already been taken”. They also sent us the spreadsheet that they were having issues with.

Screenshot of an Excel file with employee data

Reproduce the bug

With the provided information, we can guess that the bug occurs according to some conditions when trying to update an employee. Our next step is to reproduce the bug. Based on the spreadsheet that was sent, we’re going to create the employee with the same information and try updating it with the same spreadsheet.

List of employees in the system

After doing this flow, we got the same error.

Error message triggered by the bug

So, we successfully reproduced the bug.

Check the request path

Now, we are going to identify the cause. First, we will check the route that this process takes. We can see that it sends a request with the file to the bulk_insert method on the EmployeesController. You can check this by looking at the request that was made on the browser client.

The Network tab in the browser’s developer tools

Track the Application Flow

def bulk_insert
    if params[:file].blank?
      return render json: { error: 'No file provided' }, status: :unprocessable_entity
    end

    service = InsertEmployeesService.new(params[:file])
    result = service.call

    if result[:success]
      flash[:notice] = 'Employees processed successfully'
    else
      flash[:alert] = 'Errors occurred while processing employees'
      flash[:errors] = result[:errors]
    end

    redirect_to employees_path
  end
 end

Based on the message, our flow entered the InsertEmployeesService and returned a failure, so we need to investigate this service.

class InsertEmployeesService
  def initialize(file)
    @file = file
  end

  def call
    result = { success: true, errors: [] }

    begin
      parser = ExcelParserService.new(@file)
      employee_data = parser.parse

      if employee_data.empty?
        result[:success] = false
        result[:errors] << "The file is empty or has no valid data"
        return result
      end

      upsert_service = EmployeeUpsertService.new(employee_data)
      upsert_errors = upsert_service.upsert

      if upsert_errors.any?
        result[:success] = false
        result[:errors].concat(upsert_errors)
      end

      result
    rescue Roo::FileNotFound => e
      result[:success] = false
      result[:errors] << "File not found: #{e.message}"
      result
    rescue => e
      result[:success] = false
      result[:errors] << "Unexpected error: #{e.message}"
      result
    end
  end
end

Now is when the debugging tools mentioned before help us. Since this service calls two other ones, it is hard to see where the bug is unless we look inside the services.

In this situation, one possible approach is to debug after the parser and check if the information is correct. To make this possible, we will use the gem byebug.

class InsertEmployeesService
  def initialize(file)
    @file = file
  end

  def call
    result = { success: true, errors: [] }

    begin
      parser = ExcelParserService.new(@file)
      employee_data = parser.parse
      byebug

      if employee_data.empty?
        result[:success] = false
        result[:errors] << "The file is empty or has no valid data"
        return result
      end

      upsert_service = EmployeeUpsertService.new(employee_data)
      upsert_errors = upsert_service.upsert

      if upsert_errors.any?
        result[:success] = false
        result[:errors].concat(upsert_errors)
      end

      result
    rescue Roo::FileNotFound => e
      result[:success] = false
      result[:errors] << "File not found: #{e.message}"
      result
    rescue => e
      result[:success] = false
      result[:errors] << "Unexpected error: #{e.message}"
      result
    end
  end
end

After that, we make the request again with the file, and a console will appear on the terminal running the application. Here, we can interact with all the variables available in that context. If we examine the employee_data, we will find that the information is accurate and matches the data in the spreadsheet that was used. Therefore, the issue must be with the other service.

Terminal at the breakpoint (using the byebug gem)


class EmployeeUpsertService
  def initialize(employee_data)
    @employee_data = employee_data
  end

  def upsert
    errors = []

    @employee_data.each_with_index do |data, index|
      begin
        upsert_employee(data[0], data[1], data[2])
      rescue ActiveRecord::RecordInvalid => e
        errors << { line: index + 2, message: "Validation failed: #{e.record.errors.full_messages.join(', ')}" }
      rescue => e
        errors << { line: index + 2, message: "Unexpected error: #{e.message}" }
      end
    end

    errors
  end

  private

  def upsert_employee(name, email, age)
    employee = Employee.find_or_initialize_by(email: email)
    employee.assign_attributes(
      name: name,
      age: age
    )
    employee.save!
  end
end

Taking a closer look at this service we can see that the method being called is the upsert, and it calls the upsert_employee

  def upsert_employee(name, email, age)
    byebug
    employee = Employee.find_or_initialize_by(email: email.downcase)
    employee.assign_attributes(
      name: name,
      age: age
    )
    employee.save!
  end

Interacting with the application through the console (using the byebug gem)

We can check the parameters on the console. If we search for an employee by email, we see that it returns nil, but we already created an employee with this email. Since the last employee we created on our development database was related to the bug, we can check the email. The email from the spreadsheet is ‘Mark@email.com’, and the one on the database is ‘mark@email.com.’
If we look at the employee model, we’ll see that before creating the entry, the application downcases the email.

class Employee < ApplicationRecord
  validates :name, presence: true, length: { maximum: 50 }  
  validates :email, presence: true, length: { maximum: 255 }, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { case_sensitive: false }
  validates :age, presence: true, numericality: { only_integer: true, greater_than: 0 }

  before_create :downcase_email

  private

  def downcase_email
    self.email = self.email.downcase if self.email.present?
  end
end 

Solve the bug

So, the application could not find the employee because the search was case-sensitive. In addition, when the entry was created, there was an error in the uniqueness validation of the email. One approach to solving this bug is to downcase the email before searching it.

  def upsert_employee(name, email, age)
    employee = Employee.find_or_initialize_by(email: email.downcase)
    employee.assign_attributes(
      name: name,
      age: age
    )
    employee.save!
  end

If we rerun the flow, we’ll see that everything is working!

Success message

Create regression tests

But we still need to do something essential: Create regression tests.

require 'rails_helper'

RSpec.describe InsertEmployeesService, type: :service do
  describe '#call' do
    let(:service) { described_class.new(file) }

    context 'when the file is valid' do
      let(:file) { fixture_file_upload('employees.xlsx', 'application/vnd.ms-excel') }

      it 'returns success' do
        result = service.call
        expect(result[:success]).to be true
        expect(result[:errors]).to be_empty
      end

      it 'creates employees' do
        expect { service.call }.to change(Employee, :count).by(1)
      end
    end

    context 'when the file is empty' do
      let(:file) { fixture_file_upload('empty.xlsx', 'application/vnd.ms-excel') }

      it 'returns an error' do
        result = service.call
        expect(result[:success]).to be false
        expect(result[:errors]).to include("The file is empty or has no valid data")
      end
    end

    context 'when the employee is already created' do
      let(:file) { fixture_file_upload('employees.xlsx', 'application/vnd.ms-excel') }
      let!(:employee) { create(:employee, email: 'mark@email.com') }

      it 'returns success' do
        result = service.call
        expect(result[:success]).to be true
        expect(result[:errors]).to be_empty
      end
    end
  end
end

The idea is also to reproduce the bug on our test suite, so if the codebase changes in the future, this test will break.

Conclusion

Debugging is a huge part of a developer’s life, so it’s important to know the tools that can make your life easier. As with everything in life, the more you do, the better you get. The knowledge of the codebase that you are using also contributes to the difficulty of this task. With time and practice, debugging can become easier, so don’t give up on the bugs that you will face in your journey.

Repository

The project used on the examples can be found here.

References

We want to work with you. Check out our "What We Do" section!