Testing best practices: behavior over implementation details
Tell, Don’t Ask
The principle of “Tell, Don’t Ask” is about shifting from querying objects about their state to commanding them what to do, you can keep your tests clean and focused on behavior rather than implementation details.
Code Examples
Active Record Save Interaction
Consider the typical Rails pattern when working with models:
# app/controllers/articles_controller.rb
if @article.save
redirect_to @article
else
render action: 'new'
end
To test this, one might only need to stub the save
method:
# test/controllers/articles_controller_test.rb
@article.stubs(:save).returns(true)
Here, #save
acts as a command.
It tries to save an Article
, handling both validation and persistence, encapsulating everything into a single action that returns a boolean.
By contrast, decomposing this into #valid?
and #save!
complicates interactions significantly:
# app/controllers/articles_controller.rb
if @article.valid?
@article.save!
redirect_to @article
else
render action: 'new'
end
Such implementation invites multiple stubs for testing:
# test/controllers/articles_controller_test.rb
@article.stubs(:valid?).returns(true)
@article.stubs(:save!)
Named Scopes in Controller
Named scopes can simplify complex queries but may increase coupling between models and controllers:
# app/models/article.rb
class Article < ActiveRecord::Base
MIN_VOTES_TO_DISPLAY = 4
FEATURED_CUTOFF_AGE = 7.days
scope :with_at_least_one_comment, -> { where('articles.comments_count > 0') }
scope :with_votes_at_least, -> (minimum) { where(['articles.votes_count >= ?', minimum]) }
scope :created_after, -> (cutoff_date) { where(['articles.created_at > ?', cutoff_date]) }
end
Application:
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@featured_articles = Article.with_at_least_one_comment
.with_votes_at_least(Article::MIN_VOTES_TO_DISPLAY)
.created_after(Article::FEATURED_CUTOFF_AGE.ago)
end
end
Testing this involves multiple stubs and mocks:
# test/controllers/articles_controller_test.rb
context "given featured articles" do
setup do
chain = stub('chain')
@featured_articles = [Factory(:article), Factory(:article)]
Article.stubs(:with_at_least_one_comment).returns(chain)
chain.stubs(:with_votes_at_least).with(Article::MIN_VOTES_TO_DISPLAY).returns(chain)
chain.stubs(:created_after).with(Article::FEATURED_CUTOFF_AGE.ago).returns(@featured_articles)
end
end
Refactor further the model to encapsulate the logic:
# app/models/article.rb
class Article < ActiveRecord::Base
def self.featured
with_at_least_one_comment.with_votes_at_least(MIN_VOTES_TO_DISPLAY)
.created_after(FEATURED_CUTOFF_AGE.ago)
end
end
And simplify the controller as well as its tests:
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@featured_articles = Article.featured
end
end
# test/controllers/articles_controller_test.rb
context "given featured articles" do
setup do
@featured_articles = [Factory(:article), Factory(:article)]
Article.stubs(:featured).returns(@featured_articles)
end
end
By focusing on telling models what to do and retrieving the needed information, Rails applications can enhance both test simplicity and robustness.
Related posts
Building a Rails Engine