Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Improving your Rails application design with be...

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.

Marko Anastasov

August 29, 2014
Tweet

More Decks by Marko Anastasov

Other Decks in Programming

Transcript

  1. require 'digest/md5' class SubscriptionsController < ApplicationController ! def activate_hook if

    security_check_passed?(params[:security_data], params[:security_hash]) user = User.find(params[:referrer_id]) plan_code = params[:product_name].gsub("OurProduct", "").gsub(" ", "-").downcase ! subscription = user.subscriptions.create!(:plan_code => plan_code, :reference => params[:reference], :customer_url => params[:customer_url]) ! subscription.api_requests.create!(:request_type => "activate_subscription", :params => params) end ! render :nothing => true, :status => 200, :content_type => 'text/html' end ! private def security_check_passed?(security_data, security_hash) data_with_private_key = security_data + App.payment_processor_activate_hook_private_key Digest::MD5.hexdigest(data_with_private_key) == security_hash end end
  2. require 'digest/md5' class SubscriptionsController < ApplicationController ! def activate_hook if

    security_check_passed?(params[:security_data], params[:security_hash]) user = User.find(params[:referrer_id]) plan_code = params[:product_name].gsub("OurProduct", "").gsub(" ", "-").downcase ! subscription = user.subscriptions.create!(:plan_code => plan_code, :reference => params[:reference], :customer_url => params[:customer_url]) ! subscription.api_requests.create!(:request_type => "activate_subscription", :params => params) end ! render :nothing => true, :status => 200, :content_type => 'text/html' end ! private def security_check_passed?(security_data, security_hash) data_with_private_key = security_data + App.payment_processor_activate_hook_private_key Digest::MD5.hexdigest(data_with_private_key) == security_hash end end some security check
  3. require 'digest/md5' class SubscriptionsController < ApplicationController ! def activate_hook if

    security_check_passed?(params[:security_data], params[:security_hash]) user = User.find(params[:referrer_id]) plan_code = params[:product_name].gsub("OurProduct", "").gsub(" ", "-").downcase ! subscription = user.subscriptions.create!(:plan_code => plan_code, :reference => params[:reference], :customer_url => params[:customer_url]) ! subscription.api_requests.create!(:request_type => "activate_subscription", :params => params) end ! render :nothing => true, :status => 200, :content_type => 'text/html' end ! private def security_check_passed?(security_data, security_hash) data_with_private_key = security_data + App.payment_processor_activate_hook_private_key Digest::MD5.hexdigest(data_with_private_key) == security_hash end end some security check
  4. require 'digest/md5' class SubscriptionsController < ApplicationController ! def activate_hook if

    security_check_passed?(params[:security_data], params[:security_hash]) user = User.find(params[:referrer_id]) plan_code = params[:product_name].gsub("OurProduct", "").gsub(" ", "-").downcase ! subscription = user.subscriptions.create!(:plan_code => plan_code, :reference => params[:reference], :customer_url => params[:customer_url]) ! subscription.api_requests.create!(:request_type => "activate_subscription", :params => params) end ! render :nothing => true, :status => 200, :content_type => 'text/html' end ! private def security_check_passed?(security_data, security_hash) data_with_private_key = security_data + App.payment_processor_activate_hook_private_key Digest::MD5.hexdigest(data_with_private_key) == security_hash end end some security check input parsing
  5. require 'digest/md5' class SubscriptionsController < ApplicationController ! def activate_hook if

    security_check_passed?(params[:security_data], params[:security_hash]) user = User.find(params[:referrer_id]) plan_code = params[:product_name].gsub("OurProduct", "").gsub(" ", "-").downcase ! subscription = user.subscriptions.create!(:plan_code => plan_code, :reference => params[:reference], :customer_url => params[:customer_url]) ! subscription.api_requests.create!(:request_type => "activate_subscription", :params => params) end ! render :nothing => true, :status => 200, :content_type => 'text/html' end ! private def security_check_passed?(security_data, security_hash) data_with_private_key = security_data + App.payment_processor_activate_hook_private_key Digest::MD5.hexdigest(data_with_private_key) == security_hash end end some security check input parsing most important thing
  6. require 'digest/md5' class SubscriptionsController < ApplicationController ! def activate_hook if

    security_check_passed?(params[:security_data], params[:security_hash]) user = User.find(params[:referrer_id]) plan_code = params[:product_name].gsub("OurProduct", "").gsub(" ", "-").downcase ! subscription = user.subscriptions.create!(:plan_code => plan_code, :reference => params[:reference], :customer_url => params[:customer_url]) ! subscription.api_requests.create!(:request_type => "activate_subscription", :params => params) end ! render :nothing => true, :status => 200, :content_type => 'text/html' end ! private def security_check_passed?(security_data, security_hash) data_with_private_key = security_data + App.payment_processor_activate_hook_private_key Digest::MD5.hexdigest(data_with_private_key) == security_hash end end some security check input parsing most important thing logging
  7. RAILS ASM MOV EAX, [EBX] ; Move the 4 bytes

    in memory at the address contained in EBX into EAX MOV [ESI+EAX], CL ; Move the contents of CL into the byte at address ESI+EAX
  8. describe SubscriptionsController, :type => :controller do describe "POST activate_hook" do

    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
  9. describe SubscriptionsController, :type => :controller do describe "POST activate_hook" do

    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
  10. context "security check passed" do ! let(:user) { double(User) }

    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
  11. context "security check passed" do ! let(:user) { double(User) }

    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 }
  12. it "creates a new subscription for given user" do expect(user).to

    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
  13. it "creates a new subscription for given user" do expect(user).to

    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
  14. def show @user = User.find(params[:id]) end Extracting parameters Invoking application

    specific method Assigning a variable for the view Rendering the view
  15. describe SubscriptionsController, :type => :controller do ! describe "POST activate_hook"

    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
  16. describe SubscriptionsController, :type => :controller do ! describe "POST activate_hook"

    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
  17. class SubscriptionsController < ApplicationController ! def activate_hook subscription_activation = OurApp::Billing::SubscriptionActivation.new(params)

    subscription_activation.execute ! render :nothing => true, :status => 200, :content_type => 'text/html' end ! end Test-driven controller
  18. describe OurApp::Billing::SubscriptionActivation do ! let(:user) { FactoryGirl.create(:user) } ! let(:params)

    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
  19. describe OurApp::Billing::SubscriptionActivation do ! let(:user) { FactoryGirl.create(:user) } ! let(:params)

    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
  20. # ...continued: describe "#execute" do ! it "creates a new

    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
  21. class OurApp::Billing::SubscriptionActivation include OurApp::Billing::HookHelper ! def initialize(params) @params = params

    @security_data = params[:security_data] @security_hash = params[:security_hash] @referrer_id = params[:referrer] @reference = params[:reference] @plan_name = params[:product_name] @customer_url = params[:customer_url] end ! def execute return if not security_check_passed? ! user = User.find(@referrer_id) subscription = user.subscriptions.create!(:plan_code => plan_code, :reference => @reference, :customer_url => @customer_url) subscription.api_requests.create!(:request_type => "activate_subscription", :params => @params) subscription end end
  22. class OurApp::Billing::SubscriptionActivation include OurApp::Billing::HookHelper ! def initialize(params) @params = params

    @security_data = params[:security_data] @security_hash = params[:security_hash] @referrer_id = params[:referrer] @reference = params[:reference] @plan_name = params[:product_name] @customer_url = params[:customer_url] end ! def execute return if not security_check_passed? ! user = User.find(@referrer_id) subscription = user.subscriptions.create!(:plan_code => plan_code, :reference => @reference, :customer_url => @customer_url) subscription.api_requests.create!(:request_type => "activate_subscription", :params => @params) subscription end end
  23. describe "OurApp::Billing::HookHelper" do ! class TestClass include OurApp::Billing::HookHelper ! def

    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
  24. describe "#plan_code" do ! subject { TestClass.new(:product_name => "OurProduct Gold

    Pro”) } ! context "given 'OurProduct Gold Pro'" do its(:plan_code) { is_expected.to eql("gold_pro") } end # … end Decomposition allows for detailed testing
  25. require 'digest/md5' ! module OurApp::Billing::HookHelper ! def security_check_passed?(private_key) data_with_private_key =

    @security_data + private_key ! Digest::MD5.hexdigest(data_with_private_key) == @security_hash end ! def plan_code @plan_name.gsub("OurProduct ", "").gsub(" ", "_").downcase end end Decomposition maximizes readability
  26. •Break away from the Rails MVC-only architecture. •Decompose into smaller

    classes/modules. Results of “What would be easier to test?”
  27. Symptoms of too much modelling •Everything is in app/models •Every

    test depends on the database •Hard to write test setups and examples
  28. Symptoms of too much modelling •Everything is in app/models •Every

    test depends on the database •Hard to write test setups and examples •Slow test suite
  29. class HipfireSetting < ActiveRecord::Base ! attr_accessible :room, :subdomain, :token !

    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
  30. class HipfireSetting < ActiveRecord::Base ! attr_accessible :room, :subdomain, :token !

    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
  31. class HipfireSetting < ActiveRecord::Base ! attr_accessible :room, :subdomain, :token !

    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
  32. class HipfireSetting < ActiveRecord::Base ! attr_accessible :room, :subdomain, :token !

    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
  33. class HipfireSetting < ActiveRecord::Base ! attr_accessible :room, :subdomain, :token !

    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
  34. describe HipfireSetting do describe "#formatted_message" do before do @post =

    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
  35. describe HipfireSetting do describe "#formatted_message" do before do @post =

    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
  36. # spec/lib/ourapp/notifications/hipfire_message_spec.rb describe Notifications::HipfireMessage do ! describe "#text" do !

    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)
  37. # spec/lib/ourapp/notifications/hipfire_message_spec.rb describe Notifications::HipfireMessage do ! describe "#text" do !

    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
  38. # spec/lib/ourapp/notifications/hipfire_spec.rb describe Notifications::Hipfire do let(:hipfire) { Semaphore::Notifications::Hipfire.new(double(HipfireSetting)) } !

    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
  39. # lib/ourapp/notifications/hipfire_sender.rb class Notifications::HipfireSender ! def self.enqueue_job(post) Notifications::HipfireJob.perform_async(post.id) end !

    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
  40. Connecting the dots Isolation via namespaces lib/ourapp/notifications/ - hipfire.rb -

    hipfire_job.rb - hipfire_message.rb - hipfire_sender.rb # lib/ourapp/notifications/hipfire_sender.rb class Notifications::HipfireSender ! def self.enqueue_job(post) Notifications::HipfireJob.perform_async(post.id) end ! 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
  41. class HipfireSetting < ActiveRecord::Base ! attr_accessible :room, :subdomain, :token !

    belongs_to :project ! validates :project_id, :subdomain, :room, :token, :presence => true ! end What happened to the model
  42. Surviving freedom Group classes in logical namespaces. ! Avoid hard

    dependencies. ! Use TDD to drive your design. Be familiar with design patterns.