gem update tapestryI’m doing this purposely to show that a micro-framework can be in evolution and still be the basis for showing people how it works, as long as you don’t break that which came before. What I show you here will, conceptually, likely be nothing new. But what I do want to show is how a popular pattern, called the page object pattern, essentially came to be and show why, despite their being possibly better patterns, it has held on for a long time as a solid method of organization.
Our Initial Script
Let’s start out with a fairly large script here:
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 32 33 34 35 36 37 38 39 40 41 42 |
require 'webdriver_manager' require 'rspec/expectations' include RSpec::Matchers require 'tapestry' include Tapestry class Veilus include Tapestry url_is 'https://veilus.herokuapp.com' end Tapestry.set_browser Tapestry.move_to(0, 0) Tapestry.maximize page = Veilus.new page.view expect(Tapestry.browser.img(id: "site-image").exists?).to be_truthy Tapestry.browser.element(id: "open").click Tapestry.browser.text_field(id: "username").set! "admin" Tapestry.browser.text_field(id: "password").set! "admin" Tapestry.browser.input(id: "login-button").click login_text = Tapestry.browser.div(class: "notice").text expect(login_text).to eq("You are now logged in as admin.") Tapestry.browser.wait_until { Tapestry.browser.element(id: "areas").style("left") == "-700px" } Tapestry.browser.element(:id, "navlist").click Tapestry.browser.wait_until { Tapestry.browser.element(id: "areas").style("left") == "0px" } Tapestry.browser.link(id: "stardate", index: 1).click expect(Tapestry.browser.title).to match 'Stardate Calculator' expect(Tapestry.browser.url).to match 'stardate' expect(Tapestry.browser.img(id: "stardate-logo").exists?).to be_truthy Tapestry.quit_browser |
Waiting For Stuff
You might notice those interesting “wait_until” statements (lines 33 and 35). I purposely designed that element of the web app to be a little tricky to automate because it relies on a DOM change before it becomes visible enough to interact with. Standard Watir may suggest using one of the following statements:
1 2 3 |
browser.element(:id, "navlist").wait_until(&:visible?).click browser.element(:id, "navlist").wait_until(&:present?).click browser.element(:id, "navlist").wait_until(&:enabled?).click |
Multiple Locators
You’ll also note on line 36 that I use multiple attributes for the locator. This is because there are two “stardate” links on the page and I want to make sure I click the one on the navigation list that gets expanded. I could, however, have done this with a single locator like this:
1 |
Tapestry.browser.link(:href, "../stardate").click |
Page Definitions
Let’s expand our Veilus definition as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Veilus include Tapestry url_is 'https://veilus.herokuapp.com' img :test_gorilla, id: "site-image" element :open, id: "open" text_field :username, id: "username" text_field :password, id: "password" button :login, id: "login-button" element :notice, class: "notice" element :navigation, id: "navlist" link :stardate_on_nav, id: "stardate", index: 1 img :stardate_logo, id: "stardate-logo" end |
Element Definitions
So a definition — a page definition, in this case — is an organizing principle for storing element definitions. And those element definitions are themselves an organizing principle in terms of providing a consistent pattern for how elements should be specified. That pattern is as such:selector :friendly name locatorFirst you specify the type of web element. This name will correspond to what are known as selectors in the web development world. These are largely the same names you would use in CSS files and are what tools like Watir and Selenium use as well. The next part of the element definition is a friendly name for the element. The friendly name, which must be preceded by a colon, is how you will refer to the element in your test logic. This can be as descriptive as you want. Do note that you cannot use spaces, but you can separate words by underscores. Finally, the last part of the element definition is a locator for that element. The locator tells the browser driver how to find the element on the web page. You have to specify a valid locator type (such as ‘id’) and then the value that the locator will have. But how do you know what Watir provides for selectors? Try this:
1 |
puts Tapestry.watir_selectors |
1 |
puts page.definition_api |
open password test_gorilla username login notice navigation stardate_on_nav stardate_logoHmm, does any of that look familiar? Those are showing you the exact names you used for the friendly names of your elements. What’s happening here is that putting element definitions on a page definition automatically converts those declarations into method calls. This is done using a particular pattern.
Proxy Pattern
Selenium is a low-level API. What I think good automation micro-frameworks do is provide wrappers around those kinds of APIs. That’s what I’m doing with Tapestry. Even more specifically, I’m using a proxy pattern to delegate down to Watir’s API. You can see how this works in my elements method, with this key line:
1 |
Watir::Container.instance_methods |
Element Objects
One thing to note is that I seem to be using some selectors that are specific to an HTML element, like “text_field”, “link”, and so on, but then I have what seem to be some very generically named ones called simply “element”. Why the difference? Well, first let’s talk about how the difference manifests. Let’s take the element I called “navigation”. Behind the scenes this looks like:#<Watir::HTMLElement:0x1c8c990ba9621aec located=true selector={:id=>"navList"}>However, if you actually inspect the element, it’s this:
#<Watir::Paragraph: located: true; selector={element: (webdriver element)}Which is ultimately just this:
#<Watir::Paragraph: located: true; {:id=>"navlist"}>Watir has a particular method called
to_subtype
and when a generic element selector is referenced, it is possible to get the subtype of that element. Tapestry handles this for you behind the scenes. (If you’re curious how, check out the access_element method, which does the work.) So what you see here is that the navigation element is actually of type paragraph. Which means I could have done this for the element definition:
1 |
p :navigation, id: "navlist" |
Test Script Uses Definitions
All these definitions are the organizing principle that Tapestry is putting in front of you. So now let’s change the rest of the test script to use those definitions. Here’s that portion:
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 |
Tapestry.set_browser Tapestry.move_to(0, 0) Tapestry.maximize page = Veilus.new page.view expect(page.test_gorilla).to exist page.open.click page.username.set! "admin" page.password.set! "admin" page.login.click expect(page.notice.text).to eq("You are now logged in as admin.") page.navigation.wait_until(&:dom_updated?).click page.stardate_on_nav.wait_until(&:dom_updated?).click expect(Tapestry.browser.title).to match 'Stardate Calculator' expect(Tapestry.browser.url).to match 'stardate' expect(page.stardate_logo).to exist Tapestry.quit_browser |
set
for a lot of actions, even when you might think you are “checking” or “choosing.” You might wonder why I say “set!” rather than just “set”. This has to do with some changes in how low-level browser drivers, like Watir, are starting to treat the entering of text.
A method with an exclamation is called a “bang method” and, in this case, what that does is have Watir use JavaScript to quickly fill the field with the specified text. This is as opposed to using the driver to send each character of text to the field, such as with Selenium’s send_keys
approach. In aggregate, you can get a small set of performance improvements with this kind of approach.
Waiting for Stuff: Revisited
Let’s talk a lines 42 and 43. Remember earlier I said examples like the below wouldn’t work?
1 2 3 |
browser.element(:id, "navlist").wait_until(&:visible?).click browser.element(:id, "navlist").wait_until(&:present?).click browser.element(:id, "navlist").wait_until(&:enabled?).click |
dom_updated?
as an addition to those. You’ll notice here that this entirely removed my need to be checking for some style on the element. To do this, Tapestry provides certain extensions to its base behavior. One of those is the DOM Observer which is, in turn, utilizing a JavaScript implementation of such an observers.
What this does is provide a JavaScript library-agnostic form of looking for mutation events that occur as part of the DOM. There is plenty information out there about this concept. Two articles that I like are
Using Mutation Observers to Watch for Element Availability and How to Track Changes in the DOM Using MutationObserver.
Any automation micro-framework should be considering this approach, in my opinion.
But notice how seamlessly this all fits together. Consider this line:
1 |
page.navigation.wait_until(&:dom_updated?).click |
1 |
navigation.wait_until(&:dom_updated?) |
1 |
navigation.wait_until { |element| element.dom_updated? } |