Background:
In a previous example entitled “Designing maintainable calabash tests using Screen Objects” I introduced a possible solution to creating maintainable cross platform calabash tests. Whilst this was a good start, there was room for improvement.
The Problem:
While on the face of it the first example may seem fine, but it could have implications. The fact that we are accessing calabash commands directly from our screen-objects, and the inability to drive the tests using different drivers (should you decide) could provide a maintenance headache in the future.
The Solution:
We refactor our Base Class, abstracting all common driver (calabash) actions and write our own wrapper methods around these. We can then leverage them in our screen-objects.
An Example:
In this example we are going to refactor the code from the “previous example.”
require 'calabash-android' require 'calabash-android/operations' class BaseClass include Calabash::Android::Operations def initialize(driver) @driver = driver end def method_missing(sym, *args, &block) @driver.send sym, *args, &block end def tap_on(element) touch("* marked:'#{element}'") end def exists?(element) element_exists("* marked:'#{element}'") end def keyboard_enter_text(el, text) query("* id:'#{el}'", {:setText => text}) end def wait_for_no_progress_bars performAction('wait_for_no_progress_bars') end def wait_for_dialog_to_close performAction('wait_for_dialog_to_close') end def wait_for_element(element) wait_for { exists?(element) } end def self.element(name, &block) define_method(name.to_s, &block) end class << self alias :value :element alias :action :element end end
As you can see above, within our base-class we now have a set of common methods that wrap around calabash commands. I have also removed reference to Calabash ABase. The previous example inherited from the Calabash base class. There’s no need for this now we are creating our own wrapper methods around calabash commands.
Now we have refactored this, (and like the previous examples) the screen-objects will inherit from it. We must now refactor our sceen-objects to remove any direct calls to calabash commands, and replace them with our custom methods.
Before:
class LoginScreen < DroidPress element(:username_field) { 'username' } element(:password_field) { 'password' } element(:login_button) { 'save' } value(:not_logged_in?) { element_exists("* id:'#{login_button}'") } action(:touch_login_button) { touch("* id:'#{login_button}'") } def login_with(username, password) query("* id:'#{username_field}'", {:setText => username}) query("* id:'#{password_field}'", {:setText => password}) performAction('scroll_down') touch_login_button performAction('wait_for_no_progress_bars') performAction('wait_for_dialog_to_close') end end
After:
class LoginScreen < BaseClass element(:username_field) { 'nux_username' } element(:password_field) { 'nux_password' } element(:login_button) { 'nux_sign_in_button' } element(:forgot_password) { 'forgot_password' } element(:create_account_btn) { 'nux_create_account_button' } value(:await) { wait_for_element(username_field) } value(:not_logged_in?) { exists?(username_field) } action(:touch_login_button) { tap_on(username_field) } def login_with(username, password) keyboard_enter_text(username_field, username) keyboard_enter_text(password_field, password) touch_login_button wait_for_no_progress_bars end end
As you can see little has changed, the only thing we have done is called our wrapper methods from the base class instead of calling calabash commands directly (The ID’s etc have changed slightly as this example is using the latest version of the wordpress app).
Once this is complete our step definitions remain unchanged, however to remove duplication I decided to refactor these slightly:
Before:
Given(/^the app is launched$/) do @screen = page(WordPressApp) end When(/^I login with (valid|invalid) credentials to Add WordPress.com blog$/) do |negate| @screen.welcome_screen.await @screen.welcome_screen.touch_add_blog @screen.login_screen.await @screen.login_screen.login_with(USERS[:valid][:email], USERS[:valid][:password]) if negate.eql? 'valid' @screen.login_screen.login_with(USERS[:invalid][:email], USERS[:invalid][:password]) if negate.eql? 'invalid' end Then /^I (should|should not) be logged in$/ do |negate| if negate.include? 'not' @screen.login_screen.should be_not_logged_in else @screen.home_screen.await @screen.home_screen.should be_logged_in end end
After:
When(/^I login with (valid|invalid) credentials to Add WordPress.com blog$/) do |negate| @screen.login_screen.await @screen.login_screen.login_with(USERS[:valid][:email], USERS[:valid][:password]) if negate.eql? 'valid' @screen.login_screen.login_with(USERS[:invalid][:email], USERS[:invalid][:password]) if negate.eql? 'invalid' end Then /^I (should|should not) be logged in$/ do |negate| if negate.include? 'not' @screen.login_screen.should be_not_logged_in else @screen.home_screen.should be_logged_in end end
You will see that I have removed the ‘Given the app is launched’ step. Rather than initialize this class at the beginning of each scenario I moved into a hook:
#hooks.rb file (lives in the support dir) Before do |scenario| @screen = WordPressApp.new(self) end
This hook will initialize our screen-objects before each scenario is executed.
Summary
We are now in a position where our screen-objects can access common actions without directly accessing calabash code. Therefore, if in the event that calabash commands change, you will only have to refactor one class.
As well as this, should you decide to switch drivers (for example Appium) it would require little effort do do this.
All example code can be found on GitHub, here
Thanks for reading, any feedback welcome!
– Ian
Hi, 2 great posts!
A couple of newbie questions if I may:
1) I have followed your posts to create a small framework for an app I am working on. However, when I run calabash I am getting a warning around the define_method call – “tried to create Proc object without a block”. Is this expected? If not, can you advise how to resolve?
2) I need to be able to store contents from a screen in the “When” step and check it in the “Then” step – can you advise on the best way to do this?
Thanks.
Hi Steve,
Point 1:
You can fix that warning by something like:
def method_missing sym, *args, &block
send sym, *args, &block
end
Point 2:
Depending on preference, you can use an instance variable, like:
When I want to store text
@text = query(“* marked:’your_id'”)
Then I should see my text:
element_exists(“* marked:’#{@text}'”)
However the most maintainable way to do it would be to return it from a method within your screen classes:
For example:
When(/^I create a new blog post$/) do
@screen.home_screen.touch_newpost_btn
@screen.new_post_screen.await
@blog_title, @blog_content = @screen.new_post_screen.add_new_blog_post
end
The method that returns the text (from my a page class):
def add_new_blog_post
title = (0…25).map { (‘a’..’z’).to_a[rand(26)] }.join
content = (0…250).map { (‘a’..’z’).to_a[rand(26)] }.join
query(“* id:’#{blog_title}'”, {:setText => title})
query(“* id:’#{post_content}'”, {:setText => content})
performAction(‘scroll_down’)
touch_publish
performAction(‘wait_for_no_progress_bars’)
return title, content
end
Then(/^the post should be added$/) do
@screen.home_screen.await
@screen.home_screen.touch_view_posts_btn
@screen.posts_screen.await
exists?(@blog_title) && exists?(@blog_content)
end
Hope that helps.
Hi Ian,
Thanks for the above. method_missing is already defined as per your response (which I believe is the same as your example project). Not sure if I am missing some other code or if I am using the wrong send library???
For the second point, is there anyway to have the instance variables declared inside the relevant screen class? For example, can @blog_title and @blog_content be set directly in the new_post_screen class and then checked in the Then step with something like @screen.new_post_screen.blog_title.should eg (“blog title”).
Thanks,
Steve
The first warning, I’ll have to look at my example code when I get to my machine, but in the meantime you could try:
def method_missing sym, *args, &block
@world.send sym, *args, &block
end
Secondly, not sure I follow. But you may want to look up attribute_accessor, which creates an instance variable that can be used within your screen class, and step definitions.
class Test
attr_accessor :blog_title, :blog_content
value(:blog_content?) { exists?(@blog_title) }
def add_new_blog_post
@blog_title = (0…25).map { (‘a’..’z’).to_a[rand(26)] }.join
@blog_content = (0…250).map { (‘a’..’z’).to_a[rand(26)] }.join
query(“* id:’#{blog_title}’”, {:setText => title})
query(“* id:’#{post_content}’”, {:setText => content})
performAction(‘scroll_down’)
touch_publish
performAction(‘wait_for_no_progress_bars’)
end
Thanks for the above Ian. I will use attr_accessor for what I need.
For the first point, I still see the error when using:
def self.element element_name
define_method element_name.to_s
end
The define_method call expects the block to passed to it but we are not - is it because of the way the elements are set in the screen class files?
Thanks again for all your help.
Steve
Hi Steve,
Apologies for not getting back to you, been quite busy.
Based on your comments I am going to refactor and update the above blog post to remove this warning. Thanks for pointing that out to me, I missed it. Although the tests will still run as normal as they are in the above example, its not good to have these annoying warnings.
Expect an update later today/tomorrow.
Thanks Ian.
Steve
Hi Steve, I’ve now updated the blog. Apologies for missing the error. It was staring me in the face all along!
Hope that helps you get set up designing maintainable framework.
Many thanks Ian for taking the time to answer my questions and for updating the blog.
Nice post. Abstracting out calabash is a great idea.
Hey Ian, maybe I am missing something but how do you call .await once you have abstracted out the ABase?
@screen.login_screen.await
Hi John, thanks for the comment. If you checkout the github project you will see that I am defining my own method called await within the welcome_screen/login_screen classes.
Hope that helps, if not let me know.