I’m going to continue introducing Symbiont in this post. In this post I’ll focus on the various ways that a script can be constructed.
It will certainly be a bit helpful if you check out the first part of this series. Here I’ll follow on from that but I’ll go through creating a few files just so I can keep the important material concise and focused.
First, let’s create harness.rb and put the following in it:
1 2 3 4 5 6 |
require 'symbiont' require 'rspec/expectations' include RSpec::Matchers require_relative 'decohere' |
Here I’m just putting most of the requires in a common place. Now let’s create the decohere.rb file. This is going to be where the page definitions go for this example. Put the following in that file:
1 2 3 4 5 6 7 |
class Decohere attach Symbiont url_is 'https://decohere.herokuapp.com/stardate' url_matches /stardate/ title_is 'Decohere - Stardate Calculator' end |
Conceptually this is where we left off in the last post in terms of how things tie together. So now let’s talk about interacting with web pages via the page definitions and Symbiont scripts.
Element Definitions
You can add element definitions to the page object. 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. 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.
You can see more about element declarations on the Symbiont GitHub wiki.
There are actually two ways you can provide these element declarations. With Watir you can use the type of element:
1 2 3 4 5 6 7 8 9 10 11 12 |
class Decohere attach Symbiont url_is 'https://decohere.herokuapp.com/stardate' url_matches /stardate/ title_is 'Decohere - Stardate Calculator' checkbox :enable_form, id: 'enableForm' radio :tng_era, id: 'tngEra' text_field :stardate, id: 'stardateValue' button :convert, id: 'convert' end |
A brief digression here. With the above element declarations in place, if you were to run the following:
1 |
puts page.public_methods(false) |
This will tell you what methods are immediately available on your page. You will see that putting element declarations on a page automatically converts those declarations into method calls.
Getting back to the example, some people don’t like doing the approach I showed (i.e., specific selectors like “checkbox”) because they feel that with this approach they have to tie the implementation detail of the element into the page definition. So you can do this instead:
1 2 3 4 5 6 7 8 9 10 11 |
class Decohere attach Symbiont url_is 'https://decohere.herokuapp.com/stardate' url_matches /stardate/ title_is 'Decohere - Stardate Calculator' element :enable_form, id: 'enableForm' element :tng_era, id: 'tngEra' element :stardate, id: 'stardateValue' element :convert, id: 'convert' |
There actually is a difference between these two approaches. Taking just one of the elements, say “enable_form”, using the element
selector gets you an object like this:
#<Watir::HTMLElement:0x1c8c990ba9621aec located=false selector={:id=>"enableForm"}>
If you use the checkbox
selector, you will get an object like this:
#<Watir::CheckBox:0x12f8b8a205180b44 located=false selector={element: (webdriver element)}>
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. Symbiont handles this for you behind the scenes.
What the above element declarations get you is a way to specify what the page “looks like”, in terms of what is located on it. Your automated scripts will be able to use this information to drive actions against the browser. Let’s consider that next.
A Symbiont Script
First let’s create a script.rb file. This is going to use the harness file created earlier. This script shows how you would use the elements you specified as part of the page definition:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#!/usr/bin/env ruby require_relative 'harness' Symbiont.set_browser page = Decohere.new page.view page.enable_form.set page.tng_era.set page.stardate.set '54868.6' page.convert.click Symbiont.browser.quit |
Here I’m simply calling Watir methods (set, click) on the objects that represent the element declarations. You’ll find that Watir uses set
for a lot of actions. But you could also use other synonyms for this. For example the above action lines could be replaced by these:
1 2 3 4 |
page.enable_form.check page.tng_era.choose page.stardate.enter '54868.6' page.convert.click |
As I mentioned in the first post, Symbiont is simply wrapping the Watir API (and the Selenium API). This means you can use anything you learn about those two libraries with Symbiont. You might want to check out the Watir-WebDriver Web Elements for more details if you are unfamiliar with Watir operations.
Variations of Scripting
The above approach of using specific elements as part of your test scripts is probably not the best way to go about things. It makes your scripts heavily dependent upon a series of coordinated actions with specific elements to achieve some purpose. A better approach is to have a method on the page definition that states the purpose and then in turn exercises all of the elements as necessary. This keeps implementation details as far removed from your scripts as is responsible.
Let’s consider a full page definition 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 43 44 45 46 47 48 49 50 51 |
class Decohere attach Symbiont url_is 'https://decohere.herokuapp.com/stardate' url_matches /stardate/ title_is 'Decohere - Stardate Calculator' checkbox :enable_form, id: 'enableForm' radio :tng_era, id: 'tngEra' text_field :stardate, id: 'stardateValue' text_field :calendar, id: 'calendarValue' button :convert, id: 'convert' def convert_tng_stardate(value) set_tng_era set_stardate(value) convert.click end def calculate_tng_stardate(value) convert_tng_stardate(value) require 'time' stardates_per_year = value.to_f * 34367056.4 origin = Time.new(2318, 7, 5, 12, 0, 0) milliseconds = ((origin.to_f * 1000).to_i) + stardates_per_year result = Time.at(milliseconds/1000.0).strftime("%Y-%m-%d %H:%M.%S") calendar = DateTime.parse(result).to_date end def enable_stardate_form enable_form.set expect(enable_form.set?).to be_truthy end def set_tng_era enable_stardate_form tng_era.set expect(tng_era.set?).to be_truthy end def set_stardate(value) stardate.set value expect(stardate.value).to eq(value) end def stardate_value(value) calculate_tng_stardate(value) end end |
There’s a lot going on there. Feel free to take some time and look at what’s going on. You could argue that I’ve broken out too many methods. You could even argue I’ve made too few. That’s the interesting thing about page definitions. They are entirely up to you in terms of what you want to expose. For example, looking at the above example you could probably decide whether or not to make certain methods provide so you lower the surface area exposed to scripts.
Here I won’t be covering every nuance of what is or is not a good page object pattern. Now let’s consider how we can change our original script to utilize these methods. To convert the stardate in this example, the above script can become:
1 2 3 4 5 6 7 8 9 10 11 |
#!/usr/bin/env ruby require_relative 'harness' Symbiont.set_browser page = Decohere.new page.view page.calculate_tng_stardate('54868.6') Symbiont.browser.quit |
Notice how that call to view
seems a little extraneous. It’s definitely necessary but it still seems like it sticks out like a sore thumb. You could actually just combine the calls like this:
1 2 |
page = Decohere.new page.view.calculate_tng_stardate('54868.6') |
There is another approach you can take, which is just a slight variation on the above:
1 |
page.perform.calculate_tng_stardate('54868.6') |
Here perform
stands in for view
and this is done just to make things a bit more expressive, as if you are telling the page to “perform” an action.
Some automated scripters really like to use Ruby to make things as concise as possible. Going back to the original script, you could, for example, do this:
1 2 3 4 5 6 7 |
Decohere.new do view enable_form.set tng_era.set stardate.set '54868.6' convert.click end |
Or by calling the perform method:
1 2 3 |
Decohere.new do perform.calculate_tng_stardate('54868.6') end |
Technically you don’t even need the block:
1 |
Decohere.new.perform.calculate_tng_stardate('54868.6') |
You can also call a block on the view action itself like this:
1 2 3 4 5 |
page = Decohere.new page.view do |action| action.calculate_tng_stardate('54868.6') end |
This gets into the concept of what Symbiont calls “ready validations” so let’s talk about that next.
Ready Validations
Ready validations enable common validations to be abstracted away and performed on a page to determine when that page has finished loading and the elements are ready for interaction in the scripts.
When a block is passed to the view method as I showed before, the page will be loaded normally and then the block will be executed within the context of a method called when_ready
. This means the block will be executed only when the page is ready.
The when_ready
method on a Ready class instance (which all page definitions become when Symbiont is attached to them) will yield the instance of the class into a block after all ready validations have passed. But what does it mean to have a ready validation on the page? Well, consider this addition to our Decohere page:
1 2 3 4 5 6 7 8 9 |
class Decohere attach Symbiont url_is 'https://decohere.herokuapp.com/stardate' url_matches /stardate/ title_is 'Decohere - Stardate Calculator' page_ready { enable_form.exists? } ... |
This is a ready validation that will be done whenever the page is asked if it is ready. If any ready validation fails, an error will be raised with the reason, if given, for the failure. If you want to add a reason, you could change the above page_ready declaration as such:
1 |
page_ready { [enable_form.exists?, 'Ability to enable form not present.'] } |
A ready validation is a block which returns a boolean value when evaluated against an instance of the page definition. In the second case, the block may instead return a two-element array which includes the boolean result as the first element and an error message as the second element.
Let’s consider how this works in context by calling when_ready directly in the script:
1 2 3 4 5 6 7 8 9 |
page = Decohere.new page.view page.when_ready do |action| action.calculate_tng_stardate('54868.6') end expect(page.ready?).to be_truthy expect(page).to be_ready |
Note here that I have some expectations to check if the page is in fact ready. This is done via the ready?
method. In fact, you can explicitly execute any ready validations from a page definition via the ready? method. This method will execute all ready validations on the page definition and return a boolean value. In the event of a validation failure, a validation error can be accessed via the ready_error method on the page:
1 |
puts page.ready_error |
Symbiont includes a default ready validation on page.displayed? which is applied to all page definitions. This means any actions against a page are only assumed to be viable when the page has been displayed. But other more specific checks can be added, such as the one for the enable form element that I showed earlier.
Context Factories
There is yet another approach that you can use called a “page context factory.” I covered this fairly well in my post on the context factory so I won’t rehash that here. That said, do be aware of this approach and consider how you might prefer it over some of the others I’ve shown.
“Complex” Expectations
I want to cover something here that may seem like a tangent, particular because it’s not something that Symbiont provides so much as simply supports. Some scripters, using Ruby’s chaining of methods, like to produce relatively “complex” ways of making expressive code. Let’s consider an example:
1 2 3 4 |
page = Decohere.new page.view page.confirm.stardate_value('54868.6').is_year(2378) |
How does that last line get constructed. In the case of Symbiont, with its page object pattern, you would simply add a confirm
method that returns the page object itself. For example:
1 2 3 4 5 6 7 8 9 10 11 |
class Decohere attach Symbiont url_is 'https://decohere.herokuapp.com/stardate' url_matches /stardate/ title_is 'Decohere - Stardate Calculator' ... def confirm self end ... |
Then you have to use Ruby’s concept of open classes to add the is_year
method to whatever gets returned by the stardate_value
method. It turns out this is a Date so you can do this:
1 2 3 4 5 |
class Date def is_year(value) expect(self.year).to eq(value) end end |
Notice here that the expectation is now not only not in the script and not even in the page definition, but rather in a method attached to an open class based on a core Ruby class. That’s quite a few levels of indirection, particularly when you consider there are raging debates about whether or not expectations (or assertions) should be removed from scripts at all.
The main point I want to make here is that you can see that Symbiont is not stopping you from applying any particular pattern you want in terms of how page and/or service objects are used. Along with some of the code variations I’ve shown here in this post, Symbiont is predicated upon letting you use Ruby to its fullest. With the understanding that sometimes this allows people to create some potentially confusing code.
Let’s consider specifically what this means with an example script, using the existing page definition.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
#!/usr/bin/env ruby require_relative 'harness' Symbiont.set_browser page = Decohere.new page.view # Test for actual date result = page.calculate_tng_stardate('54868.6') expect(result.year).to eq(2378) expect(result.strftime('%B')).to eq('April') expect(result.day).to eq(6) # Test for display value date = page.calendar.value expect(date).to match '2378' expect(date).to match 'Apr' Symbiont.browser.quit |
Here you can see an example of expectations that part of the script logic. As I mentioned there are debates out there that go on (sometimes quite vigorously) about whether these expectations should be better put in well-named methods on the page object itself.
As an exercise, you might want to think about how you would convert the above example in just that way. Note, however, that Symbiont does not impose any sort of decision on you. While Symbiont does, for the most part, mandate the use of page objects (at least to get full value), it does not mandate how those page objects are used.
Symbiont is a automated checking tool that supports testing and I’ve grown quite proud of it, at least in terms of its implementation and how I think it can be extended. I have begun some tentative explorations into supporting Capybara as well (see my post on Capybara support) but I’m holding off going too full into that until I’m sure it won’t defocus Symbiont from its core task of doing one thing and doing it well.
Symbiont is open source (see the Symbiont GitHub page) and I encourage any and all contributions via pull requests or even just to let me known about any issues.