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
Building a Rails Engine