1/21/2012

Polymorphous page objects

I'm a big fan of page object pattern used for developing Selenium tests and I like the whole approach it follows. However, there are some points which can be improved in this approach. Let's talk about them.

Imagine that we have profile page for authenticated user. It has navigation menu with a set of links: "Home", "Profile", "Messages". Typical page class will look like this:

class ProfilePage

  def click_home_link
    @browser.find_element(:id => 'home_link').click
    HomePage.new
  end

  def click_profile_link
    @browser.find_element(:id => 'profile_link').click
    ProfilePage.new
  end

  def click_messages_link
    @browser.find_element(:id => 'message_link').click
    MessagesPage.new
  end

end # ProfilePage

Home and Messages pages are rather different page, but still have navigation menu. So they should have the same set of methods. We shouldn't duplicate code, so we need to extract these methods into a separate module.

module NavigationMenuRegion

  def click_home_link
    @browser.find_element(:id => 'home_link').click
    HomePage.new
  end

  def click_profile_link
    @browser.find_element(:id => 'profile_link').click
    ProfilePage.new
  end

  def click_messages_link
    @browser.find_element(:id => 'message_link').click
    MessagesPage.new
  end

end # NavigationMenuRegion


class ProfilePage

  include NavigationMenuRegion

end # ProfilePage


class HomePage

  include NavigationMenuRegion

end # HomePage


class MessagesPage

  include NavigationMenuRegion

end # MessagesPage

That's very good. But let's imagine that we begin to write tests for admin user. He certainly has profile page with navigation menu, but it contains different links: "Add user", "Content". The first idea is to add few more methods to NavigationMenuRegion. We won't care because simple authenticated user won't touch them. This will look like this.

module NavigationMenuRegion

  def click_home_link
    @browser.find_element(:id => 'home_link').click
    HomePage.new
  end

  def click_profile_link
    @browser.find_element(:id => 'profile_link').click
    ProfilePage.new
  end

  def click_messages_link
    @browser.find_element(:id => 'message_link').click
    MessagesPage.new
  end

  def click_add_user_link
    @browser.find_element(:id => 'add_user_link').click
    AddUserPage.new
  end

  def click_content_link
    @browser.find_element(:id => 'content_link').click
    ContentPage.new
  end

end # NavigationMenuRegion


class ProfilePage

  include NavigationMenuRegion

end # ProfilePage


class HomePage

  include NavigationMenuRegion

end # HomePage


class MessagesPage

  include NavigationMenuRegion

end # MessagesPage

But what if we have 5 roles, each with a custom set of links? And same menu is shown to anonymous user? This will bring a mess into a module. Then, let's imagine that we several regions with elements depending on role. This may become a nightmare for tester to support.

But what if our page class will check the currently logged in user's role and provide page object with a set of methods unique for it?

Let's imagine that each page has small link with username in header (obvious) and we follow a rule of thumb to name user with his role. Then we can figure out the role of currently logged in user with something like this (in our base page class):

class Page

  def user_name
    @browser.find_element(:id => 'username_link').text
  end

  def user_role
    case self.user_name
    when /simple/ then :simple
    when /reader/ then :reader
    when /writer/ then :writer
    when /editor/ then :editor
    when /admin/  then :admin
    else :anonymous
    end
  end

end # Page

I understand that we can get role via other ways (e.g. if we use fixtures or have access to application code), but let's use username link just for example.

So, now we can split NavigationMenuRegion into a set of role-based modules.

module NavigationMenuRegion
  
  module Simple

    def click_home_link
      @browser.find_element(:id => 'home_link').click
      HomePage.new
    end

    def click_profile_link
      @browser.find_element(:id => 'profile_link').click
      ProfilePage.new
    end

    def click_messages_link
      @browser.find_element(:id => 'message_link').click
      MessagesPage.new
    end

  end # Simple

  module Admin

    def click_add_user_link
      @browser.find_element(:id => 'add_user_link').click
      AddUserPage.new
    end

    def click_content_link
      @browser.find_element(:id => 'content_link').click
      ContentPage.new
    end

  end # Admin

end # NavigationMenuRegion

Ok, but as far it doesn't change anything because we didn't add user role check. And there goes Ruby magic!

module NavigationMenuRegion

  #
  # Checks current user role and extends page object with
  # corresponding module.
  #
  def initialize
    super
    extend case self.user_role
           when :simple then Simple
           when :admin   then Admin
           else Default
           end
  end
  

  module Simple
    # there go our methods for simple user
  end # Simple

  module Admin
    # there go our methods for admin user
  end # Admin

  module Default
    # we can add as many modules as we need, but let's have default
  end # Default

end # NavigationMenuRegion

Pretty easy, ain't it? I really like methods like #extend in Ruby, because when it's called for class, it adds class methods to it, but when it's called for object - it adds instance methods to it. However, they make code so elegant!

The key of it is that we overwrite #initialize thus ensuring it's called on page object creation. We should also call super in case page class has custom #initialize.

I cater this pattern for a month and it shows itself really good. We keep our code organized, page objects are flexible and easy to support.

I'm sure I'm not the first who came to this, but I didn't manage to google anything like this.

P.S. I called it "polymorphous" because page objects change themselves depending on environment. Maybe it's not a good title, but whatever.

5 comments:

Twin Cities Hoops Czar said...

Can you show the bit where you're defining @browser and how you keep from having multiple browsers open each time you access a new page. When I set up my system to try and do something similar to your code below, ProfilePage.new opens a new browser instead of using an existing @browser that I've already setup.

def click_profile_link
@browser.find_element(:id => 'profile_link').click
ProfilePage.new

Thanks for your help.

Alex Rodionov said...

My examples are incorrect in that because I used different approach in general. Instead of @browser I had #browser method which returned class attribute like. Something like in https://gist.github.com/1717569

malini ecorp said...

wonderful information, I had come to know about your blog from my friend nandu , hyderabad,i have read atleast 7 posts of yours by now, and let me tell you, your website gives the best and the most interesting information. This is just the kind of information that i had been looking for, i'm already your rss reader now and i would regularly watch out for the new posts, once again hats off to you! Thanks a ton once again, Regards, QA online trainingamong the QA in Hyderabad. Classroom Training in Hyderabad India

Aliaksandr Ikhelis said...

Hi Alex, this is a nice idea and clean demonstration code.

We have built similar logic in our internal page models gem at Expedia several years back. I found it can be quite difficult to debug, should a test fail in such a structure. Dynamic programming defining behaviour and structure of your objects depends on some run-time conditions, which can be brittle and hard to trace. E.g. inability to simply jump-to-source to track the failure down is just tip of an iceberg.

I was struggling to define a good trade-of balance between complexity and re-usability in the test framework. Imagine, in addition to different user roles you've got app customisation across dozens of Points of Sales, and ever going AB tests in place ;-) You may end up making your test system as complex as your application under test but obviously much more brittle and expecting a lot of maintenance efforts.

I am trying to take simpler approach and chunk tests by test objectives, and in many cases buy simplicity over re-usability.

Would be great to hear your thoughts.
Cheers,
Alex

David Williams said...

Thanks for Information QA Testing Online Training

Post a Comment