In a few previous projects that I was doing recently, I decided to
write acceptance tests in a little bit more object oriented
manner. The idea comes from a
BBQ gem, implemented by guys from
DRUG. The gem seems to be a really good
solution for most of the cases. It supports RSpec and Test::Unit,
provides helpers, generators and some more useful stuff. However, it
turns out that the whole idea is pretty easy to implement on your own,
if you cannot use the gem for some reason (i.e. in an unsupported
testing framework).
The first thing that you need to do, is to implement your testing
personas. Instead of controlling a global browser instance, we would
like it to be bound to a particular user object. It would be nice if
such an object behaves pretty much like our User model, but have
some additional abilities at the same time. This behaviour can be
easily achieved using standard Ruby SimpleDelegator class.
class Test::User < SimpleDelegator
  attr_reader :session, :model
  def initialize(user)
    super
    @model = user
    @session = Capybara::Session.new(:poltergeist, Rails.application)
  end
end
From now on we can use user’s attributes and associations just like we
did before (user.email, user.orders), but we also have user
browser session available, and bound to the object. It would be nice
to have all Capybara’s DSL methods available on the Test::User
object as well. Unfortunately Capybara does not provide a single
module that we could include in our class, so we need to iterate
through DSL_METHODS array, just like
Capybara does.
class Test::User
  Capybara::Session::DSL_METHODS.each do |method|
    define_method method do |*args, &block|
      session.send method, *args, &block
    end
  end
end
Now we are able to drive the browser directly through the user object,
i.e. user.visit("/"), user.click_on("Sign in"). It would also be
handy to have URL helpers and FactoryGirl methods available in the
object.
class Test::User
  include Rails.application.routes.url_helpers
  include FactoryGirl::Syntax::Methods
end
We can also define our own helper methods.
class Test::User
  def see?(*args)
    has_content?(*args)
  end
  def emails
    ActionMailer::Base.deliveries.select { |mail| mail.to.include?(email) }
  end
end
And inherit a generic User class creating more specific classes with
higher level, role specific methods.
class Test::Client < Test::User
  def initialize(user = FactoryGirl.create(:client))
    super
  end
  def sign_in
    visit(root_path)
    click_on("Sign In")
    fill_in("Email", with: email)
    fill_in("Password", with: password)
    click_on("Sign In")
  end
end
This approach allows you to write more readable, object oriented tests. It is much easier to test interactions between users, since you can create multiple browser sessions within a single test.
class GatekeeperAcceptanceTest < ActionDispatch::IntegrationTest
  test "receives an email with a reservation request" do
    agent = Test::Gatekeeper.new
    gatekeeper.sign_in
    gatekeeper.go_on_duty
    assert_difference("gatekeeper.emails.size") do
      client = Test::Client.new
      client.sign_in
      client.request_booking(gatekeeper.restaurant.name)
      assert client.see?("Thank you")
    end
  end
end
One more thing needs to be mentioned before you start using the above
approach in your tests. The example uses
Poltergeist driver,
and it creates a new browser instance every time you call
Test::User.new in your tests. Each browser created in such a way is
a separate process, that stays in the memory until the whole test
suite is finished. It means that you will quickly run out of memory,
causing your tests crash or slowing them down significantly. I suggest
creating a simple SeessionPool mechanism to reduce memory footprint
of your test suite.
module Test::SessionPool
  mattr_accessor :idle, :taken
  self.idle = []
  self.taken = []
  def self.release
    idle.concat(taken)
    taken.clear
  end
  def self.take
    (idle.pop || Capybara::Session.new(:poltergeist, Rails.application)).tap do |session|
      session.reset!
      taken.push(session)
    end
  end
end
Every time when you instantiate a new user, it is better to take a session from the pool instead of creating a new one.
class Test::User < SimpleDelegator
  def initialize(user)
    super
    @model = user
    @session = Test::SessionPool.take
  end
end
Do not forget to release taken sessions after your test finishes.
class ActionDispatch::IntegrationTest
  teardown do
    Test::SessionPool.release
  end
end