BDD: Behind the scenes (Part 2)
Introduction
In Part 1, we explored the fundamental concepts of Behavior-Driven Development (BDD) and the tools that support it. Now, let’s dive into a practical example to see how these concepts come together in an example.
For our example, we’ll use a simple ToDo application. This application allows users to add, complete, and delete tasks. It was taken from Azure-Samples/node-todo. We’ll walk through the process of writing and implementing a BDD test for adding a new task.
Reference BDD test project structure
The proposed BDD test project structure was implemented a few year ago and published as a reference on GitHub: https://github.com/nazarii-piontko/ToDo-BDD:
app/
docker-compose.yml
: Defines the Docker services for running the application under test.
asserts/
general.py
: Contains general-purpose assertion methods.html_element.py
: Specific assertions for HTML elements.
features/
todo.feature
: Simple Gherkin file describing one Feature of the sample todo application.
infrastructure/
app_controller.py
: Manages the lifecycle of the application under test.artifacts.py
: Handles test artifacts like screenshots.config_keys.py
,config_providers.py
,config.py
: Manage configuration settings.errors.py
: Defines custom exceptions.page.py
: Base class for Page Objects. Contains common functions likeget_elements_by_css
orcreate_screenshot
.registry.py
: Implements a services registry or dependency injection container.session.py
:sessions.py
: Manage test sessions. Session contain reference to session’s WebDriver instance.utils.py
: Utility functions.web_driver_factory.py
: Creates and manages WebDriver instances.
pages/
home_page.py
: Page Object for the home page.
steps/
general.py
: General step definitions.todo.py
: Step definitions specific to todo functionality.
tools/
docker/entrypoint.sh
: Entry point script for Docker containers.web-drivers-chrome/
andweb-drivers-gecko/
: Contains WebDriver executables for different platforms.
behave.ini
: Configuration for the Behave framework.config.json
: default JSON configuration file, configuration could be overridden from environment variables as well.Dockerfile
: Defines the Docker image for running tests.environment.py
: Sets up the test environment for Behave - Behave Hooks.requirements.txt
: Lists Python dependencies.
This structure reflects a well-organized BDD test project:
- Each directory has a specific purpose.
- Separate directories for pages, steps, and infrastructure.
- Features directory for Gherkin scenarios.
- Custom assertions to make tests more readable and maintainable.
- WebDriver executables for different operating systems.
- Docker integration for consistent test environments.
- Infrastructure layer for handling complex setup and management tasks.
The Flow
Feature file
Let’s start with a simple scenario.
Feature: I should be able to add ToDo items
Background:
Given open home page
And wait until loading is done
Scenario: Add ToDo items
When I add ToDo item "ToDo #1"
Then I should see ToDo items
"""
ToDo #1
"""
In this example, we would like to check the feature of adding a new ToDo item. We have a Background
which will run before the Scenario
. It will open the browser on the home page and ensure that the home page has been fully loaded, all necessary bootstrap JS scripts are executed, and the page is ready to accept user input.
Next, we have a scenario which has one action and one verification expressed in human-readable form.
The action When
instructs to add a ToDo item with the content “ToDo #1”. The verification Then
checks whether the list of to-do items displayed on the screen contains the exact sequence with the one just added item.
Steps
As we know from the previous part, each step, whether it’s a When
or a Then
, has to be “bound” to some code.
These bindings demonstrate how the human-readable Gherkin steps are connected to the actual test code, showing the translation from natural language to programmatic actions and verifications.
Given open home page
@given('open home page')
def open_home_page(context):
reg(context)[HomePage].navigate()
This step opens the home page. The reg(context)
function retrieves a services registry, from which we get the HomePage
object and call its navigate()
method.
Given wait until loading is done
@given('wait until loading is done')
def wait_until_loading_disappear(context):
r = reg(context)
timeout = r[Config].get_float(WellKnownConfigKeys.HTTP_WAIT_TIMEOUT)
def is_loading_visible():
loading_element = r[HomePage].get_loading_element()
return loading_element is not None and loading_element.is_displayed()
try:
execute_with_retry(lambda: is_loading_visible(),
timeout=timeout)
except TimeoutError:
r[GeneralAssert].fail('Loading element is not disappeared in {} seconds'.format(timeout))
This step waits for the page to finish loading. It retrieves a timeout value from the configuration, then repeatedly checks if a loading element is visible. If the loading element doesn’t disappear within the timeout period, it fails the test.
When I add ToDo item "{todo}"
@when('I add ToDo item "{todo}"')
def add_todo_item(context, todo):
reg(context)[HomePage].add_todo_item(todo)
This step adds a ToDo item. The {todo}
in the step definition is a placeholder that captures the text between the quotes in the Gherkin step. This captured text is passed as the todo
parameter to the function, which then calls the add_todo_item
method of the HomePage
object.
Then I should see ToDo items
@then('I should see ToDo items')
def see_todo_items(context):
r = reg(context)
expected_todo_items = context.text.splitlines()
actual_todo_items = r[HomePage].get_todo_items()
r[GeneralAssert].assertSequenceEqual(expected_todo_items,
actual_todo_items,
'ToDo items do not match\nExpected:\n{}\n\nActual:\n{}'
.format('\n'.join(expected_todo_items), '\n'.join(actual_todo_items)))
This step verifies the ToDo items. The context.text
refers to the multi-line string (“"”…”””) in the Gherkin step. It’s split into lines to get the expected ToDo items. The actual ToDo items are retrieved from the HomePage
object. Then, it uses a custom assertion to compare the expected and actual items, providing a detailed error message if they don’t match.
Page Object: HomePage
The HomePage
class is a Page Object that represents the home page of the ToDo application. It inherits from a base Page
class and provides methods to interact with specific elements on the home page.
ctor
class HomePage(Page):
def __init__(self, registry: Registry):
Page.__init__(self, registry)
The constructor takes a services Registry
object. It calls the parent Page
class constructor with this registry.
navigate()
def navigate(self) -> NoReturn:
self._navigate('')
The navigate
method is used to navigate to the home page. It calls a parent class method _navigate
with an empty string, meaning the home page is at the root URL.
get_loading_element()
def get_loading_element(self) -> Union[WebElement, None]:
element = self.get_element_by_css('span.loading')
if element is not None:
element = self.get_element_parent(element)
return element
This method finds the loading element on the page. It first looks for a span
with class loading
, then gets its parent element. It because the actual visible loading indicator is the parent of this span. This is a specific of this page.
get_todo_items()
def get_todo_items(self) -> Sequence[str]:
elements = self.get_elements_by_css('li.list-group-item > p')
todo_items = [e.text for e in elements]
return todo_items
This method retrieves all ToDo items from the page. It finds all p
elements that are direct children of li
elements with class list-group-item
, then extracts their text content.
add_todo_item(todo)
def add_todo_item(self, todo) -> NoReturn:
input_field = self.get_element_by_css('#todoInput')
if input_field is None:
raise TestError('Unable to find input for new ToDo item')
input_field.send_keys(todo)
submit_button = self.get_element_by_css('form > div > button')
if submit_button is None:
raise TestError('Unable to find submit button to create new ToDo item')
current_todo_items_count = self.get_todo_items_count()
submit_button.click()
execute_with_retry(lambda: self.get_todo_items_count() == current_todo_items_count,
timeout=self._registry[Config].get_float(WellKnownConfigKeys.HTTP_WAIT_TIMEOUT))
This method adds a new ToDo item:
- It finds the input field for new ToDo items.
- If the input field isn’t found, it raises a
TestError
. - It enters the new ToDo text into the input field.
- It finds the submit button.
- If the submit button isn’t found, it raises a
TestError
. - It gets the current count of ToDo items.
- It clicks the submit button.
- It then waits (with retries) for the ToDo item count to increase, indicating the new item has been added. The timeout for this wait is retrieved from the configuration. This logic is a little bit fuzzy, in properly constructed application there might be another explicit indicator for completion of such actions.
This class demonstrates several best practices for Page Objects:
- It encapsulates all interactions with the page, hiding the details of how elements are found and interacted with.
- It includes error handling for cases where expected elements are not found.
- It uses explicit waits to handle asynchronous operations (like adding a new ToDo item).
- It provides methods that correspond to user actions (like navigating to the page or adding a ToDo item) rather than exposing low-level WebDriver operations.
Page Object: Base
The Page
class serves as a foundation for all page objects in the application. It encapsulates common functionality for interacting with web pages:
__init__(self, registry: Registry)
: Initializes the page object with a service registry for accessing dependencies.wait(self, seconds: float)
: Implements a simple wait function, useful for explicit waits in tests.get_elements_by_css(self, css_path: str)
: Finds and returns multiple web elements using a CSS selector._navigate(self, path)
: Internal method to navigate to a specific page URL._get_session(self)
and_get_driver(self)
: Helper methods to retrieve the current session and WebDriver instance.
This base class provides a consistent interface for all page objects, promoting code reuse and maintainability. It abstracts away common WebDriver operations and provides a foundation for building more specific page objects.
Hooks
In this example we have implemented three hooks: before_all
, before_scenario
, and after_scenario
.
before_all(context)
def before_all(context):
r = Registry()
config = Config([EnvironmentConfigProvider(), JsonConfigProvider('./config.json')])
logger = getLogger()
r.set(logger)
r.set(config)
r.set(Artifacts(config))
r.set(WebDriverFactory(config))
r.set(AppController(config, logger))
r.set(Sessions())
r.set(GeneralAssert())
r.set(HtmlElementAssert(r[GeneralAssert]))
set_registry(context, r)
This before_all
hook runs once before all scenarios. It:
- Creates a new Registry.
- Sets up configuration from environment variables and a JSON file.
- Initializes and registers core services like Artifacts, WebDriverFactory, AppController, and Sessions.
- Sets up assertion helpers.
- Attaches the Registry to the Behave context.
before_scenario(context, scenario)
def before_scenario(context, scenario):
r = reg(context)
r[AppController].start()
default_session = Session('default', r[WebDriverFactory].create())
r[Sessions].add(default_session)
for page_type in PAGES:
r.set(page_type(r))
This before_scenario
hook runs before each scenario. It:
- Retrieves the Registry from the context.
- Starts the application under test.
- Creates a default WebDriver session.
- Initializes and registers page objects for the scenario.
after_scenario(context, scenario)
def after_scenario(context, scenario):
r = reg(context)
for session in r[Sessions].get_sessions():
session.get_web_driver().quit()
r[Sessions].clear()
r.get(AppController).stop()
for page_type in PAGES:
r.remove(page_type)
This after_scenario
hook runs after each scenario. It:
- Closes all WebDriver sessions.
- Clears the Sessions registry.
- Stops the application under test.
- Removes page objects from the registry.
These hooks ensure a clean, isolated environment for each test scenario, improving test reliability and maintainability by properly setting up and tearing down resources.
As an alternative more performed version of this implementation might just clean the database and/or other persistance in after_scenario
. In addition after_feature
might be used for complete restart of the application under test.
Infrastructure layer
Service Registry
The Service Registry is a simple dependency injection mechanism that acts as a central place to store and retrieve service instances in the application.
It provides a way to manage dependencies between different components of the application, allowing for loose coupling and easier testing.
Pros:
- Simple to implement and understand
- Flexible - easy to swap implementations
- Facilitates testing by allowing easy replacement of services with mocks
Cons:
- Can become a form of global state if overused
- Doesn’t manage object lifetimes out of the box
- Can be harder to manage in larger applications with complex dependency graphs
In such small BDD tests codebase in Python, the Services Registry offers a pragmatic approach to dependency management without the complexity of full-fledged IoC containers.
Application Controller
The Application Controller manages the lifecycle of the application under test. While this implementation uses Docker Compose, it’s designed to be adaptable to other deployment methods.
Key features:
- Starting the application:
def start(self) -> NoReturn: docker_compose_file = self._config.get(WellKnownConfigKeys.APP_DOCKER_COMPOSE_FILE) if docker_compose_file is None: self._logger.debug('app_controller: docker-compose file is missing in configuration') docker_process = None else: docker_process = self._run_docker_compose_up(docker_compose_file) execute_with_retry(lambda: not self._is_app_accessible(docker_process), timeout=self._config.get_float(WellKnownConfigKeys.WAIT_TIMEOUT))
Launches the application using Docker Compose, then waits for it to become accessible.
- Stopping the application:
def stop(self) -> NoReturn: docker_compose_file = self._config.get(WellKnownConfigKeys.APP_DOCKER_COMPOSE_FILE) if docker_compose_file is None: return docker_process = self._run_docker_compose_down(docker_compose_file) execute_with_retry(lambda: docker_process.poll() is None, timeout=self._config.get_float(WellKnownConfigKeys.WAIT_TIMEOUT))
Stops the application using Docker Compose.
- Providing the application base URI:
def get_uri(self) -> str: return self._config.get(WellKnownConfigKeys.APP_BASE_URI)
Returns the base URI of the application from the configuration or any other source depending on used technology.
This design allows for easy adaptation to other deployment methods by modifying the start()
and stop()
methods while keeping the interface consistent for the rest of the test framework.
Web Driver Factory
The WebDriverFactory encapsulates the creation of WebDriver instances based on configuration and environment:
-
It supports both local and remote WebDriver creation.
- For local drivers, it supports Chrome, Firefox, and Edge:
if driver_type == WebDriverFactory.WEB_DRIVER_TYPE_CHROME: return self._create_chrome_driver()
- For remote drivers, it sets up the appropriate options based on the driver type:
if driver_type == WebDriverFactory.WEB_DRIVER_TYPE_CHROME: options = ChromeOptions()
- It handles platform-specific driver executables:
def _get_platform_dependent_driver_name() -> str: if platform in ('linux', 'linux2'): return 'linux' if platform == "win32": return 'win.exe'
- It uses configuration to determine settings like remote execution and driver type:
if self._config.get_bool(WellKnownConfigKeys.SELENIUM_REMOTE): return self._create_remote_driver()
This factory provides a flexible way to create WebDriver instances, accommodating different browsers and execution environments (local/remote) based on configuration.
Demo
Note: the test speed artificially slowed down for demonstration purposes.
Last Thoughts
BDD, when implemented effectively, can significantly improve communication between technical and non-technical team members, ensure that development efforts are aligned with business goals, and provide a robust suite of automated tests that validate the application’s behavior.
Even more:
-
Integrate your BDD tests into your CI/CD pipeline. This ensures that your scenarios are run automatically with every code change, providing quick feedback on whether new changes have broken existing functionality.
-
For larger test suites, consider implementing parallel test execution to reduce overall test run time.
-
Extend your BDD approach to cover API testing. Tools like Behave can be used with libraries like requests to test RESTful APIs.
-
Adapt your BDD setup for mobile app testing using tools like Appium alongside Behave.
-
While BDD is primarily for functional testing, you can incorporate some performance expectations into your scenarios, such as response time assertions.
Remember, BDD is not just about tools and techniques, but about improving communication and collaboration within your team. As you implement BDD, continuously seek feedback from all stakeholders and refine your process to best suit your team’s needs.
By embracing BDD, you’re not just creating tests, but building a shared understanding of your application’s behavior that will guide your development efforts and ensure you’re building the right thing, in the right way.
Leave a comment