Making Iterative Tests Maintainable with Metaprogramming


Initial Test Setup

# test/models/purchase_test.rb
class PurchaseTest < Test::Unit::TestCase
  context "Given some Purchases of each status" do
    setup do
      %w(in_progress submitted approved shipped received canceled).each do |s|
        Factory(:purchase, :status => s)
      end
    end

    context "Purchase.all_in_progress" do
      setup { @purchases = Purchase.all_in_progress }

      should "not be empty" do
        assert !@purchases.empty?
      end

      should "return only in progress purchases" do
        @purchases.each do |purchase|
          assert purchase.in_progress?
        end
      end
    end

    # All the other methods for statuses
  end
end

The initial test setup above correctly tests the functionality but imagine duplicating it for multiple statuses.

Problem with Iterative Tests

An initial attempt might involve replicating blocks for each status:

# Traditionally duplicated test blocks
%w(in_progress submitted approved shipped received canceled).each do |status|
  context "Purchase.all_#{status}" do
    setup { @purchases = Purchase.send("all_#{status}") }

    should "not be empty" do
      assert !@purchases.empty?
    end

    should "return only #{status} purchases" do
      @purchases.each do |purchase|
        assert purchase.send("#{status}?")
      end
    end
  end
end

This strategy introduces redundancy, makes maintenance difficult, and multiplies update costs if statuses change.

Also code coverage tools might not be able to catch the coverage of these test suites.

Wich would lead to something like this in the model:

class Purchase < ActiveRecord::Base
  validates :status, :presence => true, :inclusion => { :in => %w[in_progress submitted approved shipped received canceled] }

  # Status Finders
  scope :all_in_progress,  where(:status => "in_progress")
  scope :all_submitted,    where(:status => "submitted")
  scope :all_approved,     where(:status => "approved")
  scope :all_shipped,      where(:status => "shipped")
  scope :all_received,     where(:status => "received")
  scope :all_canceled,     where(:status => "canceled")

  # Status Accessors
  def in_progress?
    status == "in_progress"
  end
  # Additional methods not shown for brevity...
end

Now, the real bad practice is that the tests are created iteratively and not declared one by one.

The priority is to create each test case manually for each method, then we can refactor the code below with metaprogramming.

# app/models/purchase.rb
class Purchase < ActiveRecord::Base
  STATUSES.each do |status|
    scope "all_#{status}", -> { where(status: status) }

    define_method("#{status}?") do
      self.status == status
    end
  end
end

Optimal Solution: Using has_status Helper and Metaprogramming

Extracting this pattern to an ActiveRecord extension:

# lib/extensions/statuses.rb
class ActiveRecord::Base
  def self.has_statuses(*status_names)
    status_names.each do |status_name|
      scope "all_#{status_name}", -> { where(status: status_name) }
      define_method("#{status_name}?") do
        self.status == status_name
      end
    end
  end
end

# app/models/purchase.rb
class Purchase < ActiveRecord::Base
  has_statuses :in_progress, :submitted, :approved, :shipped, :received, :canceled
end

This has_statuses method centralizes status handling, simplifying the model’s code and enhancing its maintainability.

Changes to the status list now are trivial and centralized.


Related posts

Free Training By Email:
Building a Rails Engine

We will send udpates but we care about your data.
No spams.

Back to homepage