Creating Tests for our First Feature

Introduction

In this section, we will finally begin creating our actual test scenarios and coding them.

The website we will be testing is an Angular website called https://www.zagat.com, a US based website for national restaurant reviews and finding recommended places to eat in any given national location.

Let’s begin 🙂

Creating the first Cucumber Scenario

When creating our BDD scenarios, we want to keep them simple and write them in a declarative style, sticking simply to the business logic, rather than writing them in an imperative style.  A great article on this can be found at http://itsadeliverything.com/declarative-vs-imperative-gherkin-scenarios-for-cucumber.

We also want each test scenario to be small, clean and atomic. This means that ideally each test scenario should only test one specific thing and we should try to avoid repeating flows in our tests.

  1. With the project open in our chosen editor, right-click the ‘features’ folder and add a file called find_a_restaurant.feature
  2. Open up the find_a_restaurant.feature file and add the following description of the feature along with our first test scenario, like below…
    Feature: Find a restaurant
     
      As Frank (a lover of food)
      I want to find recommended restaurants to eat in
      So that I can feed my appetite with great cuisine
     
      Scenario: Searching for all restaurants in San Diego
        Given that Frank decides to use Zagat to find recommended restaurants
        When he searches for all restaurants in his area of "San Diego"
        Then a list of recommended restaurants in "San Diego" are returned to him

Creating the undefined step definitions

The steps in our scenario are currently undefined and do not exist, let’s now get the undefined step definitions so we can add them in to our project 🙂

  1. With the Terminal (or Command prompt) window open in the project root, enter the following command to print out to the console and generate the undefined step definitions…
    pytest-bdd generate test/features/find_a_restaurant.feature

    It should generate output like below…

    # coding=utf-8
    """Find a restaurant feature tests."""
    
    from pytest_bdd import (
        given,
        scenario,
        then,
        when,
    )
    
    
    @scenario('features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego."""
    
    
    @given('that Frank decides to use Zagat to find recommended restaurants')
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants():
        """that Frank decides to use Zagat to find recommended restaurants."""
        raise NotImplementedError
    
    
    @when('he searches for all restaurants in his area of 'San Diego'')
    def he_searches_for_all_restaurants_in_his_area_of_san_diego():
        """he searches for all restaurants in his area of 'San Diego'."""
        raise NotImplementedError
    
    
    @then('a list of recommended restaurants in 'San Diego' are returned to him')
    def a_list_of_recommended_restaurants_in_san_diego_are_returned_to_him():
        """a list of recommended restaurants in 'San Diego' are returned to him."""
        raise NotImplementedError
  2. With the project open in your chosen editor, right-click on the step_defs python package and create a Python file named ‘test_foodie.py‘ so that this step definition file follows the pytest naming conventions for python files
  3. Copy and paste the console output of the imports and the @scenario fixture in to the ‘test_foodie.py’ file like below, also quickly adding an import to the BASE_URL we specified in our conftest.py file…
    from pytest_bdd import (
        given,
        scenario,
        then,
        when,
        parsers)
    
    from tests.conftest import BASE_URL
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego"""

‘Given that Frank decides to use Zagat to find recommended restaurants’

  1. Copy the pending ‘Given’ step definition from the console output and add it in to the ‘test_foodie.py‘ step definition file, along with appropriate imports and adding the additional ‘parsers’ import, with the whole thing now looking like below…
    from pytest_bdd import (
        given,
        scenario,
        then,
        when,
        parsers
    )
    
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego."""
    
    
    @given('that Frank decides to use Zagat to find recommended restaurants')
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants():
        """that Frank decides to use Zagat to find recommended restaurants."""
        raise NotImplementedError
  2. Next, let’s edit the step definition a bit so that it can take any actors name as a string parameter by making use of that parsers import we added previously, with the whole thing now looking like below…
    from pytest_bdd import (
        given,
        scenario,
        then,
        when,
        parsers
    )
    
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego."""
    
    
    @given(parsers.cfparse('that {name} decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(name):
        """that Frank decides to use Zagat to find recommended restaurants."""
        raise NotImplementedError

‘When he searches for all restaurants in his area of San Diego’

  1. Copy the pending ‘When’ step definition from the console output file and add it in to the ‘test_foodie.py’ step definition file, updating any necessary imports, with the whole thing looking like below…
    from pytest_bdd import (
        given,
        scenario,
        then,
        when,
        parsers
    )
    
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego."""
    
    
    @given(parsers.cfparse('that "{name}" decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(name):
        """that Frank decides to use Zagat to find recommended restaurants."""
        raise NotImplementedError
    
    @when('he searches for all restaurants in his area of 'San Diego'')
    def he_searches_for_all_restaurants_in_his_area_of_san_diego():
        """he searches for all restaurants in his area of 'San Diego'."""
        raise NotImplementedError
  2. The ‘When’ step will have some formatting errors, so let’s edit it now to fix that and also take a parameter for the area to search for restaurants in, with the whole thing looking like below…
    from pytest_bdd import (
        given,
        scenario,
        then,
        when,
        parsers
    )
    
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego."""
    
    
    @given(parsers.cfparse('that "{name}" decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(name):
        """that Frank decides to use Zagat to find recommended restaurants."""
        raise NotImplementedError
    
    
    @when(parsers.cfparse('he searches for all restaurants in his area of "{area}"'))
    def he_searches_for_all_restaurants_in_his_area_of_san_diego(area):
        """he searches for all restaurants in his area of 'San Diego'."""
        raise NotImplementedError

‘Then a list of recommended restaurants in San Diego are returned to him’

  1. Copy the pending ‘Then’ step definition from the console output and add it in to the ‘test_foodie.py’ step definition file, updating any necessary imports, with the whole thing looking like below…
    from pytest_bdd import (
        given,
        scenario,
        then,
        when,
        parsers
    )
    
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego."""
    
    
    @given(parsers.cfparse('that "{name}" decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(name):
        """that Frank decides to use Zagat to find recommended restaurants."""
        raise NotImplementedError
    
    
    @when(parsers.cfparse('he searches for all restaurants in his area of "{area}"'))
    def he_searches_for_all_restaurants_in_his_area_of_san_diego(area):
        """he searches for all restaurants in his area of 'San Diego'."""
        raise NotImplementedError
    
    @then('a list of recommended restaurants in 'San Diego' are returned to him')
    def a_list_of_recommended_restaurants_in_san_diego_are_returned_to_him():
        """a list of recommended restaurants in 'San Diego' are returned to him."""
        raise NotImplementedError
  2. The ‘Then’ step will have some formatting errors, so let’s edit it now to fix that and also take a parameter for the area again, like so….
    from pytest_bdd import (
        given,
        scenario,
        then,
        when,
        parsers
    )
    
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego."""
    
    
    @given(parsers.cfparse('that "{name}" decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(name):
        """that Frank decides to use Zagat to find recommended restaurants."""
        raise NotImplementedError
    
    
    @when(parsers.cfparse('he searches for all restaurants in his area of "{area}"'))
    def he_searches_for_all_restaurants_in_his_area_of_san_diego(area):
        """he searches for all restaurants in his area of 'San Diego'."""
        raise NotImplementedError
    
    
    @then(parsers.cfparse('a list of recommended restaurants in "{area}" are returned to him'))
    def a_list_of_recommended_restaurants_in_san_diego_are_returned_to_him(area):
        """a list of recommended restaurants in 'San Diego' are returned to him."""
        raise NotImplementedError

Defining the Step Definitions

‘Given that Frank decides to use Zagat to find recommended restaurants’

  1. Edit the ‘Given’ step to access the ‘browser’ in our conftest.py file and navigate to the BASE_URL also specified in the conftest.py file…
    @given(parsers.cfparse('that {name} decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(browser, name):
        browser.get(BASE_URL)

    The whole thing should now look like below…

    from pytest_bdd import (
        given,
        parsers,
        scenario, when, then)
    
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego"""
    
    
    @given(parsers.cfparse('that {name} decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(browser, name):
        browser.get(BASE_URL)
    
    
    @when(parsers.cfparse('he searches for all restaurants in his area of "{area}"'))
    def he_searches_for_all_restaurants_in_his_area_of_san_diego(area):
        """he searches for all restaurants in his area of 'San Diego'."""
        raise NotImplementedError
    
    
    @then(parsers.cfparse('a list of recommended restaurants in "{area}" are returned to him'))
    def a_list_of_recommended_restaurants_in_san_diego_are_returned_to_him(area):
        """a list of recommended restaurants in 'San Diego' are returned to him."""
        raise NotImplementedError

‘When he searches for all restaurants in his area of San Diego’

If we break this step down in to any tasks that need to be performed, we could say that the user attempts to search for recommended restaurants in their given area of ‘San Diego’

  1. Edit the ‘When’ step to look like below…
    @when(parsers.cfparse('he searches for all restaurants in his area of "{area}"'))
    def he_searches_for_all_restaurants_in_his_area_of_san_diego(browser, area):
        base_page = BasePage(browser)
        base_page.search_for_restaurants_in(area)

    You will notice that we get an error, as the BasePage does not exist yet.  Let’s fix that by creating it 🙂

BasePage object

  1. With the project open in your chosen editor, right-click on the ‘pages’ python package and add a python file named ‘base_page.py’
  2. Open up the ‘base_page.py’ file and edit it to create the class with an ‘__init__’ constructor to initialise the browser that we pass in to the base_page object, like below…
    class BasePage:
    
        def __init__(self, browser):
            self.browser = browser
Adding our ‘search_for_restaurants_in()’ function
  1. In the BasePage class, add the following function to search for restaurants in a given area, with the whole thing looking like below…
    from selenium.webdriver.common.keys import Keys
    
    
    class BasePage:
    
        def __init__(self, browser):
            self.browser = browser
    
        def search_for_restaurants_in(self, area):
            self.browser.find_element(*self.INITIAL_SEARCH_FIELD).click()
            self.browser.find_element(*self.LOCATION_SEARCH_FIELD).clear()
            self.browser.find_element(*self.LOCATION_SEARCH_FIELD).send_keys(area + Keys.RETURN)

    You’ll notice that we have some errors, as we have not yet added the By locators for the two search fields, so let’s do that next 🙂

  2. Add the following By locators in the class but outside of any functions, like so…
    from selenium.webdriver.common.by import By
    from selenium.webdriver.common.keys import Keys
    
    
    class BasePage:
        __INITIAL_SEARCH_FIELD = (By.CLASS_NAME, 'zgt-header-search-text')
        __LOCATION_SEARCH_FIELD = (By.CLASS_NAME, 'zgt-search-bar-location-term-input')
    
        def __init__(self, browser):
            self.browser = browser
    
        def search_for_restaurants_in(self, area):
            self.browser.find_element(*self.__INITIAL_SEARCH_FIELD).click()
            self.browser.find_element(*self.LOCATION_SEARCH_FIELD).clear()
            self.browser.find_element(*self.__LOCATION_SEARCH_FIELD).send_keys(area + Keys.RETURN)

    This is good and will work, but we could also make this arguably cleaner by adding getter functions to get the elements we need and then call them in the search_for_restaurants_in() function instead. We could also create a single function for clearing and entering text in to the LOCATION_SEARCH_FIELD, so let’s also do that now 🙂

  3. Edit the BasePage class to include two private getter functions for the two By locators we added and also add a function for clearing and then entering text in the LOCATION_SEARCH_FIELD element, with the whole thing looking like below…
    from selenium.webdriver.common.by import By
    from selenium.webdriver.common.keys import Keys
    
    
    class BasePage:
        __INITIAL_SEARCH_FIELD = (By.CLASS_NAME, 'zgt-header-search-text')
        __LOCATION_SEARCH_FIELD = (By.CLASS_NAME, 'zgt-search-bar-location-term-input')
    
        def __init__(self, browser):
            self.browser = browser
    
        def search_for_restaurants_in(self, area):
            self.__get_initial_search_field().click()
            self.__set_location__search_field(area)
    
        def __get_initial_search_field(self):
            return self.browser.find_element(*self.__INITIAL_SEARCH_FIELD)
    
        def __get_location_search_field(self):
            return self.browser.find_element(*self.__LOCATION_SEARCH_FIELD)
    
        def __set_location__search_field(self, area):
            self.__get_location_search_field().clear()
            self.__get_location_search_field().send_keys(area + Keys.RETURN)
  4. Finally, let’s go back to the ‘test_foodie.py’ file and add an import for the BasePage class, with the whole thing looking like below…
    from pytest_bdd import (
        given,
        parsers,
        scenario, when, then)
    
    from pages.base_page import BasePage
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego"""
    
    
    @given(parsers.cfparse('that {name} decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(browser, name):
        browser.get(BASE_URL)
    
    
    @when(parsers.cfparse('he searches for all restaurants in his area of "{area}"'))
    def he_searches_for_all_restaurants_in_his_area_of_san_diego(browser, area):
        base_page = BasePage(browser)
        base_page.search_for_restaurants_in(area)
    
    
    @then(parsers.cfparse('a list of recommended restaurants in "{area}" are returned to him'))
    def a_list_of_recommended_restaurants_in_san_diego_are_returned_to_him(area):
        """a list of recommended restaurants in 'San Diego' are returned to him."""
        raise NotImplementedError

‘Then a list of recommended restaurants in San Diego are returned to him’

In this step, we will now assert that the user sees a list of recommended restaurants in the area they searched in.  We will do this by asserting that the given area is present in the browser’s current URL and also that the actual returned results are visible 🙂

  1. Edit the ‘Then’ step to look like below (remembering to add the browser parameter too)…
    @then(parsers.cfparse('a list of recommended restaurants in "{area}" are returned to him'))
    def a_list_of_recommended_restaurants_in_san_diego_are_returned_to_him(browser, area):
        assert area in unquote(browser.current_url)
        search_results_page = SearchResultsPage(browser)
        assert search_results_page.get_returned_search_results.is_displayed()

    You will notice we have some errors as we have not added the import for unquote() and we have not yet implemented the SearchResultsPage(), so let’s do all that next

Decode the current URL

  1. Add the following import for the unquote() function and to decode the current URL…
    from urllib.parse import unquote

SearchResultsPage object

  1. With the project open in your chosen editor, right-click on the ‘pages’ python package and add a python file named ‘search_results_page.py’
  2. Open up the ‘search_results_page.py’ file and edit it to create a class that inherits from the BasePage() class, like so…
    from pages.base_page import BasePage
    
    
    class SearchResultsPage(BasePage):

    It might complain about the class being empty, so we’ll fix that next too

Adding our ‘RETURNED_SEARCH_RESULTS’ By locator
  1. Open up the ‘search_results_page.py’ file and add the following By locator in the SearchResultsPage() class, with the whole thing looking like below…
    from selenium.webdriver.common.by import By
    
    from pages.base_page import BasePage
    
    
    class SearchResultsPage(BasePage):
        RETURNED_SEARCH_RESULTS = (By.CLASS_NAME, 'zgt-place-results-list')
Adding our ‘get_returned_search_results’ function
  1. Add the following function in the SearchResultsPage() class to return the search results container element, with the whole thing looking like below…
    from selenium.webdriver.common.by import By
    
    from pages.base_page import BasePage
    
    
    class SearchResultsPage(BasePage):
        RETURNED_SEARCH_RESULTS = (By.CLASS_NAME, 'zgt-place-results-list')
    
        def get_returned_search_results(self):
            return self.browser.find_element(*self.RETURNED_SEARCH_RESULTS)
  2. Next, we can go back to our ‘test_foodie.py’ step definition file and simply add the import for the SearchResultsPage(), with the whole file looking like below…
    from urllib.parse import unquote
    
    from pytest_bdd import (
        given,
        parsers,
        scenario, when, then)
    
    from pages.base_page import BasePage
    from pages.search_results_page import SearchResultsPage
    from tests.conftest import BASE_URL
    
    
    @scenario('../features/find_a_restaurant.feature', 'Searching for all restaurants in San Diego')
    def test_searching_for_all_restaurants_in_san_diego():
        """Searching for all restaurants in San Diego"""
    
    
    @given(parsers.cfparse('that {name} decides to use Zagat to find recommended restaurants'))
    def that_frank_decides_to_use_zagat_to_find_recommended_restaurants(browser, name):
        browser.get(BASE_URL)
    
    
    @when(parsers.cfparse('he searches for all restaurants in his area of "{area}"'))
    def he_searches_for_all_restaurants_in_his_area_of_san_diego(browser, area):
        base_page = BasePage(browser)
        base_page.search_for_restaurants_in(area)
    
    
    @then(parsers.cfparse('a list of recommended restaurants in "{area}" are returned to him'))
    def a_list_of_recommended_restaurants_in_san_diego_are_returned_to_him(browser, area):
        assert area in unquote(browser.current_url)
        search_results_page = SearchResultsPage(browser)
        assert search_results_page.get_returned_search_results().is_displayed()

    If we now run the command pytest in our Terminal (or Command prompt) window (still in our virtual environment), it should run our test and it should pass successfully 😀

Liked it? Take a second to support Thomas on Patreon!

Previous Article

5 Replies to “Part 3. Creating Tests for our First Feature”

  1. Hi Tom,
    Great post! I have just started taking an interest in Python as a language, having come from a Java and JavaScript/TypeScript background; this post was super helpful.
    The step-by-step setup guide is very clear and the test design walk-through was very well thought out and described.
    Thanks!

  2. Hi Tom,
    Greetings from London. I hope you’re well. I’m a complete newbie to pytest-bdd and I’m struggling to really understand how to structure everything, so I really appreciate this blog post. I just wanted to ask the following questions:
    1. As I’m new to this and have no prior knowledge, is this still the most current and best way for implementing the pytest-bdd framework using the page object model method?

    2. By any chance, do you have any video version/YouTube tutorials of the above showing practical examples step by step? If not would this be something you could do? I wouldn’t mind paying for it. I really want to learn and understand the pytest-bdd framework using the page object model. I’ve checked for course on Udemy, I can’t find anything.

    Thanks in advance, I can’t wait to hear back from you any feedback would be helpful.

    Kind regards,

    Emmanuel.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.