From a039671202b3faa6221daafa60564e123ab7cb8e Mon Sep 17 00:00:00 2001 From: Andrew Haines Date: Fri, 18 Jan 2013 16:40:57 +0000 Subject: [PATCH] Add support for decoupled specs Closes #424 --- README.md | 16 +++ lib/draper.rb | 9 +- lib/draper/view_context.rb | 78 ++++++++++-- lib/draper/view_context/build_strategy.rb | 48 +++++++ .../view_context/build_strategy_spec.rb | 116 +++++++++++++++++ spec/draper/view_context_spec.rb | 117 ++++++++++++++++++ spec/dummy/fast_spec/post_decorator_spec.rb | 38 ++++++ spec/dummy/lib/tasks/test.rake | 6 +- spec/spec_helper.rb | 6 + 9 files changed, 416 insertions(+), 18 deletions(-) create mode 100644 lib/draper/view_context/build_strategy.rb create mode 100644 spec/draper/view_context/build_strategy_spec.rb create mode 100644 spec/draper/view_context_spec.rb create mode 100644 spec/dummy/fast_spec/post_decorator_spec.rb diff --git a/README.md b/README.md index d90a0ea..f14ad3e 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,22 @@ In your `Spork.prefork` block of `spec_helper.rb`, add this: require 'draper/test/rspec_integration' ``` +### Isolated tests + +In tests, Draper needs to build a view context to access helper methods. By default, it will create an `ApplicationController` and then use its view context. If you are speeding up your test suite by testing each component in isolation, you can eliminate this dependency by putting the following in your `spec_helper` or similar: + +```ruby +Draper::ViewContext.test_strategy :fast +``` + +In doing so, your decorators will no longer have access to your application's helpers. If you need to selectively include such helpers, you can pass a block: + +```ruby +Draper::ViewContext.test_strategy :fast do + include ApplicationHelper +end +``` + ## Advanced usage ### Shared Decorator Methods diff --git a/lib/draper.rb b/lib/draper.rb index f071f37..52b146a 100644 --- a/lib/draper.rb +++ b/lib/draper.rb @@ -24,10 +24,11 @@ module Draper base.class_eval do include Draper::ViewContext extend Draper::HelperSupport - before_filter ->(controller) { - Draper::ViewContext.current = nil - Draper::ViewContext.current_controller = controller - } + + before_filter do |controller| + Draper::ViewContext.clear! + Draper::ViewContext.controller = controller + end end end diff --git a/lib/draper/view_context.rb b/lib/draper/view_context.rb index d545d59..633211e 100755 --- a/lib/draper/view_context.rb +++ b/lib/draper/view_context.rb @@ -1,37 +1,89 @@ +require 'draper/view_context/build_strategy' require 'request_store' module Draper module ViewContext + # Hooks into a controller or mailer to save the view context in {current}. def view_context super.tap do |context| Draper::ViewContext.current = context end end - def self.current_controller - RequestStore.store[:current_controller] || ApplicationController.new + # Returns the current controller. + def self.controller + RequestStore.store[:current_controller] end - def self.current_controller=(controller) + # Sets the current controller. + def self.controller=(controller) RequestStore.store[:current_controller] = controller end + # Returns the current view context, or builds one if none is saved. def self.current - RequestStore.store[:current_view_context] ||= build_view_context + RequestStore.store[:current_view_context] ||= build end - def self.current=(context) - RequestStore.store[:current_view_context] = context + # Sets the current view context. + def self.current=(view_context) + RequestStore.store[:current_view_context] = view_context end + # Clears the saved controller and view context. + def self.clear! + self.controller = nil + self.current = nil + end + + # Builds a new view context for usage in tests. See {test_strategy} for + # details of how the view context is built. + def self.build + build_strategy.call + end + + # Configures the strategy used to build view contexts in tests, which + # defaults to `:full` if `test_strategy` has not been called. Evaluates + # the block, if given, in the context of the view context's class. + # + # @example Pass a block to add helper methods to the view context: + # Draper::ViewContext.test_strategy :fast do + # include ApplicationHelper + # end + # + # @param [:full, :fast] name + # the strategy to use: + # + # `:full` - build a fully-working view context. Your Rails environment + # must be loaded, including your `ApplicationController`. + # + # `:fast` - build a minimal view context in tests, with no dependencies + # on other components of your application. + def self.test_strategy(name, &block) + @build_strategy = Draper::ViewContext::BuildStrategy.new(name, &block) + end + + # @private + def self.build_strategy + @build_strategy ||= Draper::ViewContext::BuildStrategy.new(:full) + end + + # @deprecated Use {controller} instead. + def self.current_controller + ActiveSupport::Deprecation.warn("Draper::ViewContext.current_controller is deprecated (use controller instead)", caller) + self.controller || ApplicationController.new + end + + # @deprecated Use {controller=} instead. + def self.current_controller=(controller) + ActiveSupport::Deprecation.warn("Draper::ViewContext.current_controller= is deprecated (use controller instead)", caller) + self.controller = controller + end + + # @deprecated Use {build} instead. def self.build_view_context - current_controller.view_context.tap do |context| - if defined?(ActionController::TestRequest) - context.controller.request ||= ActionController::TestRequest.new - context.request ||= context.controller.request - context.params ||= {} - end - end + ActiveSupport::Deprecation.warn("Draper::ViewContext.build_view_context is deprecated (use build instead)", caller) + build end end end diff --git a/lib/draper/view_context/build_strategy.rb b/lib/draper/view_context/build_strategy.rb new file mode 100644 index 0000000..3d4dc11 --- /dev/null +++ b/lib/draper/view_context/build_strategy.rb @@ -0,0 +1,48 @@ +module Draper + module ViewContext + # @private + module BuildStrategy + + def self.new(name, &block) + const_get(name.to_s.camelize).new(&block) + end + + class Fast + def initialize(&block) + @view_context_class = Class.new(ActionView::Base, &block) + end + + def call + view_context_class.new + end + + private + + attr_reader :view_context_class + end + + class Full + def initialize(&block) + @block = block + end + + def call + controller.view_context.tap do |context| + context.singleton_class.class_eval(&block) if block + end + end + + private + + attr_reader :block + + def controller + (Draper::ViewContext.controller || ApplicationController.new).tap do |controller| + controller.request ||= ActionController::TestRequest.new if defined?(ActionController::TestRequest) + end + end + end + + end + end +end diff --git a/spec/draper/view_context/build_strategy_spec.rb b/spec/draper/view_context/build_strategy_spec.rb new file mode 100644 index 0000000..abb154c --- /dev/null +++ b/spec/draper/view_context/build_strategy_spec.rb @@ -0,0 +1,116 @@ +require 'spec_helper' + +def fake_view_context + double("ViewContext") +end + +def fake_controller(view_context = fake_view_context) + double("Controller", view_context: view_context, request: double("Request")) +end + +module Draper + describe ViewContext::BuildStrategy::Full do + describe "#call" do + context "when a current controller is set" do + it "returns the controller's view context" do + view_context = fake_view_context + ViewContext.stub controller: fake_controller(view_context) + strategy = ViewContext::BuildStrategy::Full.new + + expect(strategy.call).to be view_context + end + end + + context "when a current controller is not set" do + it "uses ApplicationController" do + view_context = fake_view_context + stub_const "ApplicationController", double(new: fake_controller(view_context)) + strategy = ViewContext::BuildStrategy::Full.new + + expect(strategy.call).to be view_context + end + end + + it "adds a request if one is not defined" do + controller = Class.new(ActionController::Base).new + ViewContext.stub controller: controller + strategy = ViewContext::BuildStrategy::Full.new + + expect(controller.request).to be_nil + strategy.call + expect(controller.request).to be_an ActionController::TestRequest + expect(controller.params).to eq({}) + + # sanity checks + expect(controller.view_context.request).to be controller.request + expect(controller.view_context.params).to be controller.params + end + + it "adds methods to the view context from the constructor block" do + ViewContext.stub controller: fake_controller + strategy = ViewContext::BuildStrategy::Full.new do + def a_helper_method; end + end + + expect(strategy.call).to respond_to :a_helper_method + end + + it "includes modules into the view context from the constructor block" do + view_context = Object.new + ViewContext.stub controller: fake_controller(view_context) + helpers = Module.new do + def a_helper_method; end + end + strategy = ViewContext::BuildStrategy::Full.new do + include helpers + end + + expect(strategy.call).to respond_to :a_helper_method + end + end + end + + describe ViewContext::BuildStrategy::Fast do + describe "#call" do + it "returns an instance of a subclass of ActionView::Base" do + strategy = ViewContext::BuildStrategy::Fast.new + + returned = strategy.call + + expect(returned).to be_an ActionView::Base + expect(returned.class).not_to be ActionView::Base + end + + it "returns different instances each time" do + strategy = ViewContext::BuildStrategy::Fast.new + + expect(strategy.call).not_to be strategy.call + end + + it "returns the same subclass each time" do + strategy = ViewContext::BuildStrategy::Fast.new + + expect(strategy.call.class).to be strategy.call.class + end + + it "adds methods to the view context from the constructor block" do + strategy = ViewContext::BuildStrategy::Fast.new do + def a_helper_method; end + end + + expect(strategy.call).to respond_to :a_helper_method + end + + it "includes modules into the view context from the constructor block" do + helpers = Module.new do + def a_helper_method; end + end + strategy = ViewContext::BuildStrategy::Fast.new do + include helpers + end + + expect(strategy.call).to respond_to :a_helper_method + end + end + end +end diff --git a/spec/draper/view_context_spec.rb b/spec/draper/view_context_spec.rb new file mode 100644 index 0000000..75da419 --- /dev/null +++ b/spec/draper/view_context_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +module Draper + describe ViewContext do + describe "#view_context" do + let(:base) { Class.new { def view_context; :controller_view_context; end } } + let(:controller) { Class.new(base) { include ViewContext } } + + it "saves the superclass's view context" do + controller.new.view_context + expect(ViewContext.current).to be :controller_view_context + end + + it "returns the superclass's view context" do + expect(controller.new.view_context).to be :controller_view_context + end + end + + describe ".controller" do + it "returns the stored controller from RequestStore" do + RequestStore.stub store: {current_controller: :stored_controller} + expect(ViewContext.controller).to be :stored_controller + end + end + + describe ".controller=" do + it "stores a controller in RequestStore" do + store = {} + RequestStore.stub store: store + + ViewContext.controller = :stored_controller + expect(store[:current_controller]).to be :stored_controller + end + end + + describe ".current" do + it "returns the stored view context from RequestStore" do + RequestStore.stub store: {current_view_context: :stored_view_context} + expect(ViewContext.current).to be :stored_view_context + end + + it "falls back to building a view context" do + RequestStore.stub store: {} + ViewContext.should_receive(:build).and_return(:new_view_context) + expect(ViewContext.current).to be :new_view_context + end + end + + describe ".current=" do + it "stores a view context in RequestStore" do + store = {} + RequestStore.stub store: store + + ViewContext.current = :stored_view_context + expect(store[:current_view_context]).to be :stored_view_context + end + end + + describe ".clear!" do + it "clears the stored controller and view controller" do + store = {current_controller: :stored_controller, current_view_context: :stored_view_context} + RequestStore.stub store: store + + ViewContext.clear! + expect(store[:current_controller]).to be_nil + expect(store[:current_view_context]).to be_nil + end + end + + describe ".build" do + it "calls the build strategy" do + ViewContext.stub build_strategy: ->{ :new_view_context } + expect(ViewContext.build).to be :new_view_context + end + end + + describe ".build_strategy" do + it "defaults to full" do + expect(ViewContext.build_strategy).to be_a ViewContext::BuildStrategy::Full + end + + it "memoizes" do + expect(ViewContext.build_strategy).to be ViewContext.build_strategy + end + end + + describe ".test_strategy" do + protect_module ViewContext + + context "with :fast" do + it "creates a fast strategy" do + ViewContext.test_strategy :fast + expect(ViewContext.build_strategy).to be_a ViewContext::BuildStrategy::Fast + end + + it "passes a block to the strategy" do + ViewContext::BuildStrategy::Fast.stub(:new).and_return{|&block| block.call} + + expect(ViewContext.test_strategy(:fast){:passed}).to be :passed + end + end + + context "with :full" do + it "creates a full strategy" do + ViewContext.test_strategy :full + expect(ViewContext.build_strategy).to be_a ViewContext::BuildStrategy::Full + end + + it "passes a block to the strategy" do + ViewContext::BuildStrategy::Full.stub(:new).and_return{|&block| block.call} + + expect(ViewContext.test_strategy(:full){:passed}).to be :passed + end + end + end + end +end diff --git a/spec/dummy/fast_spec/post_decorator_spec.rb b/spec/dummy/fast_spec/post_decorator_spec.rb new file mode 100644 index 0000000..a38a2d1 --- /dev/null +++ b/spec/dummy/fast_spec/post_decorator_spec.rb @@ -0,0 +1,38 @@ +require 'draper' +require 'rspec' + +require 'active_model/naming' +require_relative '../app/decorators/post_decorator' + +Draper::ViewContext.test_strategy :fast + +Post = Struct.new(:id) { extend ActiveModel::Naming } + +describe PostDecorator do + let(:decorator) { PostDecorator.new(source) } + let(:source) { Post.new(42) } + + it "can use built-in helpers" do + expect(decorator.truncated).to eq "Once upon a..." + end + + it "can use built-in private helpers" do + expect(decorator.html_escaped).to eq "<script>danger</script>" + end + + it "can't use user-defined helpers from app/helpers" do + expect{decorator.hello_world}.to raise_error NoMethodError, /hello_world/ + end + + it "can't use path helpers" do + expect{decorator.path_with_model}.to raise_error NoMethodError, /post_path/ + end + + it "can't use url helpers" do + expect{decorator.url_with_model}.to raise_error NoMethodError, /post_url/ + end + + it "can't be passed implicitly to url_for" do + expect{decorator.link}.to raise_error + end +end diff --git a/spec/dummy/lib/tasks/test.rake b/spec/dummy/lib/tasks/test.rake index 8e2a9d4..fc5a4fb 100644 --- a/spec/dummy/lib/tasks/test.rake +++ b/spec/dummy/lib/tasks/test.rake @@ -3,8 +3,12 @@ require 'rake/testtask' RSpec::Core::RakeTask.new :rspec +RSpec::Core::RakeTask.new :fast_spec do |t| + t.pattern = "fast_spec/**/*_spec.rb" +end + Rake::TestTask.new :mini_test do |t| t.test_files = ["mini_test/mini_test_integration_test.rb"] end -task :default => [:rspec, :mini_test] +task :default => [:rspec, :mini_test, :fast_spec] diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9a550a7..f485a3e 100755 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,7 @@ require 'bundler/setup' require 'draper' +require 'action_controller' +require 'action_controller/test_case' RSpec.configure do |config| config.treat_symbols_as_metadata_keys_with_true_values = true @@ -28,3 +30,7 @@ end def protect_class(klass) before { stub_const klass.name, Class.new(klass) } end + +def protect_module(mod) + before { stub_const mod.name, mod.dup } +end