Cross-process metaprogramming on the cheap

I will preface my first post of the new decade by saying: this is not by any means elegant. It’s an egregious hack, but it may come in handy for those of you using Culerity for testing your Rails front-end using JavaScript. This is not so much about JavaScript as about dealing with the multitude of processes involved in the testing setup.

For those of you unfamiliar with it, Culerity is a Ruby gem that lets you use Celerity within Cucumber. What’s Celerity? It’s a JRuby wrapper for HtmlUnit, which is a web browser simulator written in Java. HtmlUnit can load web pages and run JavaScript just like a real browser, except it’s “headless”, meaning it has no GUI.

Installing all these tools is a breeze (especially now that JRuby is in the Ubuntu repos) but what’s going on behind the scenes is fairly tricky:

  • Culerity loads your application in development mode in one Ruby process (let’s call this process A).
  • Your Cucumber tests run in another Ruby process (process B).
  • While your tests are running, Culerity starts a third process in JRuby that’s running HtmlUnit and Celerity (process C). All this process can do is make HTTP requests to process A and inspect the results. Culerity provides a bridge such that process B can issue browsing commands to process C, thereby letting you use a full-blown browser simulator to test your app.

Now recently I’ve been working on Acceptance, which is a JavaScript validation API that comes with a Rails plugin that generates client-side validation code from ActiveRecord validations. I want its integration tests to look something like:

  Scenario: Leaving a required checkbox unchecked
    Given the Account class validates acceptance of terms
    When I visit "/accounts/new"
    And I press "Submit"
    Then I should see "Terms must be accepted"

The item I really want to concentrate on is the first step. When your app and your tests are running the same Ruby process, you might be able to implement the step as follows:

Given /^the Account class validates acceptance of (\S+)$/ do |field|
  ::Account = Class.new(ActiveRecord::Base) do
    validates_acceptance_of field
  end
end

But under Culerity, your app and your tests run in separate processes. This means your dynamic creation of the Account class in process B won’t be visible to the application running in process A, so you cannot script the behaviour of the app like this. You’re going to have to do something sneaky.

One thing you can do is “send” code from your tests to your app by storing it in some globally visible resource, such as the filesystem. For more advanced uses you may want to use proper inter-process messaging but for our purposes dumping code in the filesystem will suffice (I told you this was on the cheap). Let’s change our step definition to write the validation code to a file:

# features/step_definitions/validation_steps.rb

require 'fileutils'
require 'find'

VALIDATION_CONFIG = File.dirname(__FILE__) + '/../../config/validation/'
FileUtils.mkdir_p(VALIDATION_CONFIG)

Given /^the Account class validates acceptance of (\S+)$/ do |field|
  File.open(VALIDATION_CONFIG + 'account.rb', 'w') do |f|
    f.write < <-CODE
    class Account
      validates_acceptance_of :#{field}
    end
    CODE
  end
end

The other half of this trick relies on the fact that Culerity runs the app in process A in development mode, so all the models will get reloaded on every request. This means we can put some code in app/models/account.rb to dynamically load the validation code we’ve dumped in the filesystem:

class Account < ActiveRecord::Base
end

file = File.dirname(__FILE__) + '/../../config/validation/account.rb'
load file if File.file?(file)

And to stop state leaking into other tests, we need to clean up after ourselves in our step definitions. We need to remove any validation files that may have been created during each test, so that next time the models are reloaded they won’t pick up any extra validations:

# features/step_definitions/validation_steps.rb

After { Given "I remove all validations" }

Given /^I remove all validations$/ do
  Find.find(VALIDATION_CONFIG) do |path|
    File.delete(path) if File.file?(path)
  end
end

And that’s just about enough setup to let you inject code into a running Rails app while you’re testing it. Maybe one day Rails validations will be modelled in such a way that you can reflect on them and add/remove them at runtime, but for now this will have to do.