In my previous post about the start of my Symbiont project, I gave a few examples of what I can do now with libraries like Selenium and Watir. It probably wasn’t entirely clear what direction Symbiont was going to go, given what I described there. Here I’ll to provide a bit more of a roadmap for what Symbiont will need to accomplish.
The test scripts I showed in the last post were ones that you tend to find when people are just starting out with these tools. Here I want to explore a bit about how those scripts tend to evolve, which should then show us the direction that Symbiont will have to evolve as well.
The first thing to notice about those examples is that all of the script logic is essentially contained within the script itself. It’s just one large mash of code. Further, since Selenium and Watir differ in some of their implementation details, those test scripts look quite a bit different in some very specific ways. Yet those underlying details should ideally be hidden from the test scripts. So here’s what I would like my test script to look like:
Test Script Example 1
1 2 3 4 5 6 7 |
action = ActivityDefinition.new(@browser) action.start action.login_as("QA", "administrator", "password") action.look_for_book("Revelation Space") action.select_research_area("Tachyonic Antitravel") action.do_research_for("Radon") |
Before I had a url_is() method and that was called with a view() method. Here I’m doing something that is conceptually different but is actually the same thing: a begin_at() method that is called with a start() method. I’ll come back to why this is relevant a bit later on.
As it turns out, this is quite possible and below I’ll show you what this would require in terms of supporting logic. First here’s the Selenium version that makes the above possible:
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 43 44 45 |
require 'symbiont' class ActivityDefinition include Symbiont begin_at "http://localhost:4567" def login_as(client, user, password) @browser.switch_to.frame("loginSection") @browser.find_element(id: "clientCode").send_keys(client) @browser.find_element(id: "loginName").send_keys(user) @browser.find_element(id: "password").send_keys(password) @browser.find_element(id: "btnSubmit").click @browser.switch_to.default_content end def look_for_book(title) wait = Selenium::WebDriver::Wait.new(:timeout => 10) @browser.find_element(id: "testPage").click element = wait.until { @browser.find_element(id: "title") } element.send_keys(title) end def select_research_area(subject) list = @browser.find_element(id: "concepts") options = list.find_elements(tag_name: "option") options.each do |item| if item.attribute('text').eql? subject item.click end end end def do_research_for(element) count = 0 @browser.find_elements(xpath: "//table[@id='atomic']/tbody/tr").each do |item| count += 1 if item.text.split[0] == element puts "Symbol: #{@browser.find_elements(:xpath => "//table[@id='atomic']/tbody/tr")[count-1].text.split(' ')[1]}" puts "Atomic Number: #{@browser.find_elements(:xpath => "//table[@id='atomic']/tbody/tr")[count-1].text.split(' ')[2]}" end end end end |
And here’s the Watir version:
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 |
require 'symbiont' class ActivityDefinition include Symbiont begin_at "http://localhost:4567" def login_as(client, user, password) @browser.frame(id: "loginSection").text_field(id: "clientCode").set client @browser.frame(id: "loginSection").text_field(id: "loginName").set user @browser.frame(id: "loginSection").text_field(id: "password").set password @browser.frame(id: "loginSection").button(id: "btnSubmit").click end def look_for_book(title) @browser.link(id: "testPage").click @browser.text_field(id: "title").when_present.set(title) end def select_research_area(subject) @browser.select_list(id: "concepts").select(subject) end def do_research_for(element) table = @browser.table(id: "atomic").when_present table.rows.each do |row| if row.text.split[0] == element puts "Symbol: #{row[1].text}" puts "Atomic Number: #{row[2].text}" end end end end |
One thing you’ll probably notice here is that Watir is often a lot easier to deal with than Selenium. Granted, that’s a subjective opinion but to me not only does Watir logic look cleaner, but it seems less convoluted to write as well. Regardless, the important thing here is that the my Test Script Example 1 will work regardless of the browser library I’m using. Put another way, the test script will work the same regardless of which activity definition is referenced.
Wait … activity what? In my previous post, I had talked about a distinction between “page definitions” and “activity definitions.” The above are essentially activity definitions in that it’s a class that gets instanced and provides methods that allow test scripts to reveal more of their intent. This is because the implementation is somewhat “hidden” — or at least behind the scenes — in the definitions.
This, in fact, is why I now provide a begin_at() method. Activity definitions suggest a workflow that begin at a certain point. Page definitions suggest nothing more than a specific page at a specific location that you visit.
Okay, so I hid the implementation behind the intent. And that brings up an interesting point. Is it possible to not only more closely align the intent parts of the logic, but also to more closely align the implementation parts? After all, if you compare the above logic to what I showed in my last post, I really haven’t done anything all that terribly different. The same logic I was using in the previous examples is pretty much the same logic I’m using now. The difference is that I’ve relegated the handling of this to methods in the activity definition, leaving my test script relatively concise.
So is it possible to subsume the differences of Selenium and Watir even further, providing a layer on top of them that allows testers to design logic that can use either library? If so, that might be a short step to providing a layer on top of other libraries beyond just Selenium and Watir. That is what a library like Symbiont should eventually evolve into.
As an example of how this stuff can evolve, let’s consider how the common page object design pattern came about. What I’ve described as “page definitions” and “activity definitions” are really just the page object design pattern being put to use. The reason I don’t use that term is because I think that a rigid adherence to the idea of page objects takes the focus off of business workflows. That’s a topic for a different day, however. For now, consider this test script logic:
Test Script Example 2
1 2 3 4 5 6 7 |
page = Login.new(@browser) page.view page.client = "QA" page.user = "administrator" page.password = "password" page.login |
That’s pretty simple, right? It’s fairly easy to see what’s happening. Here is what the logic that would support this looks like in Selenium:
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 Login include Symbiont url_is "http://localhost:4567" def client=(client) @browser.switch_to.frame("loginSection") @browser.find_element(id: "clientCode").send_keys(client) @browser.switch_to.default_content end def user=(user) @browser.switch_to.frame("loginSection") @browser.find_element(id: "loginName").send_keys(user) @browser.switch_to.default_content end def password=(password) @browser.switch_to.frame("loginSection") @browser.find_element(id: "password").send_keys(password) @browser.switch_to.default_content end def login @browser.switch_to.frame("loginSection") @browser.find_element(id: "btnSubmit").click @browser.switch_to.default_content end end |
Here is that same logic in Watir:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Login include Symbiont url_is "http://localhost:4567" def client=(client) @browser.frame(id: "loginSection").text_field(id: 'clientCode').set(client) end def user=(user) @browser.frame(id: "loginSection").text_field(id: 'loginName').set(user) end def password=(password) @browser.frame(id: "loginSection").text_field(id: 'password').set(password) end def login @browser.frame(id: "loginSection").button(id: 'btnSubmit').click end end |
Notice how this second test script is a bit lower level than what I just showed you before, however. My previous scripts were focused on providing higher-level method names that indicated what you wanted to do. So, in the first case, you had a login_as() method that handled the details of what fields to use. Yet consider that method again. First in Watir:
1 2 3 4 5 6 |
def login_as(client, user, password) @browser.frame(id: "loginSection").text_field(id: "clientCode").set client @browser.frame(id: "loginSection").text_field(id: "loginName").set user @browser.frame(id: "loginSection").text_field(id: "password").set password @browser.frame(id: "loginSection").button(id: "btnSubmit").click end |
Then in Selenium:
1 2 3 4 5 6 7 8 |
def login_as(client, user, password) @browser.switch_to.frame("loginSection") @browser.find_element(id: "clientCode").send_keys(client) @browser.find_element(id: "loginName").send_keys(user) @browser.find_element(id: "password").send_keys(password) @browser.find_element(id: "btnSubmit").click @browser.switch_to.default_content end |
I would like to combine those methods with the easier way of referring to the objects that Test Script Example 2 shows. In short, I want this:
1 2 3 4 5 6 |
def login_as(client, user, password) page.client = client page.user = user page.password = password page.login end |
Can I have that? You can and this is where you get into traditional page objects. To click a particular button (that happens to be in an iframe), Selenium says I must do this:
1 2 3 |
@browser.switch_to.frame("loginSection") @browser.find_element(id: "btnSubmit").click @browser.switch_to.default_content |
Watir says I must do this:
1 |
@browser.frame(id: "loginSection").button(id: "btnSubmit").click |
So what I’m saying here is that I want the page.login statement in Test Script Example 2 to make whatever call is necessary to execute the appropriate logic.
The Symbiont way of doing this is to provide the platform object. The platform will indicate what browser driver is being used. An action — such as clicking a button or typing text — will be passed into the library and will be routed by the platform object to code that is either Selenium-specific or Watir-specific.
So I have the future path that I need to take at this point. My next steps will be to work on handling the most common web page objects: links, text fields, and buttons.