How dependency injection can make Rails tests easier

by Jason Swett,

“Dependency injection” is a fancy-sounding term. When I first heard it I assumed it referred to some super-advanced technique. It wasn’t until years later that I realized that dependency injection is a pretty straightforward technique once you understand what it is.

My aim with this post is to cut through the jargon and show you in simple terms what dependency injection is and why it’s useful.

But first: why are we interested in this topic?

Why bother learning dependency injection?

Depending on how it’s written, some code can be easy to test and some code can be hard to test. Code with entangled dependencies is hard to test.

Why is code with entangled dependencies hard to test? Imagine I have a class `Order` that requires instances of a class called `Payment` in order to function. Let’s then imagine that `Payment` needs some `PaymentType` instances (Visa, MasterCard, cash, etc.) in order to work.

This means that in order to test the class I’m interested in, `Order`, I have to bring two other classes into the picture, `Payment` and `PaymentType`, just to perform the test. And what if `Payment` and `PaymentType` in turn depend on other classes? This test is going to potentially be very tedious to set up.

The opposite of having entangled dependencies is having loose coupling and modularity. Modular, loosely coupled code is easy to test. A number of factors have a bearing on how modular and loosely coupled your code will end up. What I want to show you right now is how dependency injection can help make your code more modular and therefore more easily testable.

An dependency-laden Rails model

Let’s say you’re working on a legacy project that you recently inherited. There’s very little test coverage. You encounter an ActiveRecord model called `CustomerFile`. There’s a method called `parse` that evidently parses a CSV.

class CustomerFile < ActiveRecord::Base
  belongs_to :customer

  def parse
    rows = []

    content = File.read(customer.csv_filename)
    CSV.parse(content, headers: true) do |data|
      rows << data.to_h
    end

    rows
  end
end

Let’s focus on this line for a second: `content = File.read(customer.csv_filename)`.

Apparently a `CustomerFile` object has an associated `customer` object which in turn has a `csv_filename`. How exactly does `customer` get set? It’s not clear. Where exactly is the file that `customer.csv_filename` points to? That’s not obvious either.

We can try to write a test for `CustomerFile` but it probably won’t go very well.

RSpec.describe CustomerFile do
  describe '#parse' do
    let(:customer_file) { CustomerFile.new }

    it 'parses a CSV' do
      # How do we know what to expect?
      # Where is the file that "customer.csv_filename" refers to?
      # expected_first_row = ?

      expect(customer_file.parse[0]).to eq(expected_first_row)
    end
  end
end

Our attempt to write a test hasn’t proven very fruitful. The challenge of writing a test for this class is somewhat “uncomeatable”.

The reason it’s hard to write this test is that `CustomerFile` has a dependency inside of a dependency. We don’t know how to make a `customer`, and even more problematic, we don’t know how to make a CSV file for that customer.

Applying dependency injection for easier testability

Let’s imagine now that `parse` doesn’t require that we have a `customer` with `csv_filename` that points to some mysterious file on the filesystem somewhere.

Let’s imagine a version of `parse` that just takes the file contents as an argument.

class CustomerFile < ActiveRecord::Base
  belongs_to :customer

  def parse(content)
    rows = []

    CSV.parse(content, headers: true) do |data|
      rows << data.to_h
    end

    rows
  end
end

When we try to write a test now, we’ll see that it’s much easier.

RSpec.describe CustomerFile do
  describe '#parse' do
    let(:customer_file) { CustomerFile.new }
    let(:content) { "First Name,Last Name\nJohn,Smith" }

    it 'parses a CSV' do
      expected_first_row = {
        'First Name' => 'John',
        'Last Name' => 'Smith'
      }

      expect(customer_file.parse(content)[0]).to eq(expected_first_row)
    end
  end
end

In this case `parse` doesn’t know or care where the CSV content comes from. This means that we don’t have to bring the filesystem into the picture at all which makes writing this test very convenient. No `customer` object or `customer.csv_filename` value necessary.

If we want to use `parse` for real in the application we can just pass in the file contents like this: `parse(File.read(customer.csv_filename))`.

Conclusion

Modular, loosely coupled code is testable code. You can use dependency injection to help make your code more modular.

Leave a Reply

Your email address will not be published. Required fields are marked *