Behavior Driven Development

Behavior Driven Development (BDD) is an agile software development technique. BDD enables non-technical participants to participate in writing tests.

The HILSTER Testing Framework enables users to write features in a gherkin-like language with add-ons that can be run as tests with seamless integration into the existing ecosystem.

Tests are written in form of Features, Rules, Scenarios and Steps, which are either Given, When, or Then steps. It is also possible to create Data Driven Feature Tests.

Thus every step is implemented in Python based on HILSTER Testing Framework.

HILSTER Testing Framework also offers requirements coverage in combination with the Dashboard.

For more information read the documentation for Behavior Driven Development.

This demo includes end-to-end tests for a web application calculator that runs in a browser.

Setup Webdriver

To be able to run the demo you need a recent webdriver for selenium for your browser. Please check the selenium-python documentation and selenium documentation for more information.

The webdriver shall be placed into behavior_driven_development folder or put into the PATH environment variable.

Behavior Driven Development based End-to-End Test

The software under test is a calculator running in a browser that can be used interactively.

There are 10 different requirements specified for the calculator.

ID

Description

REQ-1

The calculator shall have buttons for the digits 0 to 9

REQ-2

The calculator shall have a clear button

REQ-3

The calculator shall have an equals button

REQ-4

The calculator shall support the mathematical operation +

REQ-5

The calculator shall support the mathematical operation -

REQ-6

The calculator shall support the mathematical operation *

REQ-7

The calculator shall support the mathematical operation /

REQ-8

The calculator shall support integer inputs

REQ-9

The calculator shall support float inputs

REQ-10

The calculator shall have a decimal point button

The demo consists of multiple tests that are written using features and Python glue code.

The following code blocks show the features covering all requirements.

Digit Buttons Feature

The Digit Buttons feature covers requirement REQ-1 and checks wheather the calculator offers all digit buttons.

Feature: Digit Buttons
  This feature describes the digit buttons.

  Requirements: REQ-1

  Scenario: Digit Buttons
    Given the calculator is cleared
    When 1234567890 is entered
    Then the result is 1234567890

Clear Button Feature

The Clear Button feature covers requirement REQ-2 and checks wheather the calculator can be cleared.

Feature: Clear Button
  This feature describes the clear button.

  Requirements: REQ-2

  Scenario: Clear Button
    Given the calculator is cleared
    When 23 is entered
    And the calculator is cleared
    Then the result is 0

Equals Button Feature

The Equals Button feature covers REQ-3 and checks wheather the calculator is cleared then the clear button is pressed.

Feature: Equals Button
  This feature describes the equals button.

  Requirements: REQ-3

  Scenario: Equals Button
    Given the calculator is cleared
    When 1+2 is entered
    And the equals button is pressed
    Then the result is 3

Addition Feature

The Addition Feature covers requirement REQ-4 and checks wheather the calculator supports the addition operation. Is uses a scenario outline.

Feature: Addition
  This feature describes the addition.

  Requirements: REQ-4

  Scenario Outline: Addition
    Given the calculator is cleared
    When <a> is added to <b>
    And the equals button is pressed
    Then the result is <result>

    Examples: Addition
      |   a |   b |
      |   1 |   1 |
      |   2 |  12 |
      | 123 | 456 |

Subtraction Feature

The Subtraction Feature covers requirement REQ-5 and checks wheather the calculator supports the subtraction operation. Is uses a scenario outline.

Feature: Subtraction
  This feature describes the subtraction.

  Requirements: REQ-5

  Scenario Outline: Subtraction
    Given the calculator is cleared
    When <a> is subtracted by <b>
    And the equals button is pressed
    Then the result is <result>

    Examples: Subtraction
      |    a |  b |
      |   11 |  1 |
      |   23 |  5 |
      | 1337 | 23 |

Multiplication Feature

The Multiplication Feature covers requirement REQ-6 and checks wheather the calculator supports the multiplication operation. Is uses a scenario outline.

Feature: Multiplication
  This feature describes the multiplication.

  Requirements: REQ-6

  Scenario Outline: Multiplication
    Given the calculator is cleared
    When <a> is multiplied by <b>
    And the equals button is pressed
    Then the result is <result>

    Examples: Multiplication
      |    a |  b |
      |   11 |  1 |
      |   23 |  5 |
      | 1337 | 23 |

Division Feature

The Division Feature covers requirement REQ-7 and checks wheather the calculator supports the division operation. Is uses a scenario outline.

Feature: Division
  This feature describes the division.

  Requirements: REQ-7

  Scenario Outline: Division
    Given the calculator is cleared
    When <a> is divided by <b>
    And the equals button is pressed
    Then the result is <result>

    Examples: Division
      |   a |  b |
      |  10 |  2 |
      |  12 |  3 |
      | 900 | 30 |

Decimal Point Feature

The Decimal Point Feature covert requirements REQ-8, REQ-9 and REQ-10 and checks wheather the calculator supports integer and float inputs.

Feature: Decimal Point
  This feature describes the decimal point.

  Requirements: REQ-8, REQ-9, REQ-10

  Scenario: Decimal Point
    Given the calculator is cleared
    When 10+2.5 is entered
    And the equals button is pressed
    Then the result is 12.5

External Data Feature

The External Data Feature covers requirements REQ-4, REQ-5, REQ-6 and REQ-7 to check all mathematical operations using an external data source input-data.csv.

Feature: External Data
  This feature describes the external data scenario outlines.

  Requirements: REQ-4, REQ-5, REQ-6, REQ-7

  Scenario Outline: External Data
    Given the calculator is cleared
    When <a><operation><b> is entered
    And the equals button is pressed
    Then the result is <result>

    Examples: Foo
      CSV: ../input-data.csv

The content of input-data.csv looks like the following:

a,operation,b,result
1,+,2,3
3,-,1,2
10,*,100,1000
100,/,10,10

Python Generator Data Feature

The Python Generator Data Feature covers requirements REQ-4, REQ-5, REQ-6 and REQ-7 to check all mathematical operations using a Python based data generator.

Feature: Python Generator Data
  This feature describes the external data scenario outlines.

  Requirements: REQ-4, REQ-5, REQ-6, REQ-7

  Scenario Outline: Python Generator Data
    Given the calculator is cleared
    When <a><operation><b> is entered
    And the equals button is pressed
    Then the result is <result>

    Examples:
      Generator: Calculations-Generator

Fixture

Communicating with the software under test is implemented using a fixture in calculator.py:

#
# Copyright (c) 2023, HILSTER - https://hilster.io
# All rights reserved.
#

from typing import Generator, Union, Dict, Optional

import htf
import os
import time
from selenium import webdriver  # type: ignore
from selenium.webdriver.common.by import By  # type: ignore
from selenium.webdriver.remote.webelement import WebElement


@htf.fixture("session")
def driver() -> Generator[webdriver.Chrome, None, None]:  # type: ignore
    """
    Webdriver fixture
    """
    os.environ["PATH"] = os.environ["PATH"] + ":."
    driver = webdriver.Chrome()
    yield driver
    driver.close()


class Calculator:
    """
    Digital twin for web application calculator.
    """

    def __init__(self, driver: webdriver.Chrome, settings: htf.Settings) -> None:  # type: ignore
        self.driver = driver
        self.settings = settings
        self.operations: Dict[str, WebElement] = {}
        self.numbers_elements: Dict[int, WebElement] = {}
        self.decimal_point: Optional[WebElement] = None
        self.clear: Optional[WebElement] = None

        self.delay = settings.delay

        # open webapp
        self.driver.get(f"file://{os.path.join(os.path.dirname(__file__), settings.webapp)}")

        # update elements
        self.get_elements()

    def get_elements(self) -> None:
        """
        Get all elements.
        """

        self.clear = self.driver.find_element(By.CSS_SELECTOR, "#clear")  # Clear
        self.result = self.driver.find_element(By.CSS_SELECTOR, "#viewer")  # Results viewer
        self.equals = self.driver.find_element(By.CSS_SELECTOR, "#equals")  # Equal button

        numbers = self.driver.find_elements(By.CSS_SELECTOR, ".num")  # List of numbers

        for number in numbers:
            try:
                self.numbers_elements[int(number.get_attribute("data-num"))] = number
            except ValueError:
                assert number.get_attribute("data-num") == "."
                self.decimal_point = number

        operations = self.driver.find_elements(By.CSS_SELECTOR, ".ops")  # List of operators

        for operation in operations:
            self.operations[operation.get_attribute("data-ops")] = operation

    def input(self, element: str) -> None:
        """
        Input on an element in the web application.

        Args:
            element: the element to be clicked
        """
        assert len(element) == 1, "element must contain 1 element"

        if element in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]:
            # enter a number
            self.numbers_elements[int(element)].click()
        elif element in ["."]:
            # enter decimal point
            self.decimal_point.click()  # type: ignore
        elif element in ["+", "-", "*", "/"]:
            # enter operation
            mapping = {"+": "plus", "-": "minus", "*": "times", "/": "divided by"}
            self.operations[mapping[element]].click()
        elif element in ["="]:
            # equals
            self.equals.click()
        elif element in ["C"]:
            # clear
            self.clear.click()  # type: ignore

        if self.delay:
            time.sleep(self.delay)

    def input_expression(self, expression: str) -> None:
        """
        Input an expression
        """
        if expression:
            for element in expression:
                self.input(element)

    def calculate_result(self) -> None:
        """
        Enter = to calculate the result.
        """
        self.input("=")

    def get_result(self) -> Union[float, str]:
        """
        Enter = and return the result
        """
        # get result
        try:
            return float(self.result.text)
        except ValueError:
            return self.result.text

    def evaluate(self, expression: str) -> Union[float, str]:
        """
        Evaluate expression and return the result.

        Args:
             expression: the expression to be evaluated

        Returns:
            float: the result from the web application
        """
        self.input_expression(expression + "=")
        return self.get_result()

    def add(self, a: float, b: float) -> Union[float, str]:
        """
        Evaluate a+b
        """
        return self.evaluate(f"{a}+{b}")

    def subtract(self, a: float, b: float) -> Union[float, str]:
        """
        Evaluate a-b
        """
        return self.evaluate(f"{a}-{b}")

    def multiply(self, a: float, b: float) -> Union[float, str]:
        """
        Evaluate a*b
        """
        return self.evaluate(f"{a}*{b}")

    def divide(self, a: float, b: float) -> Union[float, str]:
        """
        Evaluate a/b
        """
        return self.evaluate(f"{a}/{b}")


@htf.fixture("test")
def calculator(driver: webdriver.Chrome, settings: htf.Settings) -> Generator[Calculator, None, None]:
    calculator = Calculator(driver, settings)
    calculator.clear.click()  # type: ignore
    yield calculator

Steps

In order to be able to run features as tests, glue code is required. The Python glue code is implemented in steps.py:

#
# Copyright (c) 2023, HILSTER - https://hilster.io
# All rights reserved.
#

from typing import Dict, Any, Union, Generator

import htf
import htf.assertions as assertions
from behavior_driven_development.calculator import Calculator


@htf.given("the calculator is cleared")
@htf.when("the calculator is cleared")
def clear_calculator(calculator: Calculator) -> None:
    calculator.clear.click()  # type: ignore


@htf.when("{expression} is entered")
def enter_expression(expression: str, calculator: Calculator) -> None:
    calculator.input_expression(expression)


@htf.when("{a} is added to {b:g}")
def add(a: float, b: float, calculator: Calculator) -> None:
    calculator.input_expression(f"{a}+{b}")


@htf.when("{a} is subtracted by {b}")
def subtract(a: float, b: float, calculator: Calculator) -> None:
    calculator.input_expression(f"{a}-{b}")


@htf.when("{a} is multiplied by {b}")
def multiply(a: float, b: float, calculator: Calculator) -> None:
    calculator.input_expression(f"{a}*{b}")


@htf.when("{a} is divided by {b}")
def divide(a: float, b: float, calculator: Calculator) -> None:
    calculator.input_expression(f"{a}/{b}")


@htf.when("the equals button is pressed")
def equals_button_pressed(calculator: Calculator) -> None:
    calculator.calculate_result()


@htf.then("the result is {expected_result:g}")
def check_result(expected_result: float, calculator: Calculator) -> None:
    assertions.assert_equal(expected_result, calculator.get_result())


def _clean_up_data(data: Dict[str, str]) -> Dict[str, float]:
    a: Any = data["a"]
    b: Any = data["b"]
    try:
        if "." in a:
            a = float(a)
        else:
            a = int(a)
    except TypeError:
        pass
    try:
        if "." in b:
            b = float(b)
        else:
            b = int(b)
    except TypeError:
        pass

    return_data: Dict[str, float] = {}
    return_data["a"] = float(a)
    return_data["b"] = float(b)
    return return_data


@htf.transformation("Addition")
def expected_addition_result(data: Dict[str, str]) -> Dict[str, float]:
    clean_data = _clean_up_data(data)
    clean_data["result"] = clean_data["a"] + clean_data["b"]
    return clean_data


@htf.transformation("Subtraction")
def expected_subtraction_result(data: Dict[str, str]) -> Dict[str, float]:
    clean_data = _clean_up_data(data)
    clean_data["result"] = clean_data["a"] - clean_data["b"]
    return clean_data


@htf.transformation("Multiplication")
def expected_multiplication_result(data: Dict[str, str]) -> Dict[str, float]:
    clean_data = _clean_up_data(data)
    clean_data["result"] = clean_data["a"] * clean_data["b"]
    return clean_data


@htf.transformation("Division")
def expected_division_result(data: Dict[str, Union[float, str]]) -> Dict[str, Union[float, str]]:
    data["result"] = float(data["a"]) / float(data["b"])
    return data


@htf.data_generator("Calculations-Generator")
def generate_calculations() -> Generator[Dict[str, Union[float, str]], None, None]:
    yield dict(a=1, operation="+", b=2, result=3)
    yield dict(a=3, operation="-", b=1, result=2)
    yield dict(a=10, operation="*", b=100, result=1000)
    yield dict(a=100, operation="/", b=10, result=10)

Run Tests

To run the demo, run

cd behavior_driven_development
htf -o calculator.py steps.py features/*.feature

To run the demo with asyncio, run

cd behavior_driven_development_async
htf -o calculator.py steps.py features/*.feature

Alternative Implementation

There is an alternative implementation for the same tests without using Behavior Driven Development but plain Python code:

The tests are implemented in test_web_application.py:

#
# Copyright (c) 2023, HILSTER - https://hilster.io
# All rights reserved.
#

# example how to test a web application calculator

import htf
from behavior_driven_development.calculator import Calculator


@htf.requirements("REQ-1")
def test_digit_button(calculator: Calculator) -> None:
    """
    This test asserts that all digits input buttons exists.
    """
    htf.assert_almost_equal(calculator.evaluate("1234567890"), 1234567890)  # type: ignore


@htf.requirements("REQ-2")
def test_clear_button(calculator: Calculator) -> None:
    """
    This test asserts that the clear button clears the result to zero
    """
    htf.assert_almost_equal(float(calculator.evaluate("23")), 23)  # type: ignore
    calculator.clear.click()  # type: ignore
    htf.assert_almost_equal(float(calculator.get_result()), 0)  # type: ignore


@htf.requirements("REQ-3")
def test_equals_button(calculator: Calculator) -> None:
    """
    This test asserts that the equals button evaluates the result of the input.
    """
    htf.assert_almost_equal(float(calculator.evaluate("1+2")), 3)


@htf.requirements("REQ-4")
@htf.data([[1, 1], [2, 12], [123, 456]], unpack=True)
def test_addition(a: int, b: int, calculator: Calculator) -> None:
    expected_result = a + b
    htf.assert_almost_equal(float(calculator.add(a, b)), expected_result)


@htf.requirements("REQ-5")
@htf.data([[11, 1], [23, 5], [1337, 23]], unpack=True)
def test_subtraction(a: int, b: int, calculator: Calculator) -> None:
    expected_result = a - b
    htf.assert_almost_equal(float(calculator.subtract(a, b)), expected_result)


@htf.requirements("REQ-6")
@htf.data([[11, 1], [23, 5], [1337, 23]], unpack=True)
def test_multiplication(a: int, b: int, calculator: Calculator) -> None:
    expected_result = a * b
    htf.assert_almost_equal(float(calculator.multiply(a, b)), expected_result)


@htf.requirements("REQ-7")
@htf.data([[10, 2], [12, 3], [900, 30]], unpack=True)
def test_division(a: int, b: int, calculator: Calculator) -> None:
    expected_result = a / b
    htf.assert_almost_equal(float(calculator.divide(a, b)), expected_result)


@htf.requirements("REQ-4", "REQ-5", "REQ-6", "REQ-7")
@htf.csv_data("input-data.csv")
def test_external_data(a: int, operation: str, b: int, result: int) -> None:
    print(a, operation, b, result)


@htf.requirements("REQ-8", "REQ-9", "REQ-10")
def test_integer_and_float_inputs(calculator: Calculator) -> None:
    """
    This test asserts that integer as well as float inputs are supported.
    """
    htf.assert_almost_equal(float(calculator.evaluate("10+2.5")), 12.5)


if __name__ == "__main__":
    htf.main(open_report=True)

To run the demo, run

cd behavior_driven_development
htf -o calculator.py test_web_application.py

To run the demo with asyncio, run

cd behavior_driven_development_async
htf -o calculator.py test_web_application.py