Encouraged a bit after the fact by a Ruby test framework in 15 minutes, along with my own post on a slimmed down Ruby test framework, and coupled with my Dialect experiment, I reworked an existing framework of mine (Symbiont) from another framework of mine (Fluent). Here I’ll explain how to get started with Symbiont.
The Symbiont Concept (In a Nutshell)
Symbiont is a Ruby gem that provides a convenience mechanism for automated testing with Watir-WebDriver, provided that you are willing to describe your application in terms of activity and page definitions. Page and activity definitions are conceptually similar (in fact, identical) to the concept of page objects, which was formerly known as the window driver pattern. With Symbiont, those definitions are proxied to Watir-WebDriver so that they can be used as the basis for executing tests against a web browser or web service.
For the most part, Symbiont is nothing more than a friendly, very thin API wrapper around Watir-WebDriver. Symbiont is also a mechanism to provide a particular organizing element around your automated tests, allowing you to use that organizing element as if it was built directly into Watir-WebDriver itself. Since Watir-WebDriver uses Selenium and the WebDriver API as its underlying implementation, you have full access to those as well.
The goal of Symbiont is to provide a very minimal DSL so that a fluent interface can be used for constructing test execution logic. This fluent interface provides for compressibility of your test logic, allowing for more factoring, more reuse, and less repetition. In fact, it’s really not even a DSL although it becomes an internal DSL when you use it with other Ruby-based tool solutions, such as RSpec, Spinach, Cucumber, or my own Specify tool. It’s important to note, however, that you don’t need those other tools. You can use Symbiont directly as an automated testing solution.
Using Symbiote
For purposes of this post, I’ll be showing how to write a script with Symbiont instead of using Watir directly. To execute this script, I’ll use my own Symbiote application. You can get this via my GitHub repo for it. Symbiote is a Sinatra application that you can run locally.
A Script — Without Symbiont
Let’s first consider how you might write a script using Watir-WebDriver. First, make sure you have the rspec and watir-webdriver gems available. Then create a script file and put the following in it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
require 'rspec' include RSpec::Matchers require 'watir-webdriver' @browser = Watir::Browser.new @browser.goto('http://localhost:9292') expect(@browser.url).to eq('http://localhost:9292/') expect(@browser.title).to eq('Symbiote') @browser.p(id: 'open').click @browser.text_field(id: 'username').set 'admin' @browser.text_field(id: 'password').set 'admin' @browser.button(id: 'login-button').click expect(@browser.div(class: 'notice').text).to eq('You are now logged in as admin.') |
You technically don’t need RSpec, but I include it so that I can use its expectation matchers. All the above script does is establish a Watir-WebDriver browser instance (in @browser
). That instance is then used to take you to the Symbiote home page. Once there, the script checks the url and the title of the page. After that, the script logs in by using certain elements on the page. Finally, the script checks that the login action worked by looking for an expected success message.
Now let’s attach the Symbiont and see what happens.
A Script — With Symbiont
Here I’m going to break down the above script, piece by piece. When that’s done, I’ll show the modified version in its entirety. Let’s start off the new script like this:
1 2 3 4 |
require 'rspec' include RSpec::Matchers require 'symbiont' |
Simple start, right?. Notice here that I don’t require the watir-webdriver gem anymore but I do require the symbiont gem. The Symbiont framework will require watir-webdriver for you as well as any supporting dependencies. The goal is to reduce the boilerplate needed in order to get your testing going and also to remove the need to worry about specific versions of, say, Selenium. However, to check out what supporting test library versions are being used, add the following:
1 2 3 4 5 |
require 'rspec' include RSpec::Matchers require 'symbiont' puts Symbiont.version |
That line will let you check what version of Symbiont you are running as well as what specific versions of Watir-WebDriver and Selenium-WebDriver are being used.
Do note that Symbiont does not require RSpec so that still has to be included. The reason RSpec is not required for you is simply because you don’t have to use it; you can use other test runner frameworks if that’s your choice.
The basic operation of Symbiont is to wrap the functionality of a driver library and provide a convenient means of using page definitions and activity definitions — along with that driver — to execute tests against an application. This is effectively following the page object design pattern because page definitions are used by Symbiont, if they are found as part of your script. You don’t have to use page definitions but, if you don’t, then you may as well use Watir-WebDriver directly.
Okay, but how do you use page definitions with Symbiont. Essentially, by converting the page definition to a page object.
Page Definitions
So let’s create a page definition for the Symbiote home page.
1 2 3 4 5 6 7 8 |
require 'rspec' include RSpec::Matchers require 'symbiont' class Symbiote attach Symbiont end |
The addition here is the class called Symbiote. Symbiote is nothing more than a Ruby class. I could have named this class anything I wanted. Since it’s the home page of the application, I could have, for example, simply named it Home.
What makes this class a page definition is that Symbiont is attached to it. Note that attach
is basically a synonym that Symbiont provides for the Ruby include
action. I personally like the fact that Ruby allows me to add some semantic sugar, if you will. But if you find that offensive to your programming sensibilities, you could just do this:
1 2 3 |
class Symbiote include Symbiont end |
Attaching the Symbiont means that the Symbiote class is treated as a special kind of class with some extra functionality. So essentially what you do is turn a generic Ruby class into a Symbiont-specific class. This makes Symbiont available to any test logic that uses that page definition class as part of its testing.
Incidentally, this separation of concerns, and a bit of inversion of control, is the primary factor that makes Symbiont a test framework rather than a test library or test engine. Similar to tools like JUnit or NUnit, you don’t run Symbiont directly. You simply execute a test script and if Symbiont has been attached (included) in that script it will do things for you. What it will do, however, depends on what other code you have in place. So, unlike a library, your code doesn’t necessarily call Symbiont; Symbiont calls your code.
Keep in mind that a page definition is meant to represent an actual page that will show up in a browser. What the definition is going to do is provide the specification for that page so that Symbiont understands how to work with the page itself and anything contained on or in it. It may be obvious but just to drive home the point: the reason you would create a page definition is because you plan to run automated tests against the page that the definition represents.
Page Objects
Now let’s use that page definition and create a page object:
1 2 3 4 5 6 7 8 9 10 11 12 |
require 'rspec' include RSpec::Matchers require 'symbiont' class Symbiote attach Symbiont end Symbiont.set_browser page = Symbiote.new |
First notice that you can get rid of the cumbersome call to the Watir::Browser. Specifically, the line @browser = Watir::Browser.new
gets wrapped up in a simple call to a Symbiont.set_browser
method. That method can take parameters to specify the specific browser type that Watir-WebDriver will be using but if no parameters are passed, as above, then the default browser for Watir-WebDriver will be used. That default is always Firefox, which is a convention that goes all the way down to Selenium.
You’ll also notice that I instantiate an instance of Symbiote, storing that instance in a variable. This is what turns the page definition into the page object. What you can’t see, because it happens behind the scenes, is that a @browser
reference is passed in to the page definition. Where does the @browser
come from? The call to set_browser
is what created the @browser
instance. Passing that @browser
instance to the page definition means that a Symbiont controlled page object will be wrapped around the driver.
A little confusing? The good news is you don’t have to worry about. But, if you’re like me, you will worry about it because you want to know what’s going on behind the scenes. So let’s briefly look at that.
You don’t need to add this next bit to your script, but you could confirm some of the internals of how Symbiont is working with the following:
1 2 3 4 |
expect(page).to be_a_kind_of(Symbiont) expect(page).to be_an_instance_of(Symbiote) expect(Symbiont.browser).to be_an_instance_of(Watir::Browser) expect(Symbiont.browser.driver).to be_an_instance_of(Selenium::WebDriver::Driver) |
So do note that while there is a @browser
instance behind the scenes, you do have to refer to it in the context of Symbiont. This is important in case you have other test frameworks that similarly create a @browser
instance variable. In other words, this is what allows Symbiont to play nicely with other tools you may be using. Details are encapsulated in the Symbiont namespace. You might also notice how I can retain access to the driver library — Watir — and also the underlying driver for it: Selenium. This means I can access any methods that exist on those libraries.
Those lines (using RSpec matchers) should all pass and they show you what things actually are. If you were to inspect the Symbiont.browser and page variables, you would find they look like this:
Symbiont.browser (@browser) = <Watir::Browser:0x7358936c url="about:blank" title=""> page = <Symbiote:0x3c1f410 @browser=#<Watir::Browser:0x7358936c url="about:blank" title="">>
Notice how the @browser
is wrapped up in the page
.
Now let’s see how you can view the page that represents Symbiote. Remember in the original Watir-WebDriver script, this was done via a simple command:
1 |
@browser.goto('http://localhost:9292') |
Symbiont provides a way to encode the URL in the page definition. That requires understanding assertion definitions. Broadly speaking, each page definition can have declarations on it. These declarations will describe the contents of the page or aspects of the page. These declarations take the form of assertion definitions and element definitions.
Assertion Definitions
An assertion definition allows you to assert aspects of state or context that can then be either directly used or checked for. So let’s fill out our page definition with one particular assertion definition:
1 2 3 4 5 |
class Symbiote attach Symbiont url_is 'http://localhost:9292' end |
You can see the addition of url_is
, which I conceptually refer to as an assertion definition but is basically a method defined on the class. That method takes an argument which is the URL of the page represented by the page definition. With that in place, to go to the Symbiote home page using Symbiont, just add this to the script:
1 2 3 4 |
Symbiont.set_browser page = Symbiote.new page.view |
You just call a view
method on the page object itself.
So let’s consider where url_is
and view
come from. They both come from Symbiont. Symbiont allows but does not require you to define a url_is
method on a page class. If you provide that method, Symbiont will be able to call to it. Specifically, if you use the view
method on a valid page object then you will cause Symbiont to check if there is a valid URL (provided by url_is
) and, if so, Symbiont will delegate to Watir-WebDriver to go to that URL via the browser driver provided which, by default, will be Firefox.
As a note, let’s say you didn’t provide a url_is assertion. Can you still view the page? You can but then you have to call a visit
method. So you could do this instead:
1 2 3 4 |
Symbiont.set_browser page = Symbiote.new page.visit('http://localhost:9292') |
That being said, the goal is to put as much information in the page definitions as you can so that the page definition is, as much as possible, a single source of truth about the page. If the page URL ever changed, you would simply have to change it in the page definition, and the call to view
would always work. If you go the visit
route, URLs — an implementation detail — start getting interspersed in your tests.
Now let’s move on to checking the url and title of the page that we navigated to. Keep in mind the original attempt looked like this:
1 2 |
expect(@browser.url).to eq('http://localhost:9292/') expect(@browser.title).to eq('Symbiote') |
With Symbiont, add the following to your script:
1 2 |
expect(page.url).to eq('http://localhost:9292/') expect(page.title).to eq('Symbiote') |
The only difference there is that I call the methods on the page object rather than the @browser. Not much of a change, right? Yet this should show you that knowledge of the Watir-WebDriver API is not lost or unavailable just because you are using Symbiont. That said, Symbiont does provide another means to get the information and it will require two more assertions. Here is a modified page definition with those assertions added:
1 2 3 4 5 6 7 |
class Symbiote attach Symbiont url_is 'http://localhost:9292' url_matches /:\d{4}/ title_is 'Symbiote' end |
Here you can see the addition of url_matches
and title_is
assertions. The former takes a string literal or regular expression that indicates what the actual URL in the browser should match. The latter takes the title of the page or a regular expression that matches some aspect of the title displayed in the browser. When those assertions are in place, two more methods are exposed by Symbiont that allow you to check the state of the URL and the title. So you could change your original actions to:
1 2 |
expect(page.has_correct_url?).to be_truthy expect(page.has_correct_title?).to be_truthy |
Note that if you are using RSpec, as I am in this post, you can rely on even more syntactic sugar for testing these assertions. Specifically, since Symbiont defines those methods as predicate methods you can check for their state like this:
1 2 |
expect(page).to have_correct_url expect(page).to have_correct_title |
As a side note, you can check both of these states — title and url — with a single action like this:
1 |
expect(page.verified?).to be_truthy |
With the RSpec predicate approach, you can also check for this via:
1 |
expect(page).to be_verified |
Verification, in this case, means checking both the title and URL. You might have cases where you don’t want to check the title at all. In that case, you can just use displayed?
:
1 |
expect(page.displayed?).to be_truthy |
With the RSpec predicate approach, you can also check for this via:
1 |
expect(page).to be_displayed |
So to make sure this is clear: if you want to use a verified?
approach, you must provide the url_matches and title_is assertion definitions on your page definition. If you want to use a displayed?
approach, you only need to provide a url_matches assertion definition. You might wonder why Symbiont doesn’t just require the url_is and then match on that to see if a page is displayed. The reason is that sometimes going to a URL takes you to another URL or adds parameters to the existing URL. As such, the url_matches assertion definition allows you to be more flexible in what it takes to determine if the correct page is displayed.
Element Definitions
Now let’s handle the meat of the script: actually logging in. Within your page definition, element definitions can be established. These definitions will provide Symbiont with declarations of elements that exist on a given page. Here’s a modified page definition:
1 2 3 4 5 6 7 8 9 10 11 12 |
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' end |
The element definitions follow a common pattern.
selector :friendly name locator
First 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, such as how I used login_form
above. You could also use camel notation (loginForm
) if you prefer.
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.
Now let’s put those to use in our script. Here’s how you originally performed the actions:
1 2 3 4 |
@browser.p(id: 'open').click @browser.text_field(id: 'username').set 'admin' @browser.text_field(id: 'password').set 'admin' @browser.button(id: 'login-button').click |
And here’s how you do it with Symbiont, using element definitions on a page definition:
1 2 3 4 |
page.login_form.click page.username.set 'admin' page.password.set 'admin' page.login.click |
You’ll notice that in the above example of element definitions, all of the locators are specified by the id. However you can use other valid locators. In fact, any locator that Watir or Selenium can use, you can use as well.
Those of you who have used Watir or Selenium may wonder about the “text_field” part. If you look at the markup of the Symbiote home page, you’ll see that these fields are actually, in true web development fashion, “input” fields. So why don’t I use “input” above? Well, you can. You could change the element definitions to this:
1 2 |
input :username, id: 'username' input :password, id: 'password' |
The problem is that you cannot then use the actions as you have them because set
cannot be called on an “input” field. This is because “input” fields are generic and are associated with subtypes in HTML, like “text” or “button”. If you did want to use the above, you could change your execution logic to look like this:
1 2 3 4 |
page.login_form.click page.username.to_subtype.set 'admin' page.password.to_subtype.set 'admin' page.login.click |
I’m not sure that doing this makes a whole lot of sense, but it’s what the underlying browser drivers would expect. Incidentally, this would also apply if you wanted to use the generic element
for all of your element definitions. In Watir and Selenium, element
stands in for any old element. So you could, if you wanted to normalize your element definitions, do this:
1 2 3 4 |
element :login_form, id: 'open' element :username, id: 'username' element :password, id: 'password' element :login, id: 'login-button' |
I’ve removed the specific element type and replaced it with the generic element
. If you do this, your script would then still look like this:
1 2 3 4 |
page.login_form.click page.username.to_subtype.set 'admin' page.password.to_subtype.set 'admin' page.login.click |
You need the to_subtype
for any HTML elements that can be subtyped. Do notice that it can get confusing, though. Why doesn’t my click of the login button require this:
1 |
page.login.to_subtype.click |
Logically it should, given that the button on the page is actually just another input element with a subtype of “submit”. But that’s simply not how the underlying drivers handle it.
To my way of thinking, the point of a page definition is that it can serve as a specification of what an actual page should look like. As such, some tend to think of “buttons”, “text fields”, and so on rather than as “inputs” or “elements”. The specificity makes the page definition more of a specification than it otherwise would be.
Verification
Let’s tackle the last part: the validation of the logged in message. This requires an additional element definition:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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 |
The original logic for checking the message was this:
1 |
expect(@browser.div(class: 'notice').text).to eq('You are now logged in as admin.') |
That becomes the following when using the element definition:
1 |
expect(page.message.text).to eq('You are now logged in as admin.') |
The Finished Script
And that’s it. So here’s a cleaned up script, just with the Symbiont logic:
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.') |
What you’ll probably see there is that the Symbiont script reads a little cleaner; it’s a bit more concise and a bit more semantic, for lack of a better term. You’ll also notice that a lot the work gets delegated to the page definition. The Symbiont framework will call out to your page definitions when those definitions provide certain aspects, such as assertion and element definitions.
I’ll have more to say about Symbiont in upcoming posts, including how to make the above script even more concise and to delegate even more to the page definition.