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