Maintainable Rails system tests with page objects

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.

← IT'S OUT NOW

I wrote a complete guide on web application deployment. Ruby with Puma, Python with Gunicorn, NGINX, PostgreSQL, Redis, networking, processes, systemd, backups, and all your usual suspects.

More →