- Time: 30-40 min
- Level: Intermediate/Advanced
- Code: GitHub
In this post we will take a look at a way to improve sample Rails 5.1.3 System Test
using POROs, collaborators, delegation and modules.
STEP #0
Basic system test, before refactoring
# test/system/users_test.rb
require "application_system_test_case"
class UsersTest < ApplicationSystemTestCase
test "visiting the index" do
visit users_url
assert_selector "h1", text: "User"
end
test 'creating new user' do
visit users_url
click_on 'New User'
fill_in 'First name', with: 'Bill'
fill_in 'Last name', with: 'Bird'
click_on 'Create User'
visit users_url
assert_text 'Bill Bird'
end
test 'editing existing user' do
User.new(first_name: 'Bill', last_name: 'Bird').save
visit edit_user_url(User.first)
fill_in 'First name', with: 'First'
fill_in 'Last name', with: 'Last'
click_on 'Update User'
assert_text 'First Last'
end
end
it verifies three things:
- If we can visit the index page and if it has the structure we expect
- If we can create a new user and see it on the index user page
- If we can update user information and see the changes on the index user page
Step #1
In this step we’ll:
- Introduce an abstract clas that will help us describe page structure and functionality
- Add a page class to test show user page
- Use new page class in a test
As a first step let’s introduce an abstract class with a single method that will help us specify what elements we have on the page, actions from this step can be found in the corresponding commit
# test/support/pages/base.rb
module Pages
class Base
Error = Class.new(StandardError)
attr_reader :current_session
attr_reader :url
def self.has_node(method_name, selector, default_selector = :css, options = {})
case default_selector
when :css
define_method(method_name) do
css_selector = @css_wrapper + ' ' + selector
current_session.first(default_selector, css_selector.strip, options)
end
when :xpath
# XPATH accessor
define_method(method_name) do
current_session.first(default_selector, selector, options)
end
else
fail Error, "Unknown selector #{default_selector}"
end
end
private
# initialize with Capybara session
def initialize(url:, css_wrapper: ' ', current_session: Capybara.current_session)
@current_session = current_session
@url = url
@css_wrapper = css_wrapper
end
end
end
Let’s take a closer look at initilaize
method and instance variablesthere:
-
@current_session
- defaults toCapybara.current_session
,collaboratior object that allows us use driver insidehas_node
method -
@url
- requidred parameter, URL of the page under test -
@css_wrapper
- defaults to an empty string, helpful when all elements under test are within an element with particular CSS class
Now let’s introduse a new class that describes a show user page
# test/support/pages/users/show.rb
require_relative '../base'
module Pages
module Users
class Show < Pages::Base
has_node :notice, '#notice'
has_node :edit_user_link, 'a', :css, text: 'Edit'
has_node :back_link, '//a[text()="Back"]', :xpath
end
end
end
You can see here three ways to identify an element on the page:
- By CSS id
- By type and text
- By xpath
Things to remember:
-
has_node
is only a wrapper aroundCapybara::Node::Finders#first so same thing may bedone in a few ways -
has_node
result is equal to Capybara::Node::Finders#first, if the element is found it’s result is aninstance of Capybara::Node::Element
Now let’s use Pages::Users::Show
in the test for UsersController#show
test 'creating new user' do
visit users_url
click_on 'New User'
fill_in 'First name', with: 'Bill'
fill_in 'Last name', with: 'Bird'
click_on 'Create User'
page = ::Pages::Users::Show.new(url: user_path(User.last))
assert page.notice.text == 'User was successfully created.'
assert page.edit_user_link.text == 'Edit'
assert page.back_link.text == 'Back'
visit users_url
assert_text 'Bill Bird'
end
this is a small first step to understand better how to use page classes
Step #2
In this step we will:
- Introuduce a new
Pages::Base#visit
method - Include
Rails.application.routes.url_helpers
inPages::Base
inorder to have access to the routes inside the class - Add
Pages::Users::New
,Pages::Users::Edit
,Pages::Users::Index
classes - Use new classes to refactor our sample test
I won’t include code for new pages here you can find it in the corresponding commit. Let’s take a look at how the test looks now instead:
# test/system/users_test.rb
require 'application_system_test_case'
require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'users', 'show')
require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'users', 'new')
require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'users', 'index')
require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'users', 'edit')
class UsersTest < ApplicationSystemTestCase
test "visiting the index" do
visit users_url
assert_selector "h1", text: "User"
end
test 'creating new user' do
::Pages::Users::Index.new.instance_eval do
visit
new_user_link.click
end
::Pages::Users::New.new.instance_eval do
visit
first_name.set( 'Bill' )
last_name.set( 'Bird' )
create_user_button.click
end
page = ::Pages::Users::Show.new(url: user_path(User.last))
assert page.notice.text == 'User was successfully created.'
assert page.edit_user_link.text == 'Edit'
assert page.back_link.text == 'Back'
::Pages::Users::Index.new.visit
assert_text 'Bill Bird'
end
test 'editing existing user' do
User.new(first_name: 'Bill', last_name: 'Bird').save
::Pages::Users::Edit.new(url: edit_user_url(User.first)).instance_eval do
visit
first_name.set( 'First' )
last_name.set( 'Last' )
update_user_button.click
end
::Pages::Users::Index.new.visit
assert_text 'First Last'
end
end
We have three more steps left, but let’s take a look what we’ve acheivedalready:
- Now we use class methods instead of raw selectors so if page structure change we will have to change only the corresponding class
- Because we use collaborator objects we have nice blocks and it’sclear on what page we are an every line
Step #3
In this step we will:
- Add ability to verify if the element is present in page classes
- Add a method to
Pages::Users::Show
to verify page structure
Let’s take a look at the changes in the test first (corresponding commit)
Before
# test/system/users_test.rb
test 'creating new user' do
# Not important piece
page = ::Pages::Users::Show.new(url: user_path(User.last))
assert page.notice.text == 'User was successfully created.'
assert page.edit_user_link.text == 'Edit'
assert page.back_link.text == 'Back'
::Pages::Users::Index.new.visit
assert_text 'Bill Bird'
end
After
# test/system/users_test.rb
test 'creating new user' do
# Not important piece
::Pages::Users::Show.new(test: self, url: user_path(User.last)).instance_eval do
check_main_elements_presence
assert notice.text == 'User was successfully created.'
end
::Pages::Users::Index.new.visit
assert_text 'Bill Bird'
end
Pages::Users::Show#check_main_elements_presence
definition
# test/support/pages/users/show.rb
def check_main_elements_presence
notice_present?
edit_user_link_present?
back_link_present?
end
In order to do this step we:
- Changed the
Pages::Base#initialize
to accept new collaboratorobjecttest:
- Changed the
Pages::Base#has_node
to define both accessor and*_present?
methods
Step #4
In this step we will extract functionality into a module (corresponding_commit)
Let’s first compare Pages::User::Edit
and Pages::User::New
# pages/user/edit.rb
require_relative '../base'
module Pages
module Users
class Edit < Pages::Base
has_node :first_name, '#user_first_name'
has_node :last_name, '#user_last_name'
has_node :update_user_button, '//input[@value ="Update User"]', :xpath
end
end
end
# pages/user/new.rb
require_relative '../base'
module Pages
module Users
class New < Pages::Base
has_node :first_name, '#user_first_name'
has_node :last_name, '#user_last_name'
has_node :create_user_button, '//input[@value= "Create User"]', :xpath
private
def http_path
new_user_path
end
end
end
end
they both have two same nodes first_name
and last_name
, which isn’t strange - we render same partial form
on both pages. Except for that when testing these pages we fill out this form, let’s extract these two pieces to a module.
Pages::Users::Partials::UserForm
module
# test/support/pages/users/partials/user_form.rb
module Pages
module Users
module Partials
module UserForm
def self.included(clazz)
clazz.has_node :first_name, '#user_first_name'
clazz.has_node :last_name, '#user_last_name'
end
def fill_out_user_form(first: 'Bill', last: 'Bird')
first_name.set(first)
last_name.set(last)
end
end
end
end
end
Pages after refactoring
# pages/user/edit.rb
require_relative '../base'
module Pages
module Users
class Edit < Pages::Base
include Partials::UserForm
has_node :update_user_button, '//input[@value ="Update User"]', :xpath
end
end
end
# pages/user/new.rb
require_relative '../base'
module Pages
module Users
class New < Pages::Base
include Partials::UserForm
has_node :create_user_button, '//input[@value= "Create User"]', :xpath
private
def http_path
new_user_path
end
end
end
end
Step #5
In This step we will:
- Add ability to take screenshots to the page classes
- Compare test we had before Step #1 and after Step #5
First item is quite straightforward, since we already have a test as a collaborator in Pages::Base
we only need to add take_screenshot
to a list of methods we delegate, you can find changes in thecorresponding commit
Now let’s compare what we had in the beginning
Before
# test/system/users_test.rb
require "application_system_test_case"
class UsersTest < ApplicationSystemTestCase
test "visiting the index" do
visit users_url
assert_selector "h1", text: "User"
end
test 'creating new user' do
visit users_url
click_on 'New User'
fill_in 'First name', with: 'Bill'
fill_in 'Last name', with: 'Bird'
click_on 'Create User'
visit users_url
assert_text 'Bill Bird'
end
test 'editing existing user' do
User.new(first_name: 'Bill', last_name: 'Bird').save
visit edit_user_url(User.first)
fill_in 'First name', with: 'First'
fill_in 'Last name', with: 'Last'
click_on 'Update User'
assert_text 'First Last'
end
end
and how the test looks now
After
# test/system/users_test.rb
require 'application_system_test_case'
require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'users', 'show')
require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'users', 'new')
require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'users', 'index')
require File.join(Rails.root.to_s, 'test', 'support', 'pages', 'users', 'edit')
class UsersTest < ApplicationSystemTestCase
test "visiting the index" do
visit users_url
assert_selector "h1", text: "User"
end
test 'creating new user' do
::Pages::Users::Index.new(test: self).instance_eval do
visit
new_user_link.click
take_screenshot
end
::Pages::Users::New.new.instance_eval do
visit
fill_out_user_form
create_user_button.click
end
::Pages::Users::Show.new(test: self, url: user_path(User.last)).instance_eval do
check_main_elements_presence
assert notice.text == 'User was successfully created.'
end
::Pages::Users::Index.new.visit
assert_text 'Bill Bird'
end
test 'editing existing user' do
User.new(first_name: 'Bill', last_name: 'Bird').save
::Pages::Users::Edit.new(url: edit_user_url(User.first)).instance_eval do
visit
fill_out_user_form(first: 'First', last: 'Last')
update_user_button.click
end
::Pages::Users::Index.new(test: self).instance_eval do
visit
assert_text 'First Last'
end
end
end
after version has few advantages, they will be listed in a summary section
Summary
Advantages of the OO approach:
- Tests are less brittle - if page structure/logic changes you will need to change only corresponding page class
- Tests are more readable - because of
instance_eval
blocks you always know which page are you on - It’s much easier to define elements that exist on the page
- Same functionality can be extracted
- Other team members may use page classes in their tests
- Pages are POROs, all the beauty/power of Ruby can be used there
Code:
Food for thought
- I’m not happy with the fact that
Pages::Base
hasinclude Rails.application.routes.url_helpers
. This is done only to show that if the page URL is static it can become a part of the page class, there should be a better way to achieve it -
has_node
works only for a single element, would be cool to havehas_nodes
for collections. Once again page classes are POROs so they may and should be changed to fit your needs - Folder with page classes may be a part of autoload paths, but noteveryone likes autoloading
- Depending on a test framework delegated methods in
Pages::Base
will differ, but it can be used with other test frameworks (like RSpec) too - Instead of having multiple test there could be one test, you won’t truncate database, you may have tests grouped by the user that is logged in, additional data in the database may help you discover bugs or make your hate your life :)
Top comments (0)