Pytest-BDD-NG Tutorial

For several years now Python users have had only two options for automating their Gherkin requirements; Pytest-BDD and Behave. Now (December 2023) there is a new option that is clearly better than either of the others – Pytest-BDD-NextGeneration. Why is Pytest-BDD-NG Better? Behave and Pytest-BDD have their own Gherkin parsers and support only a subset of Gherkin. Pytest-BDD doesn’t support data tables; I believe that is a fatal flaw. Fixtures and parameterization are not a substitute for data tables; they are a Gherkin anti-pattern. Pytest-BDD-NextGeneration (version 2.1.3) supports lots of Cucumber and Gherkin goodness, including:
  • The official Cucumber Gherkin parser
  • Cucumber Expressions
  • Standard JSON output for reporting
  • The Cucumber Messages protocol (also for reporting tools)
Now that we know that Pytest-BDD-NG is much more up to date, let’s jump in! Let’s begin by writing a requirement for a library catalog:
Feature: Library book searches and book delivery

Scenario: The catalog can be searched by author name.
    Given these books in the catalog
    | Author          | Title                       |
    | Stephen King    | The Shining                 |
    | James Baldwin   | If Beale Street Could Talk  |
    When a name search is performed for Stephen
    Then only these books will be returned
    | Author          | Title                       |
    | Stephen King    | The Shining                 |

Now let’s see how we would automate that requirement using Pytest-BDD-NG.

Getting Started with Pytest-BDD-NG

Although it is much more up to date than Pytest-BDD or Behave, Pytest-BDD-NG still builds on the foundation of Pytest-BDD, so it is different in many ways from Cucumber and Behave. Let’s examine those differences
  • There is no ‘context’ object
  • Data tables don’t have a separate header row
  • The ‘features’ and ‘steps’ directories are optional
  • A ‘tests’ directory and a ‘conftest.py’ file are required
  • There isn’t a ‘before_feature’ hook
  • Step Definitions are different

There Is No ‘context’ Object

Cucumber and Behave have a ‘context’ object that is created automatically and allows you to carry contextual information from one step to the next within a scenario. The context object contains essential information such as the tags associated with this scenario and the data table (if present). The context object is destroyed at the end of every scenario and recreated for the next one so that scenarios are independent of each other.

Pytest-BDD-NG has a ‘step’ object but no context object. The step object holds the data table or docstring associated with the current step; these would be on the context object in Cucumber or Behave. In order to carry contextual information from one step to the next the automation engineer must use a Pytest fixture in place of the context object. In the next tutorial we will show how to do this properly, but for this very simple case this code will create a context object:

Disclaimer The Python code in this tutorial is deliberately simple; there are much better ways to write some of this. For example, I used a 5-line ‘while’ loop where I could have used a 1-line list comprehension. The list comprehension would be *much* more Pythonic, but even a non-Python programmer should understand the ‘while’ loop. I would never write production code like this!
@fixture
def context():
    """
    Create a placeholder object to use in place of Cucumber's context
    object. The context object allows us to pass state between steps.

    CAUTION: DO NOT USE THIS FIXTURE; read the next tutorial to see
    the right way to pass state from one step to another. Failure to
    do this may result in scenarios that depend on order of execution;
    this is a Bad Thing.
    """
    class dummy():
        pass

    return dummy()
Data Tables Don’t Have a Separate Header Row Pytest-BDD-NG assumes that data tables do not have a header row; that is, that the data table doesn’t have column headings. This is consistent with the way Cucumber works, but different from Behave. Behave assumes that all data tables have a header row, and separates the data table into two parts – the headings and the data. Behave allows you to index into the columns of a data table using the column headings. For example, you could write:
author_name = table.rows[0][“Author”]
Row zero is the first row of the data – the first row after the title row – and we selected the column containing the author’s name by specifying the column title as an index – ‘[“Author”]’. With Pytest-BDD-NG, if your data table does have a header row, then your code must process row zero differently from all of the other rows. For reading a data table of book titles and author names with a header row you might use code like this:
def read_books_from_table(table_of_books):
    """
    Read all of the authors and titles from a data table. The data table has
    this format:
    | Author                | Title         |
    | first author name     | first title   |
    | second author name    | second title  |
    .
    .
    .
    """
    books = []
    FIRST_ROW = 0
    FIRST_COLUMN = 0
    if table_of_books.rows[FIRST_ROW].cells[FIRST_COLUMN].value == "Author":
        # If the first column is 'Author', assume that the second column is 
        # 'Title'. Of course this is poor programming, but it is adequate for
        # this simple example.
        author_column = 0
        title_column = 1
    # Now read each row, starting with row 1 (the first row containing an 
    # author’s name and book title).
The ‘features’ and ‘steps’ Directories Are Optional Behave and Cucumber expect all of the requirements to be in Feature files that are in a directory named ‘features’. Similarly, the step definitions are expected to be in a ‘steps’ directory. Pytest-BDD-NG doesn’t have any of these conventions; requirements and step definitions may be in directories with whatever names you like – but there must be a ‘tests’ directory. A ‘tests’ Directory And a ‘conftest.py’ File May Be Required Pytest-BDD-NG expects you to create a ‘tests’ directory at the top level of your automation code. Under that ‘tests’ directory there must be at least one ‘conftest.py’ file; Pytest-BDD-NG will read this file as the starting point of your automation code. This means that your directories might look like this:
Tests---|--Features
        |
        |--steps
        |
        |--Reports
        |
        |--conftest.py

Your conftest.py file may include whatever initialization code you need, but it must at least load your step definitions. So, continuing our library catalog example, our conftest.py might look like this:

from steps.library_steps import *
You can use an ‘@scenario’ decorator in place of the conftest.py file, and you can change the search path for feature files if you create a pytest configuration file (pytest.ini, tox.ini or setup.cfg). There Isn’t a ‘before_feature’ Hook

Cucumber and Behave have a ‘before_feature’ hook that allows you to perform initialization that is required for each Feature file, but not required for each Scenario. Pytest-BDD-NG doesn’t have this, but you can get the same result with a ‘module’ fixture. We will illustrate this in a future tutorial.

Step Definitions Are Different

In Pytest-BDD-NG, the step parser (plain text, regular expression, Cucumber Expressions, etc.) must be defined for each step definition.

The ‘heuristic’ parser is the default, so for a simple line of Gherkin we can write it like this:

@given("these books in the catalog")
def these_books_in_the_catalog(step):
Notice that we have declared ‘step’ as a parameter to this method; we must do this if the step definition needs to access the ‘step’ object. This step definition needs to access the data table which is on the ‘step’ object. If we need the regular expression parser we must write something like this:
@when(parsers.re("a (?P<search_type>name|title) search is performed for " +
                 "(?P<search_term>.+)"))
def a_SEARCH_TYPE_is_performed_for_SEARCH_TERM(search_type, search_term):
And if we want to use the Cucumber Expressions parser we must declare our step definition like this:
@when(parsers.cucumber_expression("a {} search is performed for {}"),
parameter_type_registry=ParameterTypeRegistry()),
    anonymous_group_names=('search_type',"search_term"),
    target_fixture="a_SEARCH_TYPE_is_performed_for_SEARCH_TERM)"
)
def a_SEARCH_TYPE_is_performed_for_SEARCH_TERM(step, search_type, search_term):
Wrapping up this tutorial, the full step definition file looks like this:
from pytest import fixture
from pytest_bdd import given, when, then, parsers, step

from helper_methods.library_catalog import Catalog
from helper_methods.verification_helper_methods import verify_returned_books

@fixture
def context()
    """ Create a placeholder object to use in place of Cucumber's context object. The context object allows us to pass state between steps. """
    class dummy():
        pass

    return dummy()

@given("these books in the catalog")
def these_books_in_the_catalog(step):
    context.catalog = Catalog()
    context.catalog.add_books_to_catalog(step.data_table)

@when(parsers.re("a (?P<search_type>name|title) search is performed for " +
"(?P<search_term>.+)")) 
def a_SEARCH_TYPE_is_performed_for_SEARCH_TERM(search_type, search_term):
    if search_type == "title":
        raise NotImplementedError("Title searches are not yet implemented.")
    context.search_results = context.catalog.search_by_author(search_term)

@then("only these books will be returned")
def only_these_books_will_be_returned(step):
expected_books = context.catalog.read_books_from_table(step.data_table)
verify_returned_books(context.search_results, expected_books)
In the next tutorial we will explore more features of Pytest-BDD-NG, and add the classes and methods to complete our library catalog example.

Leave a Comment