Racking my brains

I'm using Cucumber and Capybara for integration testing on a web application that depends heavily on the use of subdomains. Since some features rely on client-side Javascript, some scenarios use the Selenium 2 (WebDriver) driver, while other scenarios use the regular rack-test driver.

The Selenium driver does a great job of handling subdomains, and switching subdomains in the middle of a scenario. Setting Capybara.app_host is all that's needed, with a couple of caveats:

  • You include the port number of your Capybara server
  • Your /etc/hosts has the relevant subdomains defined

Since I'm using Capybara 0.4.0.rc, I've specified which port to use in features/support/custom_env.rb (you could also put this in features/support/env.rb, but since that's overwritten when you upgrade, it's best to keep your customizations separate):

# custom_env.rb
 
Capybara.run_server = true
Capybara.server_port = 9887

Then, when I need to switch subdomains in a step definition:

# features/step_definitions/general_steps.rb
 
DOMAIN = "example.com"
PORT = Capybara.server_port
 
Given /^I am in subdomain "(.+)"$/ do |subdomain|
  Capybara.app_host = "#{subdomain}.#{DOMAIN}:#{PORT}"
end

This works well enough if all your scenarios are tagged @selenium, but what happens if you try to switch subdomains using the default rack-test driver? It doesn't work, because the rack-test driver that comes with Capybara doesn't look at app_host, only default_host. And what's worse, it only uses that when the first rack-test mock session is created:

# lib/capybara/driver/rack_test_driver.rb
 
def build_rack_mock_session
  @mock_session = Rack::MockSession.new(app,
        Capybara.default_host || "www.example.com")
end

Any on-the-fly changes to Capybara.default_host will have no effect, since the session has already been created. The net effect is that your first scenario might use the correct subdomain, then you're stuck in that subdomain--and since rack-test runs rather silently in an invisible mock browser, the only symptom of this may be strange errors about nil objects and missing page content. Some scenarios might run fine in isolation (if they only use one subdomain), but then blow up when you try to run them as part of a larger batch (two scenarios, each using a different subdomain).

After failing at a bunch of different workarounds, I was ready to give up and just stick @selenium tags on everything (effectively tripling Cucumber's execution time). Just as I was preparing to leap from the precipice, a co-worker pointed me towards Tristan Dunn's article Multiple Sessions in Cucumber & Selenium. This technique was designed to allow multiple Selenium browser instances to be in different subdomains, by creating separate sessions for each one. Since my problem with rack-test was directly related to being unable to create a new session for each subdomain, this proved to be an excellent starting point.

I mainly wanted the ability to switch sessions whenever the subdomain changed, so I factored out a new method switch_session:

# features/support/session.rb
 
module Capybara
  module Driver
    module Sessions
      def set_session(id)
        Capybara.instance_variable_set('@session_pool', {
         "#{Capybara.current_driver}#{Capybara.app.object_id}" => $sessions[id]
        })
      end
 
      def switch_session(id)
        $sessions ||= {}
        $sessions[:default] ||= Capybara.current_session
        $sessions[id]       ||= Capybara::Session.new(Capybara.current_driver, Capybara.app)
        set_session(id)
      end
 
      def in_session(id, &block)
        switch_session(id)
        yield
        set_session(:default)
      end
    end
  end
end
 
World(Capybara::Driver::Sessions)

Now, changes to Capybara.default_host will be picked up whenever a new subdomain is used for the first time. When I switch to subdomain foo, then $sessions['foo'] stores a new Capybara::Session. If I switch to a different subdomain, then back to foo later on, it'll just use the same foo session created earlier.

And now my subdomain-switching step looks like this:

# features/step_definitions/general_steps.rb
 
Given /^I am in subdomain "(.+)"$/ do |subdomain|
  if Capybara.current_driver == :selenium
    Capybara.app_host = "#{subdomain}.#{DOMAIN}:#{PORT}"
  else
    Capybara.default_host = "#{subdomain}.#{DOMAIN}"
    switch_session(subdomain)
    visit("http://#{subdomain}.#{DOMAIN}")
  end
end

At first, this worked well, except my Selenium scenarios kept opening new browser windows. Since I don't really need multiple-session support in my Selenium tests at this point, I solved this by using a single session for all Selenium-driven tests:

# features/support/custom_env.rb
 
Before('@selenium') do
  switch_session('selenium')
end

Now, my Selenium and rack-test scenarios can coexist, with reliable subdomain behavior. Success!