Posted on

Let’s say you want to import all Belgian beers from a beer export CSV and then save them to your database. After some quick hacking, something like this emerges:

class BelgianBeerImport
  def initialize(io)
    @io = io
  end

  def import!
    CSV.parse(io.read, headers: true) do |row|
      next unless row[:country] == 'Belgium'

      Beer.create_from_import!(name: row[:name],
                               brewery_name: row[:brewery],
                               country_name: row[:country])
    end
  end
end

There’s even a test for this. Note how it’s cleverly using a StringIO here to get the data in there without using a file:

class BelgianBeerImportTest < ActiveSupport::TestCase
  def test_import_belgian_beers_from_csv
    io = StringIO.new(
      "name,brewery,country\n" \
      "Grimbergen Blonde,Brouwerij Alken-Maes,Belgium\n" \
      "Oatmeal Stout,Left Hand Brewing Company,United States\n" \
      "Grolsch Premium Weizen,Grolsche Bierbrouwerij,Netherlands\n" \
      "Straffe Hendrik Brugse,De Halve Maan,Belgium"
    )

    assert_difference('Beer.count', +2) do
      BelgianBeerImport.new(io).import!
    end

    # Or however something as complicated as this is tested...
    beer1, beer2 = Beer.last(2)

    assert_equal('Grimbergen Blonde', beer1.name)
    assert_equal('Brouwerij Alken-Maes', beer1.brewery_name)

    assert_equal('Straffe Hendrik Brugse', beer2.name)
    assert_equal('De Halve Maan', beer2.brewery_name)
  end
end

This looks overly complicated though. The test is not only testing the BelgianBeerImport class, but also the CSV class and ActiveRecord itself. In fact, the tests are even hitting the database to verify that the system under test works.

The class is also restricted to importing CSV data. In fact, the test is supplying CSV data, adding another layer of complexity, since you need to provide valid CSV. One typo could ruin the beerfest, making this test rather brittle.

Enumerable, the coolest Ruby module

Enumerable is awesome! I can go on about it for hours, but many others have already done it before me, so I’ll skip on its basics.

The important part is that almost every Ruby developer knows its interface. Array and Hash are enumerable. So are Set, Range, IO, StringIO, File (IO is its parent), Dir, CSV, Struct and even more.

On each of these classes you can use the greatness that Enumerable provides:

# Find all elements matching /apple/
%w(apple pear banana pineapple).grep(/apple/)

# List all file names in the current folder
Dir.new('.').to_a

# Get the longest line in a file, not counting surrounding whitespaces
File.new('Gemfile').map(&:strip).max_by(&:length)

Great, so how does this help here?

Well, CSV is enumerable too. BelgianBeerImport should be refactored to take advantage of this fact. If we can abstract away from using CSV, it opens up interesting opportunities for our test and codebase.

class BelgianBeerImport
  def initialize(io)
    @csv = CSV.new(io, headers: true)
  end

  def import!
    @csv.each do |row|
      next unless row[:country] == 'Belgium'

      Beer.create_from_import!(name: row[:name],
                               brewery_name: row[:brewery],
                               country_name: row[:country])
    end
  end
end

Now we’re creating an instance of CSV and using each to enumerate through the CSV row objects. The tests still pass so all is good!

This can be taken a lot further though. This test is still testing CSV and ActiveRecord. Let’s inject them as dependencies:

class BelgianBeerImport
  def initialize(csv, repository = Beer)
    @csv, @repository = csv, repository
  end

  def import!
    @csv.each do |row|
      next unless row[:country] == 'Belgium'

      @repository.create_from_import!(name: row[:name],
                                      brewery_name: row[:brewery],
                                      country_name: row[:country])
    end
  end
end

The test has to be adjusted accordingly to pass in the CSV object:

class BelgianBeerImportTest < ActiveSupport::TestCase
  def test_import_belgian_beers_from_csv
    io = StringIO.new(
      "name,brewery,country\n" \
      "Grimbergen Blonde,Brouwerij Alken-Maes,Belgium\n" \
      "Oatmeal Stout,Left Hand Brewing Company,United States\n" \
      "Grolsch Premium Weizen,Grolsche Bierbrouwerij,Netherlands\n" \
      "Straffe Hendrik Brugse,De Halve Maan,Belgium"
    )

    assert_difference('Beer.count', +2) do
      BelgianBeerImport.new(CSV.new(io, headers: true)).import!
    end

    # Or however something as complicated as this is tested...
    beer1, beer2 = Beer.last(2)

    assert_equal('Grimbergen Blonde', beer1.name)
    assert_equal('Brouwerij Alken-Maes', beer1.brewery_name)

    assert_equal('Straffe Hendrik Brugse', beer2.name)
    assert_equal('De Halve Maan', beer2.brewery_name)
  end
end

It’s time to have a good look at the BelgianBeerImport class and squeeze your eyes. If you stop thinking about @csv to be a CSV object and row to be a CSV row and think about it as an Enumerable, it suddenly looks an awful lot like an array containing hashes!

class BelgianBeerImport
  def initialize(beers, repository = Beer)
    @beers, @repository = beers, repository
  end

  def import!
    @beers.each do |beer|
      next unless beer[:country] == 'Belgium'

      @repository.create_from_import!(name: beer[:name],
                                      brewery_name: beer[:brewery],
                                      country_name: beer[:country])
    end
  end
end

It’s simply renaming a few variables, but the new names make it a lot easier to see everything in a more abstract fashion.

Time to simplify the test, as the whole CSV part is no longer necessary. Instead we can supply a plain array (also enumerable) with hashes:

class BelgianBeerImportTest < ActiveSupport::TestCase
  def test_import_belgian_beers_from_csv
    beers = [
      { name: 'Grimbergen Blonde', brewery: 'Brouwerij Alken-Maes',
        country: 'Belgium' },
      { name: 'Oatmeal Stout', brewery: 'Left Hand Brewing Company',
        country: 'United States' },
      { name: 'Grolsch Premium Weizen', brewery: 'Grolsche Bierbrouwerij',
        country: 'Netherlands' },
      { name: 'Straffe Hendrik Brugse', brewery: 'De Halve Maan',
        country: 'Belgium' }
    ]
    repository = mock

    repository.expects(:create_from_import!).with(name: 'Grimbergen Blonde',
      brewery_name: 'Brouwerij Alken-Maes', country_name: 'Belgium')
    repository.expects(:create_from_import!).with(name: 'Straffe Hendrik Brugse',
      brewery_name: 'De Halve Maan', country_name: 'Belgium')

    BelgianBeerImport.new(beers, repository).import!
  end
end

Note how the ActiveRecord dependency got replaced with a simple mock and expectations.

Let’s take a bit more advantage of the enumerable interface and be more explicit that we’re expecting the enumerable to contain hashes:

class BelgianBeerImport
  def initialize(beers, repository = Beer)
    @beers, @repository = beers, repository
  end

  def import!
    @beers
      .map(&:to_hash)
      .select { |beer| beer[:country] == 'Belgium' }
      .each { |beer| create_beer(beer) }
  end

  private

  def create_beer(beer)
    @repository.create_from_import!(name: beer[:name],
                                    brewery_name: beer[:brewery],
                                    country_name: beer[:country])
  end
end

A CSV row object also has a to_hash method, which converts the row to a real hash. Now it’s obvious what type of values we’re working with. It also allows the enumerable to contain objects that aren’t hashes, as long as they define the to_hash method and return the data in the required format.

Conclusion

the BelgianBeerImport class has become a lot more flexible. It doesn’t depend on CSV data alone anymore. Instead it has been abstracted into accepting any Enumerable whose values define to_hash.

The class has been completely decoupled from other dependencies. Mostly thanks to Enumerable! It’s less complex, and therefore a lot easier to test. As a bonus, the test is blazing fast. Be sure not to blink when running tests like this, otherwise you’ll think it hasn’t even run!

Combined with duck typing, Enumerable is a very powerful interface. The fact that many Ruby developers are already familiar with its interface make it the ideal candidate to use for collection interfaces.


If you like this post, follow me:


Or share this post: