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