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)))))

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()