O'Reilly    
 Published on O'Reilly (http://oreilly.com/)
 See this if you're having trouble printing code examples


Rails Plugins: Chapter 3 - Advanced Rails

by Brad Ediger

Civilization advances by extending the number of important operations which we can perform without thinking of them.

—Alfred North Whitehead

Advanced Rails book cover

This excerpt is from Advanced Rails . This is the book for experienced Rails developers who want to go to the next level with this web development framework, with an in-depth look at techniques for dealing with databases, security, performance, web services and much more. Chapters in this book help you understand not only the tricks and techniques used within the Rails framework itself, but also how make use of ideas borrowed from other programming paradigms.

buy button

Ruby on Rails is very powerful, but it cannot do everything. There are many features that are too experimental, out of scope of the Rails core, or even blatantly contrary to the way Rails was designed (it is opinionated software, after all). The core team cannot and would not include everything that anybody wants in Rails.

Luckily, Rails comes with a very flexible extension system. Rails plugins allow developers to extend or override nearly any part of the Rails framework, and share these modifications with others in an encapsulated and reusable manner.

About Plugins

Plugin Loading

By default, plugins are loaded from directories under vendor/plugins in the Rails application root. Should you need to change or add to these paths, the plugin_paths configuration item contains the plugin load paths:

	config.plugin_paths += [File.join(RAILS_ROOT, 'vendor', 'other_plugins')]

By default, plugins are loaded in alphabetical order; attachment_fu is loaded before http_authentication. If the plugins have dependencies on each other, a manual loading order can be specified with the plugins configuration element:

	config.plugins = %w(prerequisite_plugin actual_plugin)

Any plugins not specified in config.plugins will not be loaded. However, if the last plugin specified is the symbol :all, Rails will load all remaining plugins at that point. Rails accepts either symbols or strings as plugin names here.

	config.plugins = [ :prerequisite_plugin, :actual_plugin, :all ]

The plugin locator searches for plugins under the configured paths, recursively. Because a recursive search is performed, you can organize plugins into directories; for example, vendor/plugins/active_record_acts and vendor/plugins/view_extensions.

The actual plugin locating and loading system is extensible, and you can write your own strategies. The locator (which by default is Rails::Plugin::FileSystemLocator) searches for plugins; the loader (by default Rails::Plugin::Loader) determines whether a directory contains a plugin and does the work of loading it.

To write your own locators and loaders, examine railties/lib/rails/plugin/locator.rb and railties/lib/rails/plugin/loader.rb. The locators (more than one locator can be used) and loader can be changed with configuration directives:

	config.plugin_locators += [MyPluginLocator]
	config.plugin_loader = MyPluginLoader

Installing Rails Plugins

Plugins are most often installed with the built-in Rails plugin tool, script/plugin. This plugin tool has several commands:

discover/source/unsource/sources

The plugin tool uses an ad-hoc method of finding plugins. Rather than requiring you to specify the URL of a plugin repository, script/plugin tries to find it for you. One way it does this is by scraping the "Plugins" page of the Rails wiki [24] for source URLs. This can be triggered with the discover command.

The source and unsource commands add and remove source URLs, respectively. The sources command lists all current source URLs.

install/update/remove

These commands install, update, and uninstall plugins. They can take an HTTP URL, a Subversion URL (svn:// or svn+ssh://), or a bare plugin name, in which case the list of sources is scanned.

script/plugin install takes an option, -x, that directs it to manage plugins as Subversion externals. This has the advantage that the directory is still linked to the external repository. However, it is a bit inflexible—you cannot cherry-pick changes from the upstream repository. We will examine some better options later.

RaPT

RaPT (http://rapt.rubyforge.org/) is a replacement for the standard Rails plugin installer, script/plugin. It can be installed with gem install rapt.

The first advantage that RaPT has is that it can search for plugins from the command line. (The second advantage is that it is extremely fast, because it caches everything.)

The rapt searchcommand looks for plugins matching a specified keyword. To search for plugins that add calendar features to Rails, change to the root directory of a Rails application and execute:

	$ rapt search calendar
	Calendar Helper
	  Info: http://agilewebdevelopment.com/plugins/show/98
	  Install: http://topfunky.net/svn/plugins/calendar_helper
	Calendariffic 0.1.0
	  Info: http://agilewebdevelopment.com/plugins/show/743
	  Install: http://opensvn.csie.org/calendariffic/calendariffic/
	Google Calendar Generator
	  Info: http://agilewebdevelopment.com/plugins/show/277
	  Install: svn://rubyforge.org//var/svn/googlecalendar/plugins/googlecalendar
	dhtml_calendar
	  Info: http://agilewebdevelopment.com/plugins/show/333
	  Install: svn://rubyforge.org//var/svn/dhtmlcalendar/dhtml_calendar
	Bundled Resource
	  Info: http://agilewebdevelopment.com/plugins/show/166
	  Install: svn://syncid.textdriven.com/svn/opensource/bundled_resource/trunk
	DatebocksEngine
	  Info: http://agilewebdevelopment.com/plugins/show/356
	  Install: http://svn.toolbocks.com/plugins/datebocks_engine/       
	datepicker_engine       
	  Info: http://agilewebdevelopment.com/plugins/show/409
	  Install: http://svn.mamatux.dk/rails-engines/datepicker_engine

One of these could then be installed with, for example, rapt install datepicker_engine.

Piston

In Rails, plugins are perhaps the most common use of code supplied by an external vendor (other than the Rails framework itself). This requires some special care where version control is concerned. Managing Rails plugins as Subversion externals has several disadvantages:

  • The remote server must be contacted on each update to determine whether any-thing has changed. This can incur quite a performance penalty if the project has many externals. In addition, it adds an unneeded dependency; problems can ensue if the remote server is down.

  • The project is generally at the mercy of code changes that happen at the remote branch; there is no easy way to cherry-pick or block changes that happen remotely. The only flexibility Subversion affords is the ability to lock to a certain remote revision.

  • Similarly, there is no way to maintain local modifications to a remote branch. Any needed modifications can only be kept in the working copy, where they are unversioned.

  • No history is kept of how external versions relate to the local repository. If you want to update your working copy to last month's version, nobody knows what version the external code was at.

To solve these problems, François Beausoleil created Piston, [25] a program to manage vendor branches in Subversion. Piston imports the remote branch into the local repository, only synchronizing when requested. As a full copy of the code exists inside the project's repository, it can be modified as needed. Any changes made to the local copy will be merged when the project is updated from the remote server.

Piston is available as a gem; install it with sudo gem install --include-dependencies piston.

To install a plugin using Piston, you need to manually find the Subversion URL of the repository. Then, simply import it with Piston, specifying the repository URL and the destination path in your working copy:

	$ piston import http://svn.rubyonrails.org/rails/plugins/deadlock_retry \
	    vendor/plugins/deadlock_retry
	Exported r7144 from 'http://svn.rubyonrails.org/rails/plugins/deadlock_retry/'
	to 'vendor/plugins/deadlock_retry'

	$ svn ci

The svn ci is necessary because Piston adds the code to your working copy. To Subversion, it is as if you wrote the code yourself—it is versioned alongside the rest of your application. This makes it very simple to patch the vendor branch for local use; simply make modifications to the working copy and check them in.

When the time comes to update the vendor branch, piston update vendor/plugins/ deadlock_retry will fetch all changes from the remote repository and merge them in. Any local modifications will be preserved in the merge. piston update can be called without an argument; in that case, it will recursively update any Piston-controlled directories under the current one.

Piston-controlled directories can be locked to their current version with piston lock and unlocked with piston unlock. And for current svn:externals users, existing directories managed with svn:externals can be converted to Piston all at once with piston convert.

Piston is also good for managing edge Rails, along with any patches you may apply. To import Rails from the edge, with all of the features of Piston:

	$ piston import http://svn.rubyonrails.org/rails/trunk vendor/rails

Decentralized version control

Piston effectively creates one layer between a remote repository and the working copy. Decentralized version control systems take this model to its logical conclusion: every working copy is a repository, equally able to share changes with other repositories. This can be a much more flexible model than normal centralized version control systems. We examine decentralized version control systems in more detail in Chapter 10, Large Projects.

Plugins and other vendor code can be managed very well with a decentralized version control system. These systems afford much more flexibility, especially in complicated situations with multiple developers and vendors.

A tool is available, hgsvn,[26] which will migrate changes from a SVN repository to a Mercurial repository. This can be used to set up a system similar to Piston, but with much more flexibility. One repository (the "upstream" or "incoming") can mirror the remote repository, and other projects can cherry-pick desired patches from the upstream and ignore undesired ones. Local modifications suitable for the upstream can be exported to patches and sent to the project maintainer.

Writing Plugins

Once you know how to extend Rails by opening classes, it is easy to write a plugin. First, let's look at the directory structure of a typical plugin (see Figure 3.1, “Directory structure of a typical plugin”).

Figure 3.1. Directory structure of a typical plugin

Directory structure of a typical plugin

There are several files and directories involved in a Rails plugin:

about.yml (not shown)

This is the newest feature of Rails plugins—embedded metadata. Right now, this feature works only with RaPT. The command rapt about plugin_name will give a summary of the plugin's information. In the future, more features are expected; right now, it exists for informational purposes. Metadata is stored in the about.yml file; here is an example from acts_as_attachment:

	author: technoweenie 
	summary: File upload handling plugin.
	homepage: http://technoweenie.stikipad.com
	plugin: http://svn.techno-weenie.net/projects/plugins/acts_as_attachment
	license: MIT
	version: 0.3a
	rails_version: 1.1.2+
init.rb

This is a Ruby file run upon initialization of the plugin. Typically, it will require files from the lib/ directory. As many plugins patch core functionality, init.rb may extend core classes with extensions from the plugin:

	require 'my_plugin'
	ActionController::Base.send :include, MyPlugin::ControllerExtensions

The send hack is needed here because Module#include is a private method and, at least for now, send bypasses access control on the receiver. [27]

install.rb (not shown)

This hook is run when the plugin is installed with one of the automated plugin installation tools such as script/plugin or RaPT. It is a good idea not to do any-thing mission-critical in this file, as it will not be run if the plugin is installed manually (by checking out the source to a directory under vendor/plugins).

A typical use for the install.rb hook is to display the contents of the plugin's README file:

	puts IO.read(File.join(File.dirname(__FILE__), 'README'))
lib/

This is the directory in which all of the plugin code is contained. Rails adds this directory to the Ruby load path as well as the Rails Dependencies load path.

For example, assume you have a class, MyPlugin, in lib/my_plugin.rb. Since it is in the Ruby load path, a simple require 'my_plugin' will find it. But since Dependencies auto loads missing constants, you could also load the file simply by referring to MyPlugin in your plugin.

MIT-LICENSE (or other license file; not shown)

All plugins, no matter how small, should include a license. Failure to include a license can prevent people from using your software—no matter how insignificant the plugin may be, it is against the law for someone else to distribute your code without your permission.

For most projects, the MIT license (under which Rails itself is released) is sufficient. Under that license, anyone can redistribute your software freely, provided that they include a copy of the license (preserving your copyright notice). Including the MIT-LICENSE file in the plugin is important in this case, as it makes compliance automatic.

Rakefile

This is the core Rake task definition file for the plugin. Usually it is used to launch tests for the plugin itself or package the plugin for distribution.

README

It is helpful to provide a short explanation here of the plugin's purpose, usage, and any special instructions. A hook can be included in install.rb (described earlier) to print this file upon plugin installation.

test/

This folder contains the plugin's tests. These tests are run using Rake, without loading Rails. Any tests written in this folder must stand alone—they must either mock out any required Rails functionality or actually load the Rails framework. We will explore both of these options later.

uninstall.rb (not shown)

This is the uninstall hook, run when a plugin is removed by tools such as script/plugin or RaPT. Unless you have a very pressing need, the use of this file is discouraged. Like install.rb, uninstall.rb is not always used—many people simply delete the plugin directory without thought.

Of course, you should feel free to add any folders required by your plugin. Use File.dirname(__FILE__) in init.rb to refer to your plugin's directory as installed. None of these files are specifically required; for example, a simple plugin may do all of its work in init.rb.

You can generate a plugin skeleton with a built-in Rails generator:

	$ script/generate plugin my_plugin

This generates a skeleton in vendor/plugins/my_plugin with sample files, a fill-in-the-blanks MIT license, and instructions.

Plugin Examples

To illustrate the flexibility and design of a typical Rails plugin, we will examine some of the plugins available from the rubyonrails.org Subversion repository. Most of these plugins are used fairly commonly; many of them are used in 37signals applications. Consider them the "standard library" of Rails. They are all available from http://svn.rubyonrails.org/rails/plugins

Account Location

Plugins can be very simple in structure. For example, consider David Heinemeier Hansson's account_location plugin. This plugin provides controller and helper methods to support using part of the domain name as an account name (for example, to support customers with domain names of customer1.example.com and customer2.example.com, using customer1 and customer2 as keys to look up the account information). To use the plugin, include AccountLocation in one or more of your controllers, which adds the appropriate instance methods:

	class ApplicationController < ActionController::Base
	  include AccountLocation
	end

	puts ApplicationController.instance_methods.grep /^account/
	=> ["account_domain", "account_subdomain", "account_host", "account_url"]

Including the AccountLocation module in the controller allows you to access various URL options from the controller and the view. For example, to set the @account variable from the subdomain on each request:

	class ApplicationController < ActionController::Base
	  include AccountLocation
	  before_filter :find_account

	  protected

	  def find_account
	    @account = Account.find_by_username(account_subdomain)
	  end
	end

The account_location plugin has no init.rb; nothing needs to be set up on load, as all functionality is encapsulated in the AccountLocation module. Here is the implementation, in lib/account_location.rb (minus some license text):

	module AccountLocation
	  def self.included(controller)
	    controller.helper_method(:account_domain, :account_subdomain,
	      :account_host, :account_url)
	  end

	  protected

	  def default_account_subdomain
	    @account.username if @account && @account.respond_to?(:username)
	  end

	  def account_url(account_subdomain = default_account_subdomain,
	      use_ssl = request.ssl?)
	    (use_ssl ? "https://" : "http://") + account_host(account_subdomain)
	  end

	  def account_host(account_subdomain = default_account_subdomain)
	    account_host = ""
	    account_host << account_subdomain + "."
	    account_host << account_domain
	  end

	  def account_domain
	    account_domain = ""
	    account_domain << request.subdomains[1..-1].join(".") +
	      "." if request.subdomains.size > 1
	    account_domain << request.domain + request.port_string
	  end

	  def account_subdomain
	    request.subdomains.first
	  end
	end

The self.included method is a standard idiom for plugins; it is triggered after the module is included in a class. In this case, that method marks the included instance methods as Rails helper methods, so they can be used from a view.

Finally, remember that Dependencies.load_paths contains the lib directories of all loaded plugins, so the act of mentioning AccountLocation searches for account_ location.rb among those lib directories. Because of this, you do not need to require anything in order to use the plugin—just drop the code into vendor/plugins.

SSL Requirement

The ssl_requirement plugin allows you to specify certain actions that must be protected by SSL. This plugin conceptually divides actions into three categories:

SSL Required

All requests to this action must be protected by SSL. If this action is requested without SSL, it will be redirected to use SSL. Actions of this type are specified with the ssl_required class method.

SSL Allowed

SSL is allowed on this action but not required. Actions of this type are specified by the ssl_allowed class method.

SSL Prohibited

SSL is not allowed for this action. If an action is not marked with ssl_required or ssl_allowed, SSL requests to that action will be redirected to the non-SSL URL.

In typical Rails fashion, the methods that specify SSL requirements are a declarative language. They specify what the requirements are, not how to enforce them. This means that the code reads very cleanly:

	class OrderController < ApplicationController
	  ssl_required :checkout, :payment
	  ssl_allowed :cart
	end

Like the account_location plugin, the ssl_requirement plugin is enabled by including a module. The SslRequirement module contains the entire SSL requirement logic:

	module SslRequirement
	  def self.included(controller)
	    controller.extend(ClassMethods)
	    controller.before_filter(:ensure_proper_protocol)
	  end

	  module ClassMethods
	    def ssl_required(*actions)

	      write_inheritable_array(:ssl_required_actions, actions)
	    end

	    def ssl_allowed(*actions)
	      write_inheritable_array(:ssl_allowed_actions, actions)
	    end
	  end

	  protected

	  def ssl_required?
	    (self.class.read_inheritable_attribute(:ssl_required_actions) || []).
	      include?(action_name.to_sym)
	  end

	  def ssl_allowed?
	    (self.class.read_inheritable_attribute(:ssl_allowed_actions) || []).
	      include?(action_name.to_sym)
	  end

	  private

	  def ensure_proper_protocol
	    return true if ssl_allowed?

	    if ssl_required? && !request.ssl?
	      redirect_to "https://" + request.host + request.request_uri
	      return false
	    elsif request.ssl? && !ssl_required?
	      redirect_to "http://" + request.host + request.request_uri
	      return false
	    end
	  end
	end

Again, the SslRequirement.included method is triggered when SslRequirement is included in a controller class. The included method does two things here. First, it extends the controller with the SslRequirement::ClassMethods module, to include the ssl_required and ssl_allowed class methods. This is a common Ruby idiom for adding class methods, and it is required because module methods of an included module do not become class methods of the including class. (In other words, ssl_required and ssl_allowed could not be module methods of SslRequirement, because they would not be added as class methods of the controller class.)

The second thing that SslRequirement.included does is to set up a before_filter on the controller to enforce the SSL requirement. This filter redirects to the proper http:// or https:// URL, depending on the logic declared by the class methods.

HTTP Authentication

The final plugin we will examine is the http_authentication plugin, which allows you to protect certain actions in an application by HTTP Basic authentication (currently, Digest authentication is stubbed out but not implemented).

The HTTP Authentication plugin is very straightforward; the most common interface is the ActionController class method authenticate_or_request_with_http_basic, typically used in a before_filter on protected actions. That method takes as parameters an authentication realm and a login procedure block that verifies the given credentials. If the login procedure returns true, the action is allowed to continue. If the login procedure returns false, the action is blocked and an HTTP 401 Unauthorized status code is sent, with instructions on how to authenticate (a WWW-Authenticate header). In that case, the browser will typically present the user with a login and password and allow three tries before displaying an "Unauthorized" page.

The following is a typical use of the HTTP Authentication plugin:

	class PrivateController < ApplicationController
	  before_filter :authenticate

	  def secret
	    render :text => "Password correct!"
	  end
 
	  protected

	  def authenticate
	    authenticate_or_request_with_http_basic do |username, password|
	      username == "bob" && password == "secret"
	    end
	  end
	end

Notice that, unlike the two plugins described earlier, here we did not have to include anything in the PrivateController—the authenticate_or_request_with_http_basic method was already provided for us. This is because the plugin added some methods to ActionController::Base (of which ApplicationController is a subclass).

One way to include methods like this is direct monkeypatching. The plugin could have directly written the methods into ActionController::Base:

	class ActionController::Base 
	  def authenticate_or_request_with_http_basic(realm = "Application", 
	                                              &login_procedure) 
	    authenticate_with_http_basic(&login_procedure) || 
	      request_http_basic_authentication(realm) 
	end


	  def authenticate_with_http_basic(&login_procedure)
	    HttpAuthentication::Basic.authenticate(self, &login_procedure)
	  end

	  def request_http_basic_authentication(realm = "Application")
	    HttpAuthentication::Basic.authentication_request(self, realm)
	  end
	end

This works for small plugins, but it can get clunky. The better solution, chosen by this plugin, is to first create a module named for the plugin (sometimes including the developer's name or company to reduce the chance of namespace collisions). Here is the abridged code for the HTTP Authentication plugin's class methods:

	module HttpAuthentication
	  module Basic
	    extend self
	    module ControllerMethods
	      def authenticate_or_request_with_http_basic(realm = "Application", 
	                                                  &login_procedure) 
	       authenticate_with_http_basic(&login_procedure) || 
	         request_http_basic_authentication(realm) 
	      end

	      def authenticate_with_http_basic(&login_procedure)
	        HttpAuthentication::Basic.authenticate(self, &login_procedure) 
	      end

	      def request_http_basic_authentication(realm = "Application")
	        HttpAuthentication::Basic.authentication_request(self, realm)
	      end
	    end
	  end
	end

Now, the methods are self-contained within HttpAuthentication::Basic:: ControllerMethods. A simple statement in the plugin's init.rb file adds the methods to ActionController::Base:

	ActionController::Base.send :include,
	  HttpAuthentication::Basic::ControllerMethods

Testing Plugins

Like the rest of Rails, plugins have very mature testing facilities. However, plugin tests usually require a bit more work than standard Rails tests, as the tests are designed to be run on their own, outside of the Rails framework. Some things to keep in mind when writing tests for plugins:

  • Unlike in the Rails plugin initializer, when running tests, load paths are not set up automatically, and Dependencies does not load missing constants for you. You need to manually set up the load paths and require any parts of the plugin that you will be testing, as in this example from the HTTP Authentication plugin:

    	$LOAD_PATH << File.dirname(__FILE__) + '/../lib/'
    	require 'http_authentication'
  • Similarly, the plugin's init.rb file is not loaded, so you must set up anything your tests need, such as including your plugin's modules in the TestCase class:

    	class HttpBasicAuthenticationTest < Test::Unit::TestCase
    	  include HttpAuthentication::Basic
    
    	  # …
    
    	end
  • You must usually recreate (mock or stub) any Rails functionality involved in your test. In the case of the HTTP Authentication plugin, it would be too much overhead to load the entire ActionController framework for the tests. The functionality being tested is very simple, and requires very little of ActionController:

    	def test_authentication_request
    	  authentication_request(@controller, "Megaglobalapp")
    	  assert_equal 'Basic realm="Megaglobalapp"',
    	               @controller.headers["WWW-Authenticate"] 
    	  assert_equal :unauthorized, @controller.renders.first[:status] 
    	end

    To support this limited subset of ActionController's features, the test's setup method creates a stub controller:

    	def setup
    	  @controller = Class.new do
    	    attr_accessor :headers, :renders
    
    	    def initialize
    	      @headers, @renders = {}, []
    	    end
    
    	    def request
    	      Class.new do
    	        def env
    	          {'HTTP_AUTHORIZATION' => 
    	            HttpAuthentication::Basic.encode_credentials("dhh", "secret") }
    	        end
    	      end.new
    	    end
    
    	    def render(options)
    	      self.renders << options
    	    end
    	  end.new
    	end

    The Class.newdo…end.new syntax creates an instance of an anonymous class with the provided class definition. A more verbose, named, equivalent would be:

    	class MyTestController
    	  # class definition…
    	end
    	@controller = MyTestController.new
  • Sometimes, dependencies are complicated enough so as to require actually loading a full framework. This is the case with the SSL Requirement plugin, which actually loads ActionController and sets up a controller for testing purposes. First, the code loads ActionController (this either requires RUBYOPT="rubygems" and a suitable gem version of ActionController, or setting the ACTIONCONTROLLER_PATH environment variable to a copy of the ActionController source):

    	begin
    	  require 'action_controller'
    	rescue LoadError
    	  if ENV['ACTIONCONTROLLER_PATH'].nil?
    	    abort <<MSG 
    	Please set the ACTIONCONTROLLER_PATH environment variable to the directory
    	containing the action_controller.rb file. 
    	MSG
    	  else
    	    $LOAD_PATH.unshift << ENV['ACTIONCONTROLLER_PATH']
    	    begin
    	      require 'action_controller'
    	    rescue LoadError
    	      abort "ActionController could not be found."
    	    end
    	  end
    	end

    Then, the test code loads ActionController's test_process, which affords access to ActionController::TestRequest and ActionController::TestResponse. After that, logging is silenced and routes are reloaded:

    	require 'action_controller/test_process'
    	require 'test/unit'
    	require "#{File.dirname(__FILE__)}/../lib/ssl_requirement"
    
    	ActionController::Base.logger = nil
    	ActionController::Routing::Routes.reload rescue nil

    Finally come the test controller and test case—these follow much the same format as Rails functional tests, as we have done all of the setup manually.

    	class SslRequirementController < ActionController::Base
    	  include SslRequirement
    
    	  ssl_required :a, :b
    	  ssl_allowed :c
    
    	  # action definitions…
    
    	end
    
    	class SslRequirementTest < Test::Unit::TestCase
    	  def setup 
    	    @controller = SslRequirementController.new 
    	    @request = ActionController::TestRequest.new 
    	    @response = ActionController::TestResponse.new
    	  end
    
    	  # tests…
    	end

    The Deadlock Retry plugin, another standard Rails plugin designed to retry deadlocked database transactions, provides a good example of how to stub out an ActiveRecord model class: [28]

    	class MockModel
    	  def self.transaction(*objects, &block)
    	    block.call
    	  end
    
    	  def self.logger
    	    @logger ||= Logger.new(nil)
    	  end
    
    	  include DeadlockRetry
    	end

    This allows simple features to be tested without introducing a database dependency:

    	def test_error_if_limit_exceeded
    	  assert_raise(ActiveRecord::StatementInvalid) do
    	    MockModel.transaction { raise ActiveRecord::StatementInvalid,
    	                              DEADLOCK_ERROR }
    	  end
    	end

Testing Plugin Database Dependencies

The semantics of some plugins makes them difficult to test without relying on a data-base. But while you would like your tests to run everywhere, you cannot depend on a particular DBMS being installed. Additionally, you want to avoid requiring your users to create a test database in order to test a plugin.

Scott Barron has come up with a clever solution, which he uses in his acts_as_state_ machine plugin [29] (a plugin to assign states to ActiveRecord model objects, such as pending, shipped, and refunded orders). The solution is to allow the user to test with any DBMS, and fall back to SQLite (which is widely installed) if none is chosen.

To make this work, a set of test model objects and corresponding fixtures are included in the plugin's test/fixtures directory. The plugin also includes a database schema backing the models (schema.rb) and some statements in test_helper.rb that load the fixtures into the database. The full test directory structure is shown in Figure 3.2, “Plugin testing directory structure”.

Figure 3.2. Plugin testing directory structure

Plugin testing directory structure

The first piece of the puzzle is the database.yml file, which includes not only configuration blocks for standard DBMSs, but also for SQLite and SQLite3, which save their database in a local file:

	sqlite:
	  :adapter: sqlite
	  :dbfile: state_machine.sqlite.db
	sqlite3:
	  :adapter: sqlite3
	  :dbfile: state_machine.sqlite3.db
	# (postgresql and mysql elided)

The schema files, fixtures, and models are self-explanatory; they are a Ruby schema file, YAML fixtures, and ActiveRecord model classes, respectively. The real magic happens in test_helper.rb, which ties everything together.

The test helper first sets up Rails load paths and loads ActiveRecord. Then it loads database.yml and instructs ActiveRecord to connect to the database (defaulting to SQLite):

	config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
	ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
	ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'sqlite'])

Next, the schema file is loaded into the database:

	load(File.dirname(__FILE__) + "/schema.rb") if 
	  File.exist?(File.dirname(__FILE__) + "/schema.rb")

Finally, the plugin's fixture path is set as TestCase's fixture path and added to the load path so that models in that directory will be recognized:

	Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/"
	$LOAD_PATH.unshift(Test::Unit::TestCase.fixture_path)

Now, the test (acts_as_state_machine_test.rb) can reference ActiveRecord classes and their fixture data just as in a standard Rails unit test.

Further Reading

Geoffrey Grosenbach has a two-part article on Rails plugins, including some information on writing plugins. The two parts are available from the following:

http://nubyonrails.com/articles/the-complete-guide-to-rails-plugins-part-i
http://nubyonrails.com/articles/the-complete-guide-to-rails-plugins-part-ii


[27] In Ruby 1.9, Object#send will not automatically ignore access control on the receiving object, although the new method Object#send! will.

[28] I would call this a stub, not a mock object, though some do not make the distinction. A stub tends to be "dumb" and has no test-related logic—it only serves to reduce external dependencies. A mock is much smarter and has knowledge of the test environment. It may keep track of its own state or know whether it is "valid" with respect to the test cases that interact with it.

If you enjoyed this excerpt, buy a copy of Advanced Rails .

Copyright © 2009 O'Reilly Media, Inc.