Testing with Behave Framework & Selenium

Testing with Behave Framework & Selenium
Tangie
White Owl Diamond
Stumptown Cold Brew

Behave Framework

This is a very simple test using the Behave Framework and Selenium. I chose to start with a login test since the bot must always have login capabilities (aka just in case Twitter decides to block my automation).  

I want to specifically point out my directory structure to give a better idea on how your tests can scale. Lets start with the Feature file.

See code on Github

Feature File

# regression/features/login_to_twitter.feature
# Created by churt at 2/16/19

@twitter
Feature: Login to Twitter
  The Bot should always be able to post.
  It is important that the login always works.
  Run this test all damn day and let me know if it fails
  cuz that means my API key is likely revoked!

  Scenario Outline: GlobalEntry Bot logs in to twitter
    Given twitters homepage
    When "<user>" logs in
    Then the twitter timeline appears

    Examples: users
      | user        |
      | GlobalEntry |

I'm using the Scenario Outline function in order to iterate the Examples table. This allows me to execute the scenario with multiple users (aka usernames).

Steps

# /regression/lib/steps/login_to_twitter.py

@given("twitters homepage")
def step_load_twitter(context):
    """
    :type context: behave.runner.Context
    """
    context.login_page = LoginPage(context)
    assert_that(context.login_page.browser.current_url, contains_string(URL.TWITTER))
    assert_that(context.login_page.username.get_attribute('autocomplete'), equal_to(Constants.USERNAME))
    time.sleep(4)


@when('"(?P<user>.+)" logs in')
def step_login(context, user):
    """
    :type context: behave.runner.Context
    :type user: str
    """
    context.timeline_page = context.login_page.login(username=context.twitter_username, password=context.twitter_password)
    time.sleep(3)
    assert_that(context.timeline_page.avatar.get_attribute('title'), equal_to("Profile and settings"))
    
@then("the twitter timeline appears")
def step_impl(context):
    """
    :type context: behave.runner.Context
    """
    context.timeline_page.view_latest_tweets(search=True)
    time.sleep(3)
    assert_that(context.timeline_page.browser.current_url, contains_string(Constants.TWEETS))


Step functions are decorated with given, when, then, or step (....wtf?). Notice each function's decorator is either given, when, or then and corresponds with steps found in my Feature file. Steps should drive the webdriver and then make assertions to ensure tests pass.  

Page Objects

# regression/lib/page_objects/login.py

class LoginPage(BasePage):

    def __init__(self, context):
        BasePage.__init__(self,
                          context.browser,
                          )
        self.context = context
        self.base_url = URL.TWITTER
        self.visit(url=self.base_url)

    locator_dictionary = LoginLocators.__dict__

    def login(self, username='', password=''):
        self.username.send_keys(username)
        self.password.send_keys(password)
        self.submit_btn.click()
        return TwitterTimeline(self.context)

Page objects are designed to provide methods for quick page actions. For example, my LoginPage object has attributes (aka elements) of Twitter's login page, so the login() method is called in my step definition to log in. The other option would have been to write the login logic within my step definition, but IMO this is easier for refactoring.

Base Page Object

# regression/lib/page_objects/base_page.py
 
class BasePage(object):
    """
    Really the only method used here is '__getattr__' now
    """

    def __init__(self, browser, base_url='', **kwargs):
        self.browser = browser
        self.base_url = base_url
        self.timeout = 10

    @contextlib.contextmanager
    def wait_for_page_load(self, timeout=10):
        old_page = self.find_element_by_tage_name('html')
        yield
        WebDriverWait(self, timeout).until(staleness_of(old_page))

    def find_element(self, *loc):
        return self.browser.find_element(*loc)

    def find_elements(self, *loc):
        return self.browser.find_elements(*loc)

    def visit(self, url='', route=''):
        if not url:
            url = self.base_url
        self.browser.get(url + route)

    def hover(self, element):
        ActionChains(self.browser).move_to_element(element).perform()
        # I don't like this but hover is sensitive and needs some sleep time
        time.sleep(5)

    def __getattr__(self, what):
        try:
            if what in self.locator_dictionary.keys():
                try:
                    element = WebDriverWait(self.browser, self.timeout).until(
                        EC.presence_of_element_located(self.locator_dictionary[what])
                    )
                except(TimeoutException, StaleElementReferenceException):
                    traceback.print_exc()

                try:
                    element = WebDriverWait(self.browser, self.timeout).until(
                        EC.visibility_of_element_located(self.locator_dictionary[what])
                    )
                except(TimeoutException, StaleElementReferenceException):
                    traceback.print_exc()
                # I could have returned element, however because of lazy loading, I am seeking the element before return
                return self.find_element(*self.locator_dictionary[what])
        except AttributeError:
            super(BasePage, self).__getattribute__("method_missing")(what)

    def method_missing(self, what):
        print "No %s here!" % what

Pointing out my BasePage object that is inherited by every page object. The __getattr__ allows for simple syntax... it is the reason why login()'s logic is written plainly as self.username.send_keys(username).

Locators

# regression/lib/locators/login.py

class LoginLocators:
    username = (By.NAME, "session[username_or_email]")
    password = (By.NAME, "session[password]")
    submit_btn = (By.CLASS_NAME, "js-submit")

A Locator is a Class that contain the elements a page object needs for its methods. Notice my LoginLocators class contains the same attributes used in LoginPage, and LoginPage has an attribute named locator_dictionary that has a value of LoginLocators.__dict__.

Register Steps

# regression/lib/steps/__all_steps__.py

from regression.lib.steps.login_to_twitter import *
# regression/lib/steps/__init__.py

__all__ = ["__all_steps__",]
regression/features/steps/__init__.py

from regression.lib.steps import __all_steps__

Notice the location of each file. It is very important to register steps in order for features to execute.

Environment Settings

regression/features/environment.py

use_step_matcher("re")


def before_all(context):
    context.logger = logging

    # import config settings
    with open(os.getcwd() + os.path.sep + Path.CONFIG, 'r') as ymlfile:
        context.config = yaml.safe_load(ymlfile)
    context.browser = context.config.get('env')['browser']
    if context.browser == 'chrome':
        context.browser = webdriver.Chrome()
    else:
        raise RuntimeError("Please run with Chrome")
    if not os.path.exists(Path.FAILED_SCREENSHOT):
        os.makedirs(Path.FAILED_SCREENSHOT)
    context.twitter_username = context.config.get('userdata')['username']
    if not context.twitter_username:
        context.twitter_username = os.environ.get("TWITTER_USERNAME")
    context.twitter_password = context.config.get('userdata')['password']
    if not context.twitter_password:
        context.twitter_password = os.environ.get("TWITTER_PASSWORD")
        
def after_scenario(context, scenario):
    if scenario.status == "failed":
        print("Failed: {}".format(Path.FAILED_SCREENSHOT + scenario.name + "_failed.png"))
        context.logger.info("Failed: {}".format(Path.FAILED_SCREENSHOT + scenario.name + "_failed.png"))
        context.browser.save_screenshot(Path.FAILED_SCREENSHOT + scenario.name + "_failed.png")
    context.browser.delete_all_cookies()


def after_feature(context, feature):
    context.browser.quit()

A few minimal things I like to do here:

  • Initiate logging
  • Load configs from yml file (top-level directory)
  • Above step can be loaded from behave.ini (userdata section) as well
  • Pass variables to context during before_all() hook
  • Create screenshot directory
  • Take screenshot if test fails in after_scenario hook

There are more hooks available for the environment.py file. I suggest reading behave's documentation for advanced topics.

Run Tests

$ behave

Show Comments