Pytest-BDD-NG Tutorial #3

Pytest-BDD-NG: Fixture Injection, Cucumber Expressions, and Enums

In the previous Pytest-BDD-NG tutorial we created a working search function for a library catalog. However, we deliberately used very simple code:
  • simple Python – for example, a ‘while’ loop instead of a list comprehension
  • explicit use of fixtures rather than a more elegant (but less obvious) approach
  • the regular expression step parser rather than the more elegant Cucumber Expressions parser
In this installment we will:
  1. Show a simpler way to inject fixtures into our step definitions
  2. Show how to use the Cucumber Expressions parser
  3. Show how to validate parameter values using custom parameter types
  4. Show how to convert Gherkin parameters into Enums

Step 1 – Simplify Fixture Injection

In the previous tutorial we created ‘catalog’ and ‘search_results’ fixtures; we injected them into a step definition like this:

@given("these books in the catalog")
def these_books_in_the_catalog(step: PickleStep, request: FixtureRequest):
    catalog = request.getfixturevalue('catalog')
    catalog.add_books_to_catalog(step.data_table)

However, there is a much simpler (but less explicit) way to make use of the fixture; you only need to list it as a parameter in the declaration of the step definition function.

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

We will change all of our step definitions to use this simpler method.

Step 2 – Switch From Regular Expressions to Cucumber Expressions

Our step definition for ‘When a name search is performed for Stephen’ will also handle ‘When a title search…’; it uses a regular expression to match either ‘name’ or ‘title’.

@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: str, 
						 search_term: str, 
    						 catalog: Catalog,
                                                    search_results: list):

Switching from regular expressions to Cucumber Expressions could be done like this:

@when(parsers.cucumber_expression("a {} search is performed for {}")," +
                       anonymous_group_names=('search_type',"search_term"),
)
def a_SEARCH_TYPE_is_performed_for_SEARCH_TERM(search_type: str, 
						 search_term: str, 
    						 catalog: Catalog,
                                                    search_results: list):
    if search_type == "title":
        raise NotImplementedError("Title searches are not yet implemented.")
    search_results.append(catalog.search_by_author(search_term))

However, this isn’t as good as using a regular expression; the regular expression restricted the parameter to be either ‘name’ or ‘title’ – the only two types of searches that we allow. The Cucumber Expression allows the parameter to be any string at all; if we did it this way we would have to add code to the step definition to validate the input and to raise an error if the Gherkin contained anything other than ‘name’ or ‘title’. It would be much cleaner if we could get Pytest-BDD-NG to do the validation for us (as it did with the regular expression parser) – and we can do that if we add custom parameter types to our Cucumber Expression.

Step 3 – Add a Custom Parameter Type

Our Cucumber Expression can restrict the search_type parameter to either ‘name’ or ‘title’ if we create a custom parameter type. Creating a custom parameter type takes a few steps:

  1. Create an Enum for the allowed values for the Gherkin parameter (in this case, ‘name’ and ‘title’)
  2. Create a ParameterTypeRegistry; we must pass this to the Cucumber Expressions parser
  3. Populate the ParameterTypeRegistry with the appropriate values:
    • Name
    • Regular expression to match the parameters
    • Type
    • Transformer, to perform any desired tranformations on the Gherkin Parameter
from enum import Enum

from cucumber_expressions.parameter_type import ParameterType
from cucumber_expressions.parameter_type_registry import ParameterTypeRegistry

# Search types
class AllSearchTypes(Enum):
    Name = "name"
    Title = "title"

def all_search_types() -> ParameterTypeRegistry:
    parameter_type_registry = ParameterTypeRegistry()
    attribute_parameter_type_registry = ParameterType(
        name="AllSearchTypes",
        regexp="name|title",
        type=AllSearchTypes,
        # This converts the Gherkin parameter from a string to an Enum
        transformer=lambda s: AllSearchTypes(s),
        use_for_snippets="",
        prefer_for_regexp_match=False,
    )
    parameter_type_registry.define_parameter_type(attribute_parameter_type_registry)
    return parameter_type_registry

I put the custom parameter types in tests/steps/custom_parameter_types.py.

Step 4 – Convert Our Gherkin Parameters to Enums

By ‘Gherkin parameters’ we mean those parts of your Gherkin that you want to capture in the step definitions. For example, if you use these lines of Gherkin in your requirements:
  • Given an active customer account
  • Given an inactive customer account
  • Given a new customer account
  • Given an expired customer account

then you should probably have just one step definition that handles all four of these lines, and that step definition should capture the third word in the Gherkin. That third word – ‘active’, ‘inactive’, ‘new’, or ‘expired’ is a parameter.

Why should we care whether the parameters are strings or Enums? Converting these parameters into Enums will require additional code; what is the advantage of using Enums?

The problem with using strings is that those strings must then appear in different places in our code, and your IDE cannot validate them. That is, we currently have a step definition that contains this code:

    if search_type == "title":
        raise NotImplementedError("Title searches are not yet implemented.")
    search_results.append(catalog.search_by_author(search_term))

If we make a mistake here and type ‘titl’ instead of ‘title’, nothing is going to automatically catch that mistake – not your IDE, not Mypy, and not Pydantic. This means that your code will run, but it will contain a subtle bug – this is a Bad Thing.

In addition, if you decide to rename one of the parameter values – for example, if your Product Owner decides that we should no longer talk about ‘expired’ customer accounts, but should instead talk about ‘suspended’ customer accounts – you can’t ask your IDE to simply ‘Rename Symbol’. Your IDE has no way to know whether a particular instance of ‘expired’ refers to this parameter value or to something else; you will have to manually search for all instances of ‘expired’ and “expired” and “EXPIRED”, etc., and decide whether each one refers to this parameter value or to something else. This is also a Bad Thing.

If we use Enums for parameter values all of these problems go away – if we mistype an Enum value our IDE will tell us that it is invalid. If we change ‘expired’ to ‘suspended’ our IDE can globally change that Enum name for us.

We created an Enum class for our Gherkin parameters, and in the ParameterTypeRegistry we included a tranformer that converts the string ‘name’ to the Enum AllSearchTypes.Name (and similarly for ‘title’). Now all we need to do is pass this ParameterTypeRegistry to the Cucumber Expression parser; once we do this the Cucumber Expression parser will only accept valid parameters (the parameters that we declared), and it will convert those parameters into their equivalent Enums before passing them to the step definition code. Passing in the ParameterTypeRegistry requires this decorator:

@when(partial(CucumberExpression,
              # This returns an 'all_search_types' ParameterTypeRegistry
              parameter_type_registry = all_search_types(),
              # This is the Cucumber Expression that is used to match the 
              # Gherkin. That is, any Gherkin that matches this Cucumber 
              # Expression will be run using this function.
             )("a {AllSearchTypes} search is performed for {}"),
              # This line assigns names to the two capture groups '{}' in the 
              # Cucumber Expression.
              anonymous_group_names=('search_type',"search_term"))
def a_SEARCH_TYPE_is_performed_for_SEARCH_TERM(search_type: AllSearchTypes,
                                               search_term: str,
                                               catalog: Catalog,
                                               search_results: list):

You must also add these imports to the step definition file:

from functools import partial

from pytest_bdd.parsers import cucumber_expression as CucumberExpression

from steps.custom_parameter_types import all_search_types, AllSearchTypes

Since the custom parameter type converts the Gherkin parameters from strings to Enums, our step definition code must also change; it must match on Enums rather than strings.

    if search_type == AllSearchTypes.Title:
        search_results.append(catalog.search_by_title(search_term))
    elif search_type == AllSearchTypes.Name:
        search_results.append(catalog.search_by_author(search_term))
    else:
        raise ValueError(f"Invalid search type: '{search_type.name}'.")

With our code as it is currently written we can’t ever get an invalid search type; the Cucumber Expression parser won’t allow it. However, this ‘else’ clause is still good defensive coding – if in the future someone adds a new search type (for example, ISBN) to the custom parameter type, but they forget to add the appropriate code in the step definition, the ‘else’ clause will raise an error with an informative error message.

We have now accomplished all of our goals for this tutorial:
  1. We have simplified fixture Injection
  2. We are using the Cucumber Expressions parser
  3. We have created a custom parameter type and passed it to the Cucumber Expressions parser
  4. Our custom parameter type is converting the Gherkin parameters from strings to Enums; this makes it possible for our IDE to type-check our Gherkin parameter comparisons.

What would you like to see in the next tutorial? Please leave comments, or send me an email and let me know!

Leave a Comment