Maintainable Rails system tests with page objects

0
23
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.

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. There are no new frameworks or anything like that, just a different take on organizing system tests. Let me know what you think – especially if you think it’s a terrible idea.

← 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 →



Source link

Leave a reply

Please enter your comment!
Please enter your name here