Improving your Rails application design with better TDD
How can we sustainably grow a Rails app? Exploring the connection between code design, refactoring and TDD. With examples. Presented at RubyConf Brasil, 2014.
def post_activate_hook post :activate_hook, :referrer_id => 123, :product_name => "OurProduct Gold Pro", :reference => "ref", :customer_url => "http://processor.org", :security_data => "secdata", :security_hash => "md5sum" end ! it "invokes the MD5 security check" do expect(Digest::MD5).to receive(:hexdigest) { "md5sum" } post_activate_hook end ! context "security check failed" do before { allow(controller).to receive(:security_check_passed?) { false } } ! it "only pretends with OK status" do post :activate_hook_bad expect(response.status).to eql(200) end end spec 1/3
def post_activate_hook post :activate_hook, :referrer_id => 123, :product_name => "OurProduct Gold Pro", :reference => "ref", :customer_url => "http://processor.org", :security_data => "secdata", :security_hash => "md5sum" end ! it "invokes the MD5 security check" do expect(Digest::MD5).to receive(:hexdigest) { "md5sum" } post_activate_hook end ! context "security check failed" do before { allow(controller).to receive(:security_check_passed?) { false } } ! it "only pretends with OK status" do post :activate_hook_bad expect(response.status).to eql(200) end end spec 1/3 stubbing private method internals
let(:subscription) { double(Subscription) } ! before do allow(controller).to receive(:security_check_passed?) { true } ! allow(User).to receive(:find) { user } allow(user).to receive_message_chain(:subscriptions, :create!) { subscription } allow(subscription).to receive_message_chain(:api_requests, :create!) end ! it "finds the user by referrer_id" do expect(User).to receive(:find).with("123") { user } post_activate_hook end spec 2/3
let(:subscription) { double(Subscription) } ! before do allow(controller).to receive(:security_check_passed?) { true } ! allow(User).to receive(:find) { user } allow(user).to receive_message_chain(:subscriptions, :create!) { subscription } allow(subscription).to receive_message_chain(:api_requests, :create!) end ! it "finds the user by referrer_id" do expect(User).to receive(:find).with("123") { user } post_activate_hook end spec 2/3 complexity }
receive_message_chain(:subscriptions, :create!). with(:plan_code => "gold-pro", :reference => "ref", :customer_url => "http://processor.org") post_activate_hook end ! it "saves the subscription's request data as an ApiRequest" do expect(subscription).to receive_message_chain(:api_requests, :create!) post_activate_hook end ! it "responds with OK in HTML body type" do post_activate_hook expect(response.code).to eql("200") expect(response.content_type).to eql("text/html") end ! end end end spec 3/3
receive_message_chain(:subscriptions, :create!). with(:plan_code => "gold-pro", :reference => "ref", :customer_url => "http://processor.org") post_activate_hook end ! it "saves the subscription's request data as an ApiRequest" do expect(subscription).to receive_message_chain(:api_requests, :create!) post_activate_hook end ! it "responds with OK in HTML body type" do post_activate_hook expect(response.code).to eql("200") expect(response.content_type).to eql("text/html") end ! end end end spec 3/3
do ! let(:service) { double(OurApp::Billing::SubscriptionActivation, :execute => nil) } ! before { allow(OurApp::Billing::SubscriptionActivation).to receive(:new) { service } } ! it "calls SubscriptionActivation" do expect(service).to receive(:execute) post_activate_hook end ! it "responds with OK in HTML body type" do post_activate_hook expect(response.code).to eql("200") expect(response.content_type).to eql("text/html") end end end Spec reimagined: going for a one-line implementation
do ! let(:service) { double(OurApp::Billing::SubscriptionActivation, :execute => nil) } ! before { allow(OurApp::Billing::SubscriptionActivation).to receive(:new) { service } } ! it "calls SubscriptionActivation" do expect(service).to receive(:execute) post_activate_hook end ! it "responds with OK in HTML body type" do post_activate_hook expect(response.code).to eql("200") expect(response.content_type).to eql("text/html") end end end service class Spec reimagined: going for a one-line implementation
do { :security_data => "secdata", :security_hash => "md5sum", :referrer_id => user.id, :reference => "ref", :product_name => "OurProduct Gold Pro", :customer_url => "http://processor.org" } end ! subject { OurApp::Billing::SubscriptionActivation.new(params) } ! before { expect(subject).to receive(:security_check_passed?) { true } } ! describe "#execute" do it "creates a new subscription" do subscription = subject.execute subscription.should be_a(Subscription) end end end First pass
do { :security_data => "secdata", :security_hash => "md5sum", :referrer_id => user.id, :reference => "ref", :product_name => "OurProduct Gold Pro", :customer_url => "http://processor.org" } end ! subject { OurApp::Billing::SubscriptionActivation.new(params) } ! before { expect(subject).to receive(:security_check_passed?) { true } } ! describe "#execute" do it "creates a new subscription" do subscription = subject.execute subscription.should be_a(Subscription) end end end First pass shortcut alert
SubscriptionApiRequest" do expect{ service.execute }.to change(SubscriptionApiRequest, :count).by(1) end ! describe "new subscription" do subject { service.execute } it "has plan_code" { subject.plan_code.should eql("ourproduct_gold_pro") } it "has reference" { subject.reference.should eql("ref") } end ! describe "subscription API request" do subject do service.execute SubscriptionApiRequest.last end its(:request_type) { is_expected.to eql("activate_subscription") } its(:params) { is_expected.to eql(params) } end end end Safety net more test coverage, enabled by! new spec and decomposition
initialize(params) @security_data = params[:security_data] @security_hash = params[:security_hash] @plan_name = params[:product_name] end end describe "#security_check_passed?" do ! before do security_hash = Digest::MD5.hexdigest("data" + "privatekey") security_data = "data" ! subject do TestClass.new(:security_data => security_data, :security_hash => security_hash) end ! context "hexdigest sum of data + private key matches with security hash" do it "returns true" do expect(subject.security_check_passed?("privatekey")).to be_truthy end end ! context "hexdigest sum of data + private key does not match hash" do it "returns true" do expect(subject.security_check_passed?("badprivatekey")).not_to be_truthy end end end Decomposition allows for detailed testing
belongs_to :project ! validates :project_id, :subdomain, :room, :token, :presence => true ! def self.enqueue_job(post) HipfireJob.perform_async(post.id) end ! def client @client ||= Tinder::Hipfire.new( subdomain, :token => token ) end def speak(message) begin room = client.find_room_by_name(room) room.speak(message) rescue Tinder::AuthenticationFailed => ex # some logging rescue Errno::ECONNRESET => ex # some other logging end end ! def formatted_message(post) "New post from #{post.author.name}: #{post.title} https://ourapp.com/posts/#{post.slug}" end ! def send_message(post) speak(formatted_message(post)) end end A specimen of a model
belongs_to :project ! validates :project_id, :subdomain, :room, :token, :presence => true ! def self.enqueue_job(post) HipfireJob.perform_async(post.id) end ! def client @client ||= Tinder::Hipfire.new( subdomain, :token => token ) end def speak(message) begin room = client.find_room_by_name(room) room.speak(message) rescue Tinder::AuthenticationFailed => ex # some logging rescue Errno::ECONNRESET => ex # some other logging end end ! def formatted_message(post) "New post from #{post.author.name}: #{post.title} https://ourapp.com/posts/#{post.slug}" end ! def send_message(post) speak(formatted_message(post)) end end A specimen of a model core data modelling
belongs_to :project ! validates :project_id, :subdomain, :room, :token, :presence => true ! def self.enqueue_job(post) HipfireJob.perform_async(post.id) end ! def client @client ||= Tinder::Hipfire.new( subdomain, :token => token ) end def speak(message) begin room = client.find_room_by_name(room) room.speak(message) rescue Tinder::AuthenticationFailed => ex # some logging rescue Errno::ECONNRESET => ex # some other logging end end ! def formatted_message(post) "New post from #{post.author.name}: #{post.title} https://ourapp.com/posts/#{post.slug}" end ! def send_message(post) speak(formatted_message(post)) end end A specimen of a model core data modelling job queueing
belongs_to :project ! validates :project_id, :subdomain, :room, :token, :presence => true ! def self.enqueue_job(post) HipfireJob.perform_async(post.id) end ! def client @client ||= Tinder::Hipfire.new( subdomain, :token => token ) end def speak(message) begin room = client.find_room_by_name(room) room.speak(message) rescue Tinder::AuthenticationFailed => ex # some logging rescue Errno::ECONNRESET => ex # some other logging end end ! def formatted_message(post) "New post from #{post.author.name}: #{post.title} https://ourapp.com/posts/#{post.slug}" end ! def send_message(post) speak(formatted_message(post)) end end A specimen of a model core data modelling implements communication w/ external service job queueing
belongs_to :project ! validates :project_id, :subdomain, :room, :token, :presence => true ! def self.enqueue_job(post) HipfireJob.perform_async(post.id) end ! def client @client ||= Tinder::Hipfire.new( subdomain, :token => token ) end def speak(message) begin room = client.find_room_by_name(room) room.speak(message) rescue Tinder::AuthenticationFailed => ex # some logging rescue Errno::ECONNRESET => ex # some other logging end end ! def formatted_message(post) "New post from #{post.author.name}: #{post.title} https://ourapp.com/posts/#{post.slug}" end ! def send_message(post) speak(formatted_message(post)) end end A specimen of a model core data modelling implements communication w/ external service message formatting job queueing
FactoryGirl.create(:post, :title => "7 ways to be a better person") @hipfire_setting = FactoryGirl.create(:hipfire_setting, :project => @post.project) end end end On our way to a slow test
FactoryGirl.create(:post, :title => "7 ways to be a better person") @hipfire_setting = FactoryGirl.create(:hipfire_setting, :project => @post.project) end end end Deep knowledge about dependencies.! How many DB transactions does this trigger? On our way to a slow test
let(:author) { double(:name => "Johnny Bravo") } ! context "with Post title 'Hello'" do let(:post) { double(:title => "Hello", :slug => "hello") } ! subject { HipfireMessage.new(post, author) } ! its(:text) { is_expected.to eql("New post from Johnny Bravo: Hello https://ourapp.com/posts/hello") } end end end Revisiting the class: one thing at a time (and off the rails)
let(:author) { double(:name => "Johnny Bravo") } ! context "with Post title 'Hello'" do let(:post) { double(:title => "Hello", :slug => "hello") } ! subject { HipfireMessage.new(post, author) } ! its(:text) { is_expected.to eql("New post from Johnny Bravo: Hello https://ourapp.com/posts/hello") } end end end Revisiting the class: one thing at a time (and off the rails) no hard dependencies — doesn’t matter what type these are
describe "#speak" do context "communication raises an exception" do before do room = double allow(hipfire).to receive(:room) { room } expect(room).to receive(:speak).and_raise(Tinder::AuthenticationFailed) end ! it "logs a warning" do expect(AwesomeLogger).to receive(:warn) hipfire.speak("msg") end end end end Dividing further
def initialize(post) @post = post @settings = @post.project.hipfire_settings end ! def message @message ||= Notifications::HipfireMessage.new(@post, @post.author) end ! def send_message hipfire = Notifications::Hipfire.new(@settings) hipfire.speak(message) end end Connecting the dots