Object Oriented Test Design in Cucumber (Part 1)
There lots of advocacy against integration testing out there because over time they become too difficult to maintain and are hard to move with the application. Tests eventually become a burden and teams leave them behind, preferring to go without any sort of safety net to spending time updating capybara calls in 100 tests.
The root of the problem here isn't that integration testing is bad, wasteful or particularly hard, it's that we left all our good ideas about coding in the production codebase when it came time to make our tests.
Spaghetti Definitions
Take this basic step definition for an example
Given(/^I am logged in$/) do
@user = create(:user)
click_link "Sign In"
fill_in "#email", with: @user.email
fill_in "#password", with: @user.password
click_button "Sign In"
end
It works! Then you run bundle update devise
at some point and suddenly this breaks everywhere because they decided "Log In" was less likely to be confused with "Sign Up". Or maybe someone decides that you should be able to sign in with username or email, and the id of the email field changes to "#user_identifier"
or something.
Not a big deal the first few times, but as your app grows in size and complexity, it becomes difficult or impossible to keep your tests up to date with changes. You'll start excluded things from the CI build, deleting tests and eventually just manually testing and calling it good.
Page Objects are your friends
The problem with using capybara or watir-webdriver statements directly in your step definitions is it breaks encapsulation. You're making calls in the global cucumber namespace to implementation details. It may be easier to get going, but make no mistake, it's technical debt.
# features/support/pages/sign_in_page.rb
class SignInPage
include Capybara::DSL
def email_field
find("input[id='email']")
end
def password_field
find("input[id='password']")
end
def sign_in_button
find("button[value='Sign In']")
end
def sign_in_as(user)
email_field.set user.email
password_field.set user.password
sign_in_button.click
end
end
# features/step_definitions/authentication_steps.rb
Given(/^I am logged in$/) do
SignInPage.new.sign_in_as(create(:user))
end
This is a super simple implementation of a page object. The object now is an interface between your test and the actual browser, and your test code just sends it a message on what to do. If a detail changes, you just change the class and the sign_in_as
message will continue to do the work of signing in.
Page Object Libraries
In the context of a Rails project with Capybara as the integration testing driver, Site Prism is really the only game in town. It provides a very simple DSL that will let you build up page objects without a lot of boilerplate methods for creating element accessors.
# features/support/pages/sign_in_page.rb
class SignInPage < SitePrism::Page
element :email_field, "input[id='email']"
element :password_field, "input[id='password']"
element :sign_in_button, "button[value='Sign In']"
def sign_in_as(user)
email_field.set user.email
password_field.set user.password
sign_in_button.click
end
end
As you can see, you get Capybara::DSL automatically, and you can use the element
class method to define you element finders, resulting in a much cleaner class. It has loads of other features, so be sure to checkout their github page.
Method Chaining
This is great, but sometimes you'll want to do multiple operations on each page. Sometimes it will read nicer to do each of these on their own line, sometime it will be nicer to chain methods.
class PostPage < SitePrism::Page
set_url "/posts{/id}"
element :reply, "a[id='reply']"
element :quick_add_comment_body, "#comment_body"
element :quick_add_comment_submit, "button[value='Add Comment']"
element :comment_count, "span[id='comment_count']"
def reply_to_post
reply.click
end
def quick_add_comment(text="WAT")
quick_add_comment_body.set text
quick_add_comment_submit.click
end
end
Here we have a page object that wraps a reply link and a quick comment form. The reply link will show the quick-reply form. This could be one operation, but we'll show it as two separate steps.
When(/^I quick add a comment$/) do
# assume @post is already populated in then given step
post_page = PostPage.new.load(id: @post.id)
post_page.reply
post_page.quick_add_comment "Hello World"
end
In this case, since it's one sequence of operations and the methods read cleanly, method chaining would be nice. This is just a matter of returning self
from the page object
class PostPage < SitePrism::Page
def reply_to_post
reply.click
self
end
end
When(/^I quick add a comment$/) do
PostPage.new.load(id: @post.id).reply.quick_add_comment "Hello World"
end
Generally, I have the methods return self if the user is going to remain on the current page. If they will navigate to another page, I return an instance of that page.
def sign_in_as(user)
# sign_in code
return HomePage.new
end
An app class for managing your page instances
As a final tip, as suggested by the site prism wiki, having a class that's responsible for managing your page object instances helps clean up your tests a lot.
# features/support/pages/app.rb
class App
def home_page
HomePage.new
end
end
# features/support/hooks.rb
Before do
@app = App.new
end
# features/step_definitions/home_steps.rb
Given(/^I am on the home page$/) do
@app.home_page.load
end
This also opens up some doors for meta-programming. Personally I don't like defining methods for each page object, but like the accessibility. So I implement something like this:
class App
# Page Object Accessors
# Converts a method call to a page object class and establishes a new instance
# @app.home_page # => HomePage
# @app.sign_in_page # => SignInPage
def method_missing(name)
klass = name.to_s.camelize
create_page_object_or_raise_error(klass)
end
private
def constant_exists?(klass_string)
Object.const_defined? klass_string
end
def create_page_object_or_raise_error(klass_string)
if constant_exists? klass_string
return klass_string.constantize.new
else
raise StandardError, "There's no page object currently defined called #{klass_string}. Create one under features/support/pages"
end
end
end
This lets me use the same interface of @app.page_klass_name
, but I don't have to define methods for each one, and it will also fail a test if I try to use a page object I haven't defined yet.
That's all for this introductory post on Object Oriented Testing. Next time we'll cover Test Services