Symbiont is a little further along, enough so that it makes sense to look at the basic idea of what it means to provide a façade over two tools. The focus here is really just to show you some tool construction from the ground up. This may help you understand tools like page-object or watir-page-helper a little bit or they may spur on your own creative efforts as you build your own tools.
You can play along if you want, assuming you have Ruby and Rubygems installed feel free to install the gem:
gem install symbiont
The example code I will be showing here is meant to run against my simple Sinatra web application. You could grab that from GitHub and, from the project directory, just do this:
ruby app.rb
Alternatively you could change the (minimal) code I will show you to run against a web site of your choosing. You will, of course, have to change the web object declarations, but we’ll get to that in a bit.
To start off a script, you need to include the Symbiont library. So create a script called symbiont_test.rb and put the following in it:
1 |
require 'symbiont' |
Now let’s create a page definition. In this case, the page definition will be the traditional page object:
1 2 3 4 5 |
class TestPage include Symbiont url_is "http://localhost:4567" end |
Here you can see that the definition (which, yes, is essentially a page class) incorporates the Symbiont library by including it. When you incorporate Symbiont into a definition, a couple of things happen.
- A series of helper methods are provided that allow you to declare web objects in your page definition.
- A “platform object” is created that provides an abstraction layer over those web objects.
The declarations allowed provide a sort of convention-over-configuration way of referencing and accessing those web objects. The platform object provides a layer around the objects so that any interactions you have with those web objects will look the same in your test scripts, regardless of the web or browser API you are using.
So let’s put this to the test. Let’s declare some simple web objects:
1 2 3 4 5 6 7 8 9 |
class TestPage include Symbiont url_is "http://localhost:4567" link (:test_page, id: "testPage") button (:clickme, id: "clickme_id") text_field (:book_title, id: "title") end |
Here the “link”, “button” and “text_field” lines are methods. Those are methods that, in Symbiont, are provided by the Generators module. These lines are declarations in that they declare web objects that exist in a given context.
The first argument to each web object method is a friendly name for the web object. It’s what you want to call the object in your tests. The second argument is an identifier that will tell Selenium or Watir how to recognize that object. Here I’ve used the id attribute. The attributes that you can use in the same way with both Selenium and Watir are class, id, name, xpath, and index.
I’ll get to what these generators are doing momentarily. For now, I can create my browser instance. If I want to be able to run my script with either Selenium or Watir, I can conditionalize accordingly:
1 2 |
@browser = Selenium::WebDriver.for(:firefox) if ARGV[0] == 'selenium' @browser = Watir::Browser.new(:firefox) if ARGV[0] == 'watir' |
Here I’ve hardcoded Firefox as the browser to run against but you could also make that an argument of the script as well. Now I will create an instance of my page definition:
1 2 |
activity = TestPage.new(@browser) activity.view |
This creates what some would call a “page object.” The call to the view() method works because there is a url_is() method that provides a URL. So the call to view() is basically saying “view this page.” A check is made if there is a url_is() method and if that method has a URL string. If so, the URL will be called up in the browser instance.
Now what I’m going to do is show you some code that is going to reference each of the above web objects and then interact with each web object.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
test_link = activity.test_page_object puts test_link activity.test_page test_text_field = activity.book_title_object puts test_text_field activity.book_title = "Revelation Space" book = activity.book_title puts "Book title is '#{book}'." test_button = activity.clickme_object puts test_button activity.clickme |
Let’s break it down. In terms of referencing the web objects, the following lines from above do that:
1 2 3 |
test_link = activity.test_page_object test_text_field = activity.book_title_object test_button = activity.clickme_object |
Here I’m getting a reference to each of my web objects. Notice that I suffix the web object friendly name with _object. This is because in order to get a reference to the web object itself, as opposed to interacting with the object, I need a syntax that makes it clear what I’m doing. Incidentally, the references stored for each of the above will look like something like this:
#<Symbiont::WebObjects::Link:0x2c3d360> #<Symbiont::WebObjects::TextField:0x2bc4010> #<Symbiont::WebObjects::Button:0x471f3a8>
I could also use the type of web object in place of the generic phrase “_object.” For example, the above code snippet could have looked like this:
1 2 3 |
test_link = activity.test_page_link test_text_field = activity.book_title_text_field test_button = activity.clickme_button |
Now let’s look at the web objects being interacted with:
1 2 3 4 5 6 7 8 9 |
# This is for the link activity.test_page # This is for the text field activity.book_title = "Revelation Space" book = activity.book_title # This is for the button activity.clickme |
What you’ll notice here is that if you just refer to the web object itself — without the suffix _object — then a certain default action takes place. The link, for example, will be clicked. So will the button. However the text field acts differently. Simply referring to the web object for a text field gets you the contents of the text field. Text fields have another ability, which is the ability to have text entered into them. You can see that the call for this is to refer to the web object with an assignment.
This all works because behind the scenes the Generators module that I mentioned before lived up to its name: it generated methods based on whatever you called the web object. So, if you have a web object declaration like this:
1 |
text_field (:book_title, id: "title") |
This will cause the following methods to be generated:
book_title_object book_title_text_field book_title= book_title
The first two are equivalent and allow you to get a web object reference. The third is a method to set the contents of the object and the fourth is a way to retrieve the contents of the object. This is purely a convention that the Generators module imposes so that web objects of different types have relatively standard ways to interact with them.
Note that I could have used the web object reference I got to perform actions, which I didn’t do earlier. So first consider a slight variation on what I did do:
1 2 3 4 5 |
title_of_book = activity.book_title_object activity.book_title = "Revelation Space" book = activity.book_title puts "Book title is '#{book}'." |
Note here that whild I could have used my title_of_book reference, I did not. I could have done this:
1 2 3 4 5 |
title_of_book = activity.book_title_object title_of_book = "Revelation Space" book = title_of_book puts "Book title is '#{book}'." |
Here you can see I’m using the object reference I got. This can allow you to be a little more expressive in your code.
Again, you can run this exact same script with both Selenium and Watir and it will work the same. How? Well, let’s just take one example. Let’s go back to some logic that sets the book title:
1 |
title_of_book = "Revelation Space" |
Since this was declared as a text_field web object, the platform object makes sure that the appropriate generator — text_field — is called. Here is that generator:
1 2 3 4 5 |
def text_field(identifier, locator) define_method("#{identifier}=") do |value| @platform.set_text_field_value_for(locator.clone, value) end end |
The script will call this logic regardless of what tool you are using to drive the browser. If you are using Watir, the platform object will redirect the action to this method:
1 2 3 |
def set_text_field_value_for(locator, value) @browser.instance_eval "text_field(locator).set(value)" end |
If you are using Selenium, the following method will be called:
1 2 3 4 |
def set_text_field_value_for(locator, value) @browser.find_element(locator.keys.first, locator.values.first).clear @browser.find_element(locator.keys.first, locator.values.first).send_keys(value) end |
For those who like behind the scenes details, here are what platform objects look like:
#<Symbiont::Platforms::WatirWebDriver::PlatformObject:0x2cce338> #<Symbiont::Platforms::SeleniumWebDriver::PlatformObject:0x2cef5d8>
Here is what the browser objects look like:
#<Watir::Browser:0x359e638> #<Selenium::WebDriver::Driver:0x2ae2f80>
Here are what page definitions look like when they are established:
#<TestPage:0x2b214a0 @browser=#<Watir::Browser:0x7f2882b2 url="http://localhost:4567/login" title="Login | Test App">, @platform=#<Symbiont::Platforms::WatirWebDriver::PlatformObject:0x2b21338 @browser=#<Watir::Browser:0x7f2882b2 url="http://localhost:4567/login" title="Login | Test App">>> #<TestPage:0x2ae2ec0 @browser=#<Selenium::WebDriver::Driver:0x320aa410 browser=:firefox>, @platform=#<Symbiont::Platforms::SeleniumWebDriver::PlatformObject:0x2ae2e30 @browser=#<Selenium::WebDriver::Driver:0x320aa410 browser=:firefox>>>
You can see how the page definition contains the browser object as an instance variable as well as the platform object as an instance variable.
A few final words here about the drivers. The selenium-webdriver API is a low-level driver that controls the browser. This specifically means it’s running Selenium against the WebDriver API, which connects with a browser and allows actions to be sent to that browser. The watir-webdriver is basically a slightly friendly gloss on top of selenium in that Watir is more of a browser API, allowing you to do certain things with much less code. However, watir-webdriver it still uses selenium-webdriver to control the browser.
Those are your testing tools. Symbiont is not a testing tool. So what is it?
Symbiont is its own DSL in that it serves as an abstraction layer that sits over Selenium and Watir. Thus Symbiont is not a test execution tool but rather wraps test execution tools under a common format. As a test solution, Symbiont is designed as an interface as opposed to a standalone application.
Put another way, Symbiont is designed as a middle state as opposed to an end state. This is why Symbiont is not a testing tool but, rather, a testing interface. This interface can — as one aspect of its operation — be utilized by automated testing tools. Symbiont can also be utilized by BDD-type acceptance specification tools.
My next iterations of Symbiont will be a refinement of the Generators approach. Again, I’ll state that I’m not doing anything all that magical here and certainly nothing all that unique. But one thing I’ve noticed is that with many of these tools out there, there is not good explanation of how the tools come about and that’s often what I want to know. The rationale for how a testing tool is constructed is often as important as how the tool itself works.