Skip to content

Architecture

Support for additional technologies, e.g. ElasticSearch, can be added by sub-classing these classes and adding specific steps, setup/teardown, and configuration. This allows reusing the basic configuration, reporting, logging, and retrying mechanisms. Further, application tests, steps, and configurations reuse by subclassing from technologies.

flowchart TD
    A[Tests: Define BDD scenarios as series of steps, also define specific setup and teardown] --> |contains| B[Steps: encapsulate UI or API operations and verifications, and may be composed of other steps]
    B --> |contains| C[Configurations: can be per environment, such as dev, qa, staging, and contain URLs, users, authentication schemes, encryption, etc.]
    B --> |uses| D[Matchers: Hamcrest matchers for single objects or for iterables]
    A --> |contains| C
    B --> |uses| E[Models: domain objects]

    subgraph Inheritance
        A1[GenericTests] -.-> |inherits| A2[Tests]
        B1[GenericSteps] -.-> |inherits| B2[Steps]
        C1[AbstractConfiguration] -.-> |inherits| C2[Configuration]
    end

Extending the Framework

To add support for a new technology (e.g., messaging, database), create: - MyTechConfiguration(BaseConfiguration) - MyTechSteps(GenericSteps[MyTechConfiguration]) - MyTechTests(AbstractTestsBase[MyTechSteps, MyTechConfiguration]) This pattern ensures you reuse the core BDD, configuration, and reporting mechanisms.

classDiagram
    %% Core Abstractions
    class AbstractTestsBase {
        <>
        +steps
        +_configuration
        +setup_method()
        +teardown_method()
    }
    class GenericSteps {
        <>
        +given
        +when
        +then
        +and_
        +with_
        +retrying()
        +eventually_assert_that()
    }
    class BaseConfiguration {
        <>
        +parser
    }

    %% Technology-Specific Extensions
    class RestTests
    class RestSteps
    class RestConfiguration

    class SeleniumTests
    class SeleniumSteps
    class SeleniumConfiguration

    %% Example: Custom Extension
    class TerminalXTests
    class TerminalXSteps
    class TerminalXConfiguration

    %% Relationships
    AbstractTestsBase <|-- RestTests
    AbstractTestsBase <|-- SeleniumTests
    SeleniumTests <|-- TerminalXTests

    GenericSteps <|-- RestSteps
    GenericSteps <|-- SeleniumSteps
    SeleniumSteps <|-- TerminalXSteps

    BaseConfiguration <|-- RestConfiguration
    BaseConfiguration <|-- SeleniumConfiguration
    SeleniumConfiguration <|-- TerminalXConfiguration

    RestTests o-- RestSteps : uses
    RestTests o-- RestConfiguration : configures

    SeleniumTests o-- SeleniumSteps : uses
    SeleniumTests o-- SeleniumConfiguration : configures

    TerminalXTests o-- TerminalXSteps : uses
    TerminalXTests o-- TerminalXConfiguration : configures

    %% Example extension note
    %% You can add new technologies by subclassing the three core abstractions:
    %% AbstractTestsBase, GenericSteps, and BaseConfiguration.

Key Classes

Class Description
AbstractTestsBase Base for all test scenarios; holds steps and config
GenericSteps Base for all step implementations; provides BDD keywords
BaseConfiguration Base for all configuration objects
RestTests REST-specific test base
RestSteps REST-specific steps
RestConfiguration REST-specific configuration
SeleniumTests Selenium-specific test base
SeleniumSteps Selenium-specific steps
SeleniumConfiguration Selenium-specific configuration
TerminalXSteps Example: custom UI steps
TerminalXConfiguration Example: custom UI configuration

Usage Examples

TerminalX Tests

@pytest.mark.external
@pytest.mark.selenium
class TerminalXTests(
    SeleniumTests[TerminalXSteps[TerminalXConfiguration],
                  TerminalXConfiguration]):
    _steps_type = TerminalXSteps
    _configuration = TerminalXConfiguration()

    # NOTE sections may be further collected in superclasses and reused across tests
    def login_section(
            self, user: TerminalXUser) -> TerminalXSteps[TerminalXConfiguration]:
        return (self.steps
                .given.terminalx(self.web_driver)
                .when.logging_in_with(user.credentials)
                .then.the_user_logged_in(is_(user.name)))

    def should_login(self):
        self.login_section(self.configuration.random_user)

    def should_find(self):
        (self.login_section(self.configuration.random_user)
            .when.clicking_search())

        for word in ["hello", "kitty"]:
            (self.steps
             .when.searching_for(word)
             .then.the_search_hints(yields_item(tracing(
                 contains_string_ignoring_case(word)))))

    @override
    def setup_method(self) -> None:
        from selenium.webdriver import Firefox
        from selenium.webdriver.firefox.options import Options as FirefoxOptions
        from selenium.webdriver.firefox.service import Service as FirefoxService
        from webdriver_manager.firefox import GeckoDriverManager
        if self._configuration.parser.has_option("selenium", "browser_type") \
                and self._configuration.parser["selenium"]["browser_type"] == "firefox":
            options = FirefoxOptions()
            service = FirefoxService(GeckoDriverManager().install())
            self._web_driver = Firefox(options=options, service=service)
            self._web_driver.set_window_size(1920, 1080)  # type: ignore
        else:
            super().setup_method()

The Setup Method

The setup_method demonstrates how default setup behavior can be overriden. In real world it would be pulled into a superclass that extends SeleniumTests.

The Configuration

Furthermore, the self._configuration.parser["selenium"]["browser_type"] could be defined as a method on the TerminalXConfiguration class, or a superclass of it.

The configuration is loaded from two sources, in this example:

  1. TerminalXConfiguration class looks for a matching terminalx_configuration.ini file under configurations/.
  2. pytest could be launched with a --config parameter to override this or add properties:
    pytest --config selenium:browser_type=firefox qa-pytest-examples/tests/terminalx_tests.py::TerminalXTests
    

Any subclass of BaseConfiguration looks for a matching ini file, this way multiple configurations can be used.

If there is a TEST_ENVIRONMENT environment variable its value will be chained to the path of ini file, this way one can select which configuration set shall be used at runtime.

Swagger Petstore Tests

@pytest.mark.external
class SwaggerPetstoreTests(
    RestTests[SwaggerPetstoreSteps[SwaggerPetstoreConfiguration],
              SwaggerPetstoreConfiguration]):
    _steps_type = SwaggerPetstoreSteps
    _configuration = SwaggerPetstoreConfiguration()

    @pytest.mark.parametrize("pet", SwaggerPetstorePet.random(range(4)))
    def should_add(self, pet: SwaggerPetstorePet):
        (self.steps
            .given.swagger_petstore(self.rest_session)
            .when.adding(pet)
            .then.the_available_pets(yields_item(tracing(is_(pet)))))

Combined Tests

@pytest.mark.external
@pytest.mark.selenium
class CombinedTests(
        RestTests[CombinedSteps, CombinedConfiguration],
        SeleniumTests[CombinedSteps, CombinedConfiguration]):
    _steps_type = CombinedSteps
    _configuration = CombinedConfiguration()

    def should_run_combined_tests(self):
        random_pet = next(SwaggerPetstorePet.random())
        random_user = random.choice(self.configuration.users)

        (self.steps
            .given.swagger_petstore(self.rest_session)
            .when.adding(random_pet)
            .then.the_available_pets(yields_item(tracing(is_(random_pet))))
            .given.terminalx(self.web_driver)
            .when.logging_in_with(random_user.credentials)
            .then.the_user_logged_in(is_(random_user.name)))

RabbitMQ Self Tests

class RabbitMqSelfTests(
    RabbitMqTests[int, str,
                  RabbitMqSteps[int, str, RabbitMqSelfConfiguration],
                  RabbitMqSelfConfiguration]):
    _queue_handler: QueueHandler[int, str]
    _steps_type = RabbitMqSteps
    _configuration = RabbitMqSelfConfiguration()

    def should_publish_and_consume(self) -> None:
        (self.steps
            .given.a_queue_handler(self._queue_handler)
            .when.publishing([Message("test_queue")])
            .and_.consuming()
            .then.the_received_messages(yields_item(
                tracing(is_(Message("test_queue"))))))

    @override
    def setup_method(self) -> None:
        super().setup_method()
        self._queue_handler = QueueHandler(
            channel := self._connection.channel(),
            queue_name=require_not_none(
                channel.queue_declare(
                    queue=EMPTY_STRING, exclusive=True).method.queue),
            indexing_by=lambda message: hash(message.content),
            consuming_by=lambda bytes: bytes.decode(),
            publishing_by=lambda string: string.encode())

    @override
    def teardown_method(self) -> None:
        try:
            self._queue_handler.close()
        finally:
            super().teardown_method()

qa_testing_utils.pytest_plugin

QA Testing Utils – Pytest Plugin

This pytest plugin provides shared testing infrastructure for Python monorepos or standalone projects using the qa-testing-utils module.

Features

  1. Per-module logging configuration:
  2. During test session startup, the plugin searches for a logging.ini file:
    • First under tests/**/logging.ini
    • Then under src/**/logging.ini
    • Falls back to qa-testing-utils/src/qa_testing_utils/logging.ini
  3. This enables consistent, per-module logging without requiring repeated boilerplate.

  4. Source code inclusion in test reports:

  5. Adds a body section to each test report with the source code of the test function (via inspect.getsource()), useful for HTML/Allure/custom reporting.

  6. Command-line config overrides (parsed but not yet consumed):

  7. Adds a --config option that accepts section:key=value,... strings.
  8. Intended for runtime configuration injection (e.g., overriding .ini files or test settings).

Usage

  1. Declare the plugin in your module's pytest_plugins (if not auto-loaded via PDM entry point): pytest_plugins = ["qa_testing_utils.pytest_plugin"]

  2. Place a logging.ini file under your module's tests/ or src/ directory. If none is found, the fallback from qa-testing-utils will be used.

  3. Run your tests, optionally with runtime overrides:

pytest --config my_section:key1=val1,key2=val2

Notes: This plugin is designed to be generic and reusable across any module consuming qa-testing-utils.

Compatible with VSCode gutter test launching and monorepo test execution.

get_config_overrides()

Returns parsed --config overrides passed to pytest. Safe to call from anywhere (e.g., BaseConfiguration).

Source code in qa-testing-utils/src/qa_testing_utils/pytest_plugin.py
113
114
115
116
117
118
def get_config_overrides() -> dict[str, dict[str, str]]:
    """
    Returns parsed `--config` overrides passed to pytest.
    Safe to call from anywhere (e.g., BaseConfiguration).
    """
    return _config_overrides

pytest_addoption(parser)

Adds the --config command-line option for runtime config overrides.

Source code in qa-testing-utils/src/qa_testing_utils/pytest_plugin.py
61
62
63
64
65
66
67
68
def pytest_addoption(parser: pytest.Parser) -> None:
    """Adds the `--config` command-line option for runtime config overrides."""
    parser.addoption(
        "--config",
        action="append",
        default=[],
        help="Override config values using section:key=value format, comma-separated"
    )

pytest_configure(config)

Configures the pytest session, loading logging.ini and parsing config overrides.

Source code in qa-testing-utils/src/qa_testing_utils/pytest_plugin.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
@pytest.hookimpl(tryfirst=True)
def pytest_configure(config: pytest.Config) -> None:
    """Configures the pytest session, loading logging.ini and parsing config overrides."""
    from qa_testing_utils import __file__ as utils_ini

    test_args = [Path(arg.split("::")[0])
                 for arg in config.args if Path(arg.split("::")[0]).is_file()]
    root = test_args[0].parent.parent if test_args else Path.cwd()

    # Collect all logging.ini files under tests/ and src/
    logging_inis = (
        list(root.glob("tests/**/logging.ini"))
        + list(root.glob("src/**/logging.ini")))

    if logging_inis:
        # Prefer the one under tests/ if it exists
        selected_ini = logging_inis[0]
        logging.config.fileConfig(selected_ini)
        print(f"loaded logging.ini from {selected_ini}")
    else:
        fallback_ini = Path(utils_ini).parent / "logging.ini"
        logging.config.fileConfig(fallback_ini)
        print(f"loaded fallback logging.ini from {fallback_ini}")

    config_arg = config.getoption("--config")
    if config_arg:
        _parse_config_overrides(config_arg)

pytest_runtest_makereport(item, call)

Generates a test report with the source code of the test function.

Source code in qa-testing-utils/src/qa_testing_utils/pytest_plugin.py
100
101
102
103
104
105
106
107
108
109
110
@pytest.hookimpl(tryfirst=True)
def pytest_runtest_makereport(
    item: pytest.Item, call: pytest.CallInfo[None]
) -> pytest.TestReport:
    """Generates a test report with the source code of the test function."""
    report = pytest.TestReport.from_item_and_call(item, call)

    if call.when == "call":
        report.sections.append(('body', _get_test_body(item)))

    return report