Rails system tests often depend on input and CSS selectors. To make our tests more maintainable, we can isolate layout changes within page objects.
This post is about an idea I had a long time ago and came back to recently. It’s from a similar category as my idea for Rails contexts, so it might not be 100% failproof, and I am looking for feedback.
Concepts
So what is it about? What’s a page object?
A regular system test might look like this:
require "application_system_test_case"
class RegisterUserTest < ApplicationSystemTestCase
setup do
@user = users(:unregistered)
end
test "registers an account" do
visit new_user_registration_path
fill_in "Email", with: user.email
fill_in "Password", with: user.password
fill_in "Password confirmation", with: user.password
click_on "Sign up"
assert_selector "h1", text: "Dashboard"
end
end
It’s nice and tidy for something small. But if we start reusing specific flows and selectors, we would have to update many places whenever we change a particular screen.
This might not be a big deal since we can extract private methods and helpers. But it got me thinking.
What if we isolate actions and assertions of a particular screen in a page object?
An example of the registration and dashboard pages could look like this:
# test/pages/test_page.rb
class TestPage
include Rails.application.routes.url_helpers
attr_accessor :test, :page
def initialize(system_test, page)
@test = system_test
@page = page
end
def visit
@test.visit page_path
end
def page_path
raise "Override this method with the page path"
end
end
# test/pages/registration_page.rb
class RegistrationPage < TestPage
def register(user)
@test.fill_in "Email", with: user.email
@test.fill_in "Password", with: "12345671"
@test.fill_in "Password confirmation", with: "12345671"
@test.click_on "Sign up"
end
def page_path
new_user_registration_path
end
end
# test/pages/dashboard_page.rb
class DashboardPage < TestPage
def assert_logged_in
@test.assert_selector "h1", text: "Dashboard"
end
def page_path
dashboard_path
end
end
The basic idea is that a page under test defines its actions (fill_in_user_email
, register
) and assertions (assert_logged_in
). Whenever the fields change or we have to use a different selector, we have one and only one place to update. Any test that uses such a page wouldn’t have to be changed at all.
When we initialize a new page we have to pass the test and page contexts (here system_test
and page
) to use the testing API from within these page objects.
Since I want to group these pages, I also have to add the test/pages
path to the testing configuration for Zeitwerk to pick up:
# config/environments/test.rb
require "active_support/core_ext/integer/time"
Rails.application.configure do
...
config.autoload_paths << "#{Rails.root}/test/pages"
end
This allows us to write the registration test as:
require "application_system_test_case"
class RegisterUserTest < ApplicationSystemTestCase
setup do
@user = users(:unregistered)
end
test "registers an account" do
registration = RegistrationPage.new(self, page)
registration.register(@user)
dashboard = DashboardPage.new(self, page)
dashbord.assert_logged_in
end
end
I find the grouping to pages rather than private methods cleaner and make the tests themselves much shorter.
Let’s say that I am now adding internalization to pages. Instead of going through all my system tests, I only have to open and edit the relevant pages:
# test/pages/registration_page.rb
class RegistrationPage < TestPage
def register(user)
@page.fill_in I18n.t("attributes.user.email"), with: user.email
@page.fill_in I18n.t("attributes.user.password"), with: user.password
@page.fill_in I18n.t("attributes.user.password_confirmation"), with: user.password
@page.click_on I18n.t("buttons.register")
end
def page_path
new_user_registration_path
end
end
The test itself stayed the same.
However, I also felt there is still some disconnect between going from page to page. So another idea is to introduce a TestFlow
object that would keep the whole flow together:
class TestFlow
attr_accessor :test, :page, :history
def initialize(system_test, system_page, start_page_class)
@test = system_test
@page = start_page_class.new(system_test, system_page)
@page.visit
@history = [@page]
end
def visit(page_class)
@page = page_class.new(@test, page)
@page.visit
@history << @page
end
def transition(page_class)
@page = page_class.new(@test, page)
assert_transition
@history << @page
end
def assert_transition
@test.assert_equal @test.current_path, @page.page_url
end
end
The idea is that we start with one page in the beginning and then change pages with a transition
call to ensure we indeed arrived on the page we originally wanted. The @history
then remembers the flow and lets us build other features like going back.
To use it, I’ll make a small helper method in application_system_test_case.rb
:
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
def start_flow(start_page)
TestFlow.new(self, page, start_page)
end
end
And then use it by starting flow in setup
and calling transition
in between the screens:
require "application_system_test_case"
class RegisterUserTest < ApplicationSystemTestCase
setup do
@user = users(:unregistered)
@flow = start_flow(RegistrationPage)
end
test "registers an account" do
@flow.page.register(@user)
@flow.transition(TeamPage)
@flow.page.assert_logged_in
end
end
That’s it for the concept itselt. There are no new frameworks or anything like that, just a different take on organizing system tests.
Second thoughts
Although being explicit is good, I think we can be just a little bit less explicit and more elegant.
The first improvement is to look at the page class and clean it up by not using instance variables for @test
and @page
– and rather keep everything as if it was in a usual system test. We can also pass a single test context, since it always have access to page
:
# test/pages/test_page.rb
class TestPage
# Include path helpers
include Rails.application.routes.url_helpers
attr_accessor :test, :page, :params
def initialize(system_test, params: {})
@test = system_test
@page = system_test.page
@params = params
end
def visit
page.visit page_path
end
def assert(*args, **kwargs, &block)
test.assert(*args, **kwargs, &block)
end
def assert_not(*args, **kwargs, &block)
test.assert_not(*args, **kwargs, &block)
end
end
I also redefined the assertions in the TestPage
class (could be method_missing
too). The nice thing about this is that the tests themselves will look almost as in the beginning. This would help moving to page objects or out of them on the whim. You can now prototype tests without test objects and then extract the methods by copy and pasting:
# test/pages/registration_page.rb
class RegistrationPage < TestPage
def register(user)
page.fill_in I18n.t("attributes.user.email"), with: user.email
page.fill_in I18n.t("attributes.user.password"), with: user.password
page.fill_in I18n.t("attributes.user.password_confirmation"), with: user.password
page.click_on I18n.t("buttons.register")
end
def assert_notice
assert page.has_content?("Notice.")
end
def page_path
new_user_registration_path
end
end
The second improvement is to actually convert TestFlow
to helpers. It’s shorter and without the need to reference the flow at all (like in usual system tests):
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
def start(page_class, params = {})
@page = page_class.new(self, params)
@page.visit
end
def transition(page_class, params = {})
@page = page_class.new(self, params)
assert_transition
end
private
def assert_transition
assert_equal @page.page_path, current_path
end
end
Instead of asking the flow object for a page (@flow.page
) we would reference the page object with the instance variable (@page
). This means we still have access to Capybara’s page
but when working with our page objects, we prepend @:
require "application_system_test_case"
class RegisterUserTest < ApplicationSystemTestCase
setup do
@user = users(:unregistered)
end
test "registers an account" do
start RegistrationPage
@page.register(@user)
transition TeamPage
@page.assert_logged_in
end
end
Current page changes via start
(first visit) and transition
(nth visit with assertion). What do you think?
I also recommend to look at SitePrism gem which implements some of these ideas on steroids.
Get Test Driving Rails while it's in prerelease.