In the post on going from Watir to Symbiont, I talked about how Symbiont encourages you to delegate as much as possible to the page definition. The reason for this being that page definitions get turned into page objects, thus allowing your scripts to rely on the page object to handle functionality, rather than having each automated script do so. Here I’ll explore that idea a bit more.
In the previous Watir-to-Symbiont post, you ended up with the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
require 'rspec' include RSpec::Matchers require 'symbiont' class Symbiote attach Symbiont url_is 'http://localhost:9292' url_matches /:\d{4}/ title_is 'Symbiote' p :login_form, id: 'open' text_field :username, id: 'username' text_field :password, id: 'password' button :login, id: 'login-button' div :message, class: 'notice' end Symbiont.set_browser page = Symbiote.new page.view expect(page).to be_verified page.login_form.click page.username.set 'admin' page.password.set 'admin' page.login.click expect(page.message.text).to eq('You are now logged in as admin.') |
Elements to Objects
Let’s focus on the lines that are actually interacting with the web page:
1 2 3 4 |
page.login_form.click page.username.set 'admin' page.password.set 'admin' page.login.click |
Those statements work because the page definition declares the following element definitions:
1 2 3 4 |
p :login_form, id: 'open' text_field :username, id: 'username' text_field :password, id: 'password' button :login, id: 'login-button' |
Let’s consider one of those lines in a certain amount of detail. Each of the selectors (p, text_field, button) are based on methods that exist within Watir-WebDriver. What you’re actually seeing there is a method call, which might be easier to see if you put in the parentheses:
1 |
text_field(:username, id: 'username') |
That’s simple enough. Symbiont goes one step further, however. What happens is that the second argument to the element method — the friendly names (:login_form, :username, etc) — will be converted by Symbiont into methods themselves. Those methods return an instance of the Watir element they correspond to. Those element objects are what you can then call on an instance of the page object. So, as an example, if you were to inspect the username
object that gets created for you, you would see it looks something like this:
#<Watir::TextField:0x..fa0bd8f12 located=false selector={:id=>"username", :tag_name=>"input or textarea", :type=>"(any text type)"}>
This is what proxies the friendly name :username (of text_field type) to the underlying Watir implementation (of the Watir::TextField type). This means you can call all methods that are relevant to a Watir::TextField on the :username object.
This is part of how Symbiont tries to let you write concise and expressive tests, by essentially letting you interact with the underlying library via your higher-level domain objects.
Page Object Design
Let me briefly digress here to talk about the design approach.
What you are seeing with the page definitions are that they can be configured using macro-style method invocations. The idea is to have a highly readable block of code at the top of the class that makes it immediately clear how that class is configured. These methods declaratively tell Symbiont how to manage pages by essentially stating what can happen on or to those pages.
Many of the invocations do some amount of metaprogramming, meaning that they participate in adding behavior to the class at runtime, in the form of additional instance variables and class methods. But as you saw above, these declarative methods are really just regular method calls. They are method calls that execute in the context of the class object. The method argument parentheses are usually left off to emphasize the declarative intention.
But why does any of this matter? Well, first I think it makes for a very concise way to define a page object. But you might also note that you can have anyone of pretty much any skill write a page definition for you and not require them to know a ton of Ruby. This is the nice thing about a language that “gets out of your way” and does not force you to use a lot of programmatic boilerplate.
Delegating Actions to the Page
Now let’s consider those specific statements again:
1 2 3 4 |
page.login_form.click page.username.set 'admin' page.password.set 'admin' page.login.click |
From a pure scripting perspective, that’s not too bad, yet each statement is providing some low level details in the script. For example, you specifically “click” two things and “set” two other things. Further, in this case it’s probably clear that the statements are logging in to something but you can certainly imagine other logic wherein the intent of the action is hidden by the implementation details. Also, if you changed the friendly names, the above statements would have to change. That doesn’t become a problem until you start re-using those statements in different execution contexts.
I think what most automation script writers would do, upon seeing the code above, is decide to wrap it in a method. And that’s pretty much exactly what you would do with Symbiont. Let’s say I want the primary portion of my script to look like this:
1 2 3 4 5 6 7 |
Symbiont.set_browser page = Symbiote.new page.view expect(page).to be_verified page.login_as ({ username: 'admin', password: 'admin' }) |
Regarding that last line, clearly I’m calling a login_as
method and I’m passing a hash that contains the username and password as arguments. Why a hash? Why not just a string? I could have done that as well. That’s the point: here you’re just using the same coding techniques you would use for any Ruby logic. You can probably see that to provide the page.login_as
functionality that I want, I just have to provide a login_as
method on my page definition that handles the actions I want. Here’s an example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
class Symbiote attach Symbiont url_is 'http://localhost:9292' url_matches /:\d{4}/ title_is 'Symbiote' p :login_form, id: 'open' text_field :username, id: 'username' text_field :password, id: 'password' button :login, id: 'login-button' div :message, class: 'notice' def login_as(data) login_form.click if data.is_a?(Hash) username.set data[:username] password.set data[:password] end login.click verify_successful_login end def verify_successful_login expect(message.text).to eq('You are now logged in as admin.') end end |
The login_as
method takes an argument and then checks if it’s a hash, which is what allows my call to it to work. Notice here that I’m calling the username
and password
element methods just as I did outside of the page definition. Further note that I’m calling another method (verify_successful_login
). There is no exception handling in the method and I’m doing that just to keep things simple. In fact, such logic might be what you further delegate to helper methods. I’m not going to go into that territory for now but just know that helper modules could be used to further refine how much information is in the page definition.
Page Objects Provide the Context
Since pages provide the context for a web site, just as activities would for a web service, it seems logical to separate them out from the scripts. Numerous scripts, after all, may refer to the same page definitions. To play along and see how this works, in a working directory create a file called pages.rb and put the page definition from above in it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
class Symbiote attach Symbiont url_is 'http://localhost:9292' url_matches /:\d{4}/ title_is 'Symbiote' p :login_form, id: 'open' text_field :username, id: 'username' text_field :password, id: 'password' button :login, id: 'login-button' div :message, class: 'notice' def login_as(data) login_form.click if data.is_a?(Hash) username.set data[:username] password.set data[:password] end login.click verify_successful_login end def verify_successful_login expect(message.text).to eq('You are now logged in as admin.') end end |
In a file called script.rb, put just the logic of your script with the addition of a require line for the pages.rb file:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
require 'rspec' include RSpec::Matchers require 'symbiont' require './pages' Symbiont.set_browser page = Symbiote.new page.view expect(page).to be_verified page.login_as ({ username: 'admin', password: 'admin' }) |
Now let’s try automating another portion of the Symbiote site. There is a “Weight on Other Planets” portion of the site, located at http://localhost:9292/weight. This page cannot be viewed unless you are logged in.
Exercise: Before reading on, take some time to navigate to the page and see how it works. There’s not much to it so it won’t take you long. When you have done that, try to construct a page definition based on the knowledge you have. Put that page definition in the pages.rb file.
Did you give it a shot? Here’s one way to do it:
1 2 3 4 5 6 |
class Weight attach Symbiont text_field :weight, id: 'wt' button :calculate, id: 'calculate' end |
Something interesting to note here: I do not have a url_is
assertion. Having that would allow me to easily just “go to” the page. Yet what that also allows me to do is not use the site navigation to do so. Is that a bad thing? It really depends. Some testers feel that scripts should always use the site navigation at all times. Others feel that you should definitely test navigation in a set of navigation scripts, but then, past that point, you can use convenience actions, like going directly to a url. In the end, do whatever you want to do as long as you are getting the test coverage you need.
Here I’m going to use the site navigation to showcase something about page definitions. Specifically, a page definition does not necessarily have to involve a whole page. It can refer to just part of a page. Consider the navigation pull-out that the Symbiote page provides. That might be a page definition in itself: it just so happens to be a partial page definition.
Exercise: Before reading on, try to make a page definition for the navigation functionality of the Symbiote site and put it in pages.rb.
Did you try it? Here’s one possible way to do it:
1 2 3 4 5 6 |
class Navigation attach Symbiont p :page_list, id: 'navlist' link :weight_calculator, id: 'weight' end |
So now we have a few page definitions. You already know how to login, so …
Exercise: Given what you know, try to write a script that reuses the login functionality to log in, navigates to the Weight on Other Planets page, and then puts in a weight value of 200 and calculates based on that value.
What did you come up with? Here’s one possible solution that would work:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
require 'rspec' include RSpec::Matchers require 'symbiont' require './pages' Symbiont.set_browser page = Symbiote.new page.view expect(page).to be_verified page.login_as ({ username: 'admin', password: 'admin' }) page = Navigation.new page.page_list.click page.weight_calculator.click page = Weight.new page.weight.set '200' page.calculate.click |
Here I essentially just keep instantiating a new page definition as my script gets to that part of the flow where the page is going to be interacted with.
Now — key question here — assuming you did something like the above, did you get an error? It’s quite possible you got an error like this:
[remote server] Element is not currently visible and so may not be interacted with (Selenium::WebDriver::Error::ElementNotVisibleError)
That’s coming up because of the call in line 17. The problem is that Watir may move a little quicker than the display. In this case, Watir is likely trying to click the link before the paragraph pull out actually was pulled out. That would, of course, mean it was trying to click on an element that wasn’t visible yet. This is where being able to harness Watir-WebDriver directly on your elements can help you out. All you have to do is tell Watir to wait until the element is present. So you could change line 17 to this:
1 |
page.weight_calculator.when_present.click |
Notice, too, how the method chaining fluent interface allows your logic to remain concise and expressive.
As this point, your script should be working. So let’s clean it up even further.
Exercise: Use methods to put the logic of the tests in the page definitions themselves.
Clearly there are numerous ways you can do this. First, here’s what I did in my script:
1 2 3 4 5 |
page = Navigation.new page.navigate_to(:weight_calculator) page = Weight.new page.convert('200') |
To support this I had the following logic in the respective page definitions:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class Weight attach Symbiont text_field :weight, id: 'wt' button :calculate, id: 'calculate' def convert(value) weight.set value calculate.click end end class Navigation attach Symbiont p :page_list, id: 'navlist' link :weight_calculator, id: 'weight' def navigate_to(location) page_list.click self.send("#{location}").when_present.click end end |
Notice how my navigate_to
method relies on a bit of dereferencing. Whether or not that’s a good way to do it, the point is once again that you can do whatever you want. This allows you to expose an API of sorts. For example, my navigate_to
method could take various types of parameters and act accordingly. Likewise, the same could occur with my login_as
method, where I could support taking a hash or a string or even an object. How page definition actions can be called can be documented as part of your API.
Going back to the script, what makes all this work is the fact that you establish a new context — an instance of a page definition. At that point, the context is being stored in the page
variable. Let’s look at the script that I came up with in full:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
require 'rspec' include RSpec::Matchers require 'symbiont' require './pages' Symbiont.set_browser page = Symbiote.new page.view expect(page).to be_verified page.login_as ({ username: 'admin', password: 'admin' }) page = Navigation.new page.navigate_to(:weight_calculator) page = Weight.new page.convert('200') |
I think that’s pretty concise and mostly intent-revealing, even at the code level that we are operating at. The test script is stating what to do but the details of how have been delegated to the page definitions. As concise as that is, Symbiont does provide a mechanism that allows you to streamline that a bit more. I’ll talk about that in the next post.
I’ve written about DRY page objects in my blog post “Keep Your Page Objects DRY”.
Good stuff. Thanks for sharing. It’s definitely one of the most effective patterns I’ve found for automation, the others being context factory and data builder.