--- title: "Building a plugin on RSpec" created_at: 2005-11-17 18:00:00 blog_post: true tags: - plugins - spec-testing id: 19 filter: - erb - textile ---
This article is obsolete because it uses features that were replaced many times over. If you want an HABTM selector widget, look at "MultipleSelect":http://ruido-blanco.net/blog/rails-multiple-select-helper-plugin/ by Daniel Rodríguez Troitiño.
View of a has and belongs to many manager in action - selected objects on the left, available objects on the right, links in the middle to move objects from one list to the other"Has and belongs to many":http://api.rubyonrails.com/classes/ActiveRecord/Associations/ClassMethods.html#M000467 associations are great: they allow you to join two tables in a "M:N relationship":http://publib.boulder.ibm.com/infocenter/ids9help/topic/com.ibm.ddi.doc/ddi50.htm. Most of the time, the relationships are managed using two lists side by side, with some kind of user interface element to transfer items from the available to the selected list, and vice-versa. Rails has no built-in helpers to manage them. A quick description of how the plugin works, and then on to how I wrote it using RSpec: an HTTP request comes in, we inspect the params Hash to convert anything that's a list of IDs into an Array of IDs (to pass to #collection_singular_ids). On the other side, we have a helper that generates two select elements, with anchor elements as the user interface to move items from one list to the other. Since this article is about writing specifications rather than on how to write a Rails plugin, let's start specifying ! h2. Installation and setup I started by installing the gem. Then, I created a Rails-like hierarchy: lib/, test/ and vendor/. I then unpacked the gem into vendor: <% code(:lang => "shell") do -%>$ cd vendor $ gem unpack rspec Unpacked gem: 'rspec-0.2.0' <% end -%> That gave me the library's full source code right next to mine, for easy inspection and analysis. Next, I needed a way to run my _expectations_ (we'd usually call these tests, but since we're trying to move away from the testing terminology...). I wrote the following Rakefile: <% code("Rakefile") do -%>require 'rake' task :default => [:test_behaviors] desc "Updates the load path to include all available vendor libraries as well as the code under test" task :set_load_path do Dir['vendor/*'].each do |dir| next unless File.directory?(dir) lib_folder = File.join(dir, 'lib') next unless File.directory?(lib_folder) $LOAD_PATH.unshift lib_folder end $LOAD_PATH.unshift 'lib' $LOAD_PATH.unshift 'test' Dir['test/**/*.rb'].each {|file| require file } end desc "Runs behavioral tests" task :test_behaviors => [:set_load_path] do require 'spec' require 'spec/text_runner' Spec::TextRunner.new.run end <% end -%> h2. First behavioral expectation The first expectation I wanted to write was to convert an empty String into an empty Array. That lets us handle the case where no items are selected. I chose to write this expectation first because we're going to need it anyway, and it was a good introduction to the RSpec framework for me. <% code("test/specs/no_selected_entries.rb") do -%>require 'spec' require 'francois_beausoleil/habtm_helper_plugin/filter' class NoSelectedEntries < Spec::Context include FrancoisBeausoleil::HabtmHelperPlugin::Filter attr_reader :params def setup @params = {:contact => {:group_ids => ''}} habtm_filter end def should_replace_string_with_empty_array params[:contact][:group_ids].should_equal [] end end <% end -%> We start by requiring spec, and then the filter's code. Next, we create a new class that describes the context of this particular set of expectations. This is the expectation's fixture, so to speak. Notice we extend Spec::Context (short for Specification Context) instead of Test::Unit::TestCase. Then, we make our lives easier by including the code under scrutiny in the context. Then, we have the setup method. Notice I am executing the filter directly in the setup method. This is the most striking difference I found between Test::Unit and RSpec. All behavioral specifications I wrote to date have used this pattern. The last item in the class is the actual specification. We are asserting that when "No selected entries" we "should replace string with empty array". Let's write the minimum amount of code, just to get started: <% code("lib/francois_beausoleil/habtm_helper_plugin/filter.rb") do -%>module FrancoisBeausoleil module HabtmHelperPlugin module Filter def habtm_filter end end end end <% end -%> Let's see that in action: <% code(:lang => "shell") do -%>$ rake (in D:/habtm) .X. 1) <"":String> should be equal to: <[]:Array> (Spec::Exceptions::ExpectationNotMetError) ./test/specs/no_selected_entries.rb:14:in `should_replace_string_with_empty_array' Finished in 0.016 seconds 3 specifications, 3 expectations, 1 failures <% end -%> _(I removed the Rake backtrace from this and all other example runs in the interest of shortening an already long article)_ What is this telling us ? It's telling us that an empty string should have been equal to an empty array. This is a direct translation of the specification we wrote. h3. Naming conventions Reading further though, we see that Spec::TextRunner has found *three* expectations to run, not just one. Which ones are they ? If we look at the Spec::Context code, we'll find this: <% code("vendor/rspec-0.2.0/lib/spec/context.rb") do -%>module Spec class Context private def self.my_methods self.instance_methods - self.superclass.instance_methods end def self.specifications return self.my_methods.select {|spec| self.specification_name?(spec)} end def self.specification_name?(name) return false unless self.new.method(name).arity == 0 return false if name[0..0] == '_' true end end end <% end -%> Ah ! There are *no* naming convention as in the xUnit case, so all methods are expectations, except the ones coming from our superclasses. That only means we have to move our code elsewhere. After refactoring, we now have: <% code("test/spec_helper.rb") do -%>require 'spec' require 'francois_beausoleil/habtm_helper_plugin/filter' class DummyController attr_accessor :params include FrancoisBeausoleil::HabtmHelperPlugin::Filter end <% end -%> <% code("test/specs/no_selected_entries.rb") do -%>require 'spec_helper' class NoSelectedEntries < Spec::Context def setup @controller = DummyController.new @controller.params = {:contact => {:group_ids => ''}} @controller.habtm_filter end def should_replace_string_with_empty_array @controller.params[:contact][:group_ids].should_equal [] end end <% end -%> Running that, we get: <% code(:lang => "shell") do -%>$ rake (in D:/habtm) X 1) <"":String> should be equal to: <[]:Array> (Spec::Exceptions::ExpectationNotMetError) ./test/specs/no_selected_entries.rb:11:in `should_replace_string_with_empty_array' Finished in 0.015 seconds 1 specifications, 1 expectations, 1 failures <% end -%> Beautiful ! Only one specification was executed - let's make it pass: <% code("lib/francois_beausoleil/habtm_helper_plugin/filter.rb") do -%>module FrancoisBeausoleil module HabtmHelperPlugin module Filter def habtm_filter params[:contact][:group_ids] = [] end end end end <% end -%> Running: <% code(:lang => "shell") do -%> $ rake (in D:/habtm) . Finished in 0.0 seconds 1 specifications, 1 expectations, 0 failures <% end -%> Even more beautiful: green bar ! h2. More expectations Let's implement a second behavioral specification, but we'll need a new context - we need to have something for the filter to work against. This means we have to create a new context for the expectations to execute in: <% code("test/specs/one_selected_entry.rb") do -%>require 'spec_helper' class OneSelectedEntry < Spec::Context def setup @controller = DummyController.new @controller.params = {:contact => {:group_ids => '624'}} @controller.habtm_filter end def should_have_one_element_in_the_array @controller.params[:contact][:group_ids].size.should_equal 1 end def should_have_the_id_as_the_first_element @controller.params[:contact][:group_ids][0].should_equal '624' end end <% end -%> " alt="Refactoring.com website logo"/>The setup method is very similar - in fact, it's nearly identical, the only difference being the value we assign to the params Hash. Probably something we can "extract superclass":http://www.refactoring.com/catalog/extractSuperclass.html from. Let's wait for the fabled "three strikes and you refactor":http://c2.com/cgi/wiki?ThreeStrikesAndYouRefactor before we do so, though. Next, we get our behavioral specifications. The first one says when we have "One selected entry" we "should have one element in the array". This is all very readable and reasonable. Then, when we have "One selected entry" we "should have the id as the first element". Let's see this in action: <% code(:lang => "shell") do -%>$ rake (in D:/habtm) XX. 1) should be equal to: <"624":String> (Spec::Exceptions::ExpectationNotMetError) ./test/specs/one_selected_entry.rb:15:in `should_have_the_id_as_the_first_element' 2) <0:Fixnum> should be equal to: <1:Fixnum> (Spec::Exceptions::ExpectationNotMetError) ./test/specs/one_selected_entry.rb:11:in `should_have_one_element_in_the_array' Finished in 0.0 seconds 3 specifications, 3 expectations, 2 failures <% end -%> We get our two expected failures. Let's "do the simplest thing that could possibly work":http://c2.com/cgi/wiki?DoTheSimplestThingThatCouldPossiblyWork: <% code("lib/francois_beausoleil/habtm_helper_plugin/filter.rb") do -%>module FrancoisBeausoleil module HabtmHelperPlugin module Filter def habtm_filter if params[:contact][:group_ids].empty? then params[:contact][:group_ids] = [] else params[:contact][:group_ids] = [params[:contact][:group_ids]] end end end end end <% end -%> Seeing it in action: <% code(:lang => "shell") do -%>$ rake (in D:/habtm) ... Finished in 0.016 seconds 3 specifications, 3 expectations, 0 failures <% end -%> Excellent, another green bar. I will leave it as an exercise to the reader to write additional specifications, or else visit the "habtm_helper Rails plugin":https://opensvn.csie.org/traccgi/habtm_helper_plugin/trac.cgi and acquire the plugin through regular means. h2. Parting thoughts I liked my first exposure to behavioral specifications. Of course, anything I had already learned for doing Test Driven Development I could immediately apply to Behavior Driven Development. This is mostly a semantical change, but it does have an impact on how one thinks. I also liked the documentational aspect of behavioral expectations. Of course, the same thing is possible using testing code - we simply have write appropriate test case names and tests, and with a bit of regular expression magic, parse away the unnecessary bits. However, using "RSpec":http://rspec.rubyforge.org/, it felt more natural to write longer, more descriptive, expectation names. After having coded and refactored some more, I have the following specifications for the filter:
When No Selected Entries
  • should replace string with empty array
When One Selected Entry
  • should replace string with single element array
  • should have entry id in list
When Two Selected Entries
  • should replace string with two elements array
  • should include first selected id
  • should include second selected id
When Two Selected Entries With Whitespace
  • should replace string with two elements array
  • should include first selected id sans whitespace
  • should include second selected id sans whitespace
When Extra Entries In Params
  • should leave non matched suffixes alone
  • should process matching entries as usual
When One Entry With Non Standard Name
  • should replace string with single element array
  • should have entry id in list
When Processable Entry In Params Root
  • should be processed like deeper params
h2. What have we learned here today ? "RSpec":http://rspec.rubyforge.com/ is a different framework. It just takes a bit of time getting used to. Besides, the version describe here is "0.2.0":http://rubyforge.org/frs/?group_id=797, hardly enough to say it's finished growing yet. On the other hand, I feel this is easier to work with than Test::Unit, and I will start pushing to get this added to Railsties. It would make a nice addition to the already existing testing framework. Some things to remember: * setup is used just like in the testing world, except I often saw setup calling the code we are asserting against; * Don't put your helper methods directly in the context class; * You'll probably call the code under specification in your setup method; * Anything you learned while doing test driven development is applicable to behavior driven development. Until later !