Igor Dobryn
about IT

FactoryBot for CSV file fixtures

Background

  • CSV processing
  • CSV might contain interpolation (encrypting some data on the fly)
  • Different edge case during single processing:
    • updating existing DB entries from the CSV
    • creating new entries from the CSV
    • validation while processing (which can contain lots of combinations)
    • decryption/encryption while processing

Goal

  • Make testing naturally simple
  • Do not create CSV fixture for every test case
  • Test edge-cases in isolation

Generating CSV file fixture

Fixtures

# ./spec/fixtures/students.csv

id,org_id,district_id,attr1,attr2,attr3 
student1_id,org_1_id,district_id,,,
student2_id,org_1_id,district_id,attr1,attr2,attr3

let(:file_path) { './spec/fixtures/students.csv' }
let(:importer) { Importer.new(file_path) }

 

Generation on the fly

let(:file_path) { './spec/fixtures/students.csv' }
let(:csv) do
  data = <<~CSV
    #{student1.id},#{org_1_id},#{district_id},,,
    #{student2.id},#{org_1_id},#{district_id},#{new_student2_attrs.values.join(',')}                
  CSV
  File.write(file_path, data)
end

let(:importer) { Importer.new(file_path) }

 
It looks awkward, doesn’t it?

What if we have some factory to generate csv_file. Then our code might look more readable

## :csv_file factory

let(:file_path) { './spec/tmp/students.csv' }
let(:csv) do
  create(:csv_file,
    file_path: file_path,
    headers: %i(id org_id district_id attr1 attr2 attr3), 
    data: [
      { id: student1.id, org_id: org_1_id, district_id: district_id },
      { id: student2.id, org_id: org_1_id, district_id: district_id }.reverse_merge(student2_new_attrs)
    ]
  )
end

let(:importer) { Importer.new(csv_file.file_path) }

 
Let’s define a builder class which we will be able to use with factory:

class CsvBuilder
  ROW_SEPARATOR = "\n"
  COLUMN_SEPARATOR = ','

  attr_reader :filename, :data

  def initialize(data)
    @filename = data[:filename]
    @data = data[:data]
    @headers = data[:headers]
  end

  def file_path
    Rails.root.join('tmp', filename)
  end

  def save!
    File.write(file_path, csv)
  end

  def csv
    CSV.generate(headers: headers, col_sep: COLUMN_SEPARATOR, row_sep: ROW_SEPARATOR) do |csv|
      data.each { |attrs| csv << attrs }
    end
  end
end

 
and implement the factory

factory :csv_file, class: CsvBuilder do
  sequence(:filename) { |n| "csv/factory_csv_#{Time.current.to_i}-#{n}.csv" }
  data { [] }
  headers { nil }

  initialize_with { new(attributes) }
end

 
It looks simple, doesn’t it?

Some more use cases

# generate CSV for specific student attributes
create(:csv_file, 
  headers: %i(id name), 
  data: [attributes_for(:student), attributes_for(:first_grade_student)]
)

# define traits
trait :with_10_rows do
  data { Array.new(10) { attributes_for(:student) } }
end

trait :invalid_students do
  data { [attributes_for(:invalid_student)] }
end

trait :with_n_rows do
  ignore do
    rows_count { 1 }
  end

  data { Array.new(rows_count) { attributes_for(:student) } }
end


# fallback for headers in CsvBuilder
def headers 
  @headers ||= data.first.keys
end

create(:csv_file, 
  data: [attributes_for(:student), attributes_for(:first_grade_student)]
)
FactoryBot CSV file fixture