Getting Started
Pytest-mh is not a plugin to support unit testing. It is a plugin designed to support testing your application as a complete product, this is often referred to as a black-box, application or system testing. The application is installed on the target host and tested by running commands on the host (and on other hosts that are required). These hosts can be virtual machines or containers.
See also
SSH, podman and docker may be used to execute commands on the remote hosts. See Running Commands on Remote Hosts for more information.
As such, it is often useful to write a high level API that will support testing your application and make your test smaller and more readable. This may require a non-trivial initial investment, but it will pay off in the long run. However, implementing such API is not required and it is perfectly possible to run commands on the host directly from each test. This approach makes tests usually larger and more difficult to understand and maintain – but every project is different and it is up to you to choose how are you going to test your application.
See also
Pytest-mh provides several building blocks to help you design your test framework. See Extending pytest-mh for Your Needs. It would be good to open this document and read it side by side.
We will use sudo as the application to test for this getting started guide as sudo is known by every power user so chances are that you are already familiar with it. Additionally, sudo tests allows us to show many pytest-mh features. Note, that these tests were written only as an example and sudo itself is not using pytest-mh for its tests at this moment and as far as we know there are no plans to do so.
See also
The example code can be found in the example folder of the git repository.
Example project: sudo
Sudo is a widely known tool that can elevate current
user’s privileges by running command as different user – usually root. It
is possible to write a set of rules to define which user can run which command
and these rules can be stored either locally in /etc/sudoers or in LDAP
database.
Our goals are:
write basic tests
allow the user to run all commands, user must authenticate
allow the user to run all commands, without authentication
allow the user to run all commands if they are a member of a group, user must authenticate
allow the user to run all commands if they are a member of a group, without authentication
these tests must be written for all possible sources of data
file:
/etc/sudoersLDAP pulled directly by sudo
LDAP pulled by SSSD
write a simple test framework that will help us to extend the tests easily
every change must be reverted after each test
As you can see, these goals require us to write 12 tests in total. But since the result is the same and only the data is fetched from different sources, we can use topology parametrization. Topology parametrization allows us to write only one test but run it against different backends and thus we will do less work but get more code coverage.
We will take the following steps to achieve it:
Prepare a file structure
The following snippet shows a recommended file structure for your test utilizing pytest-mh. Look at Extending pytest-mh for Your Needs to get more information about the meaning of individual classes.
.
├── framework/ # Test framework, high-level API
│ ├── hosts/ # Subclasses of MultihostHost
│ │ └── __init__.py
│ ├── roles/ # Subclasses of MultihostRole
│ │ └── __init__.py
│ ├── utils/ # Subclasses of MultihostUtility
│ │ └── __init__.py
│ ├── __init__.py
│ ├── config.py # Definition of MultihostConfig, MultihostDomain
│ ├── topology_controllers.py # Custom topology controllers
│ └── topology.py # Definition of multihost topologies
|
├── tests/ # Tests
|
├── conftest.py # Pytest conftest.py
├── pytest.ini # Pytest configuration file
├── py.typed # Declare that this project uses type hints
|
├── mhc.yaml # Pytest-mh configuration file
|
├── readme.md # Tests readme
└── requirements.txt # Tests requirements
Define multihost topologies
This is the first step when designing a test framework since it defines what hosts and roles your project needs. For sudo, we want sudo rules to be fetched from different sources. We can consider each data source to be a single topology.
sudoers
only one host needed
users, groups and sudo rules will be created locally
ldap
we need a host where we will run sudo and a host that runs an LDAP server
users, groups and sudo rules will be added to the LDAP database
sudo will read data from LDAP
sssd
we need a host where we will run sudo and SSSD and a host that runs an LDAP server
SSSD will be connected to the LDAP domain
users, groups and sudo rules will be added to the LDAP database
sudo will read data from SSSD which in turn reads it from LDAP
These are the three topologies that we will define. We will also define a topology group as a shortcut for topology parametrization.
See the code
"""Predefined well-known topologies."""
from __future__ import annotations
from enum import unique
from typing import final
from pytest_mh import KnownTopologyBase, KnownTopologyGroupBase, Topology, TopologyDomain, TopologyMark
from .topology_controllers import LDAPTopologyController, SSSDTopologyController, SudoersTopologyController
__all__ = [
"KnownTopology",
"KnownTopologyGroup",
]
@final
@unique
class KnownTopology(KnownTopologyBase):
"""
Well-known topologies that can be given to ``pytest.mark.topology``
directly. It is expected to use these values in favor of providing
custom marker values.
.. code-block:: python
:caption: Example usage
@pytest.mark.topology(KnownTopology.LDAP)
def test_ldap(client: Client, ldap: LDAP):
assert True
"""
Sudoers = TopologyMark(
name="sudoers",
topology=Topology(TopologyDomain("sudo", client=1)),
controller=SudoersTopologyController(),
fixtures=dict(client="sudo.client[0]", provider="sudo.client[0]"),
)
LDAP = TopologyMark(
name="ldap",
topology=Topology(TopologyDomain("sudo", client=1, ldap=1)),
controller=LDAPTopologyController(),
fixtures=dict(client="sudo.client[0]", ldap="sudo.ldap[0]", provider="sudo.ldap[0]"),
)
SSSD = TopologyMark(
name="sssd",
topology=Topology(TopologyDomain("sudo", client=1, ldap=1)),
controller=SSSDTopologyController(),
fixtures=dict(client="sudo.client[0]", ldap="sudo.ldap[0]", provider="sudo.ldap[0]"),
)
class KnownTopologyGroup(KnownTopologyGroupBase):
"""
Groups of well-known topologies that can be given to ``pytest.mark.topology``
directly. It is expected to use these values in favor of providing
custom marker values.
The test is parametrized and runs multiple times, once per each topology.
.. code-block:: python
:caption: Example usage (runs on AD, IPA, LDAP and Samba topology)
@pytest.mark.topology(KnownTopologyGroup.AnyProvider)
def test_ldap(client: Client, provider: GenericProvider):
assert True
"""
AnyProvider = [KnownTopology.Sudoers, KnownTopology.LDAP, KnownTopology.SSSD]
Write configuration file
The topology defines which hosts and roles are needed to run sudo tests. We can convert it into a configuration file that can be used to run all sudo tests.
The configuration file will define one domain with two hosts - one client
which will run sudo and SSSD and one ldap which will run the LDAP server.
See also
The full format of the configuration file can be found at Configuration File.
See the code
domains:
- id: sudo
hosts:
- hostname: master.ldap.test
conn:
type: ssh
host: 172.16.200.3
role: ldap
- hostname: client.test
conn:
type: ssh
host: 172.16.200.4
role: client
artifacts:
- /var/log/sssd
Define MultihostConfig and MultihostDomain
These two classes are required to correctly map the configuration file into your Python code. Look for more information at Multihost Configuration and Multihost Domains. It is possible to extend these classes in order to add custom configuration options, use different topology mark and so on. In this example, they only provide the mapping from configuration file to Python classes.
See the code
from __future__ import annotations
from typing import Type
from pytest_mh import MultihostConfig, MultihostDomain, MultihostHost, MultihostRole
__all__ = [
"SUDOMultihostConfig",
"SUDOMultihostDomain",
]
class SUDOMultihostConfig(MultihostConfig):
@property
def id_to_domain_class(self) -> dict[str, Type[MultihostDomain]]:
"""
All domains are mapped to :class:`SUDOMultihostDomain`.
:rtype: Class name.
"""
return {"*": SUDOMultihostDomain}
class SUDOMultihostDomain(MultihostDomain[SUDOMultihostConfig]):
@property
def role_to_host_class(self) -> dict[str, Type[MultihostHost]]:
"""
Map roles to classes:
* client to ClientHost
* ldap to LDAPHost
:rtype: Class name.
"""
from .hosts.client import ClientHost
from .hosts.ldap import LDAPHost
return {
"client": ClientHost,
"ldap": LDAPHost,
}
@property
def role_to_role_class(self) -> dict[str, Type[MultihostRole]]:
"""
Map roles to classes:
* client to Client
* ldap to LDAP
:rtype: Class name.
"""
from .roles.client import Client
from .roles.ldap import LDAP
return {
"client": Client,
"ldap": LDAP,
}
Design and implement the framework
This step is more complicated and can not be treated universally as every
project has different needs. It is possible to use multiple building blocks
provided by pytest-mh in order to build a high-level API for your tests, see
Extending pytest-mh for Your Needs and Life Cycle and Hooks to get a good grasp of all the classes
and how to use them.
For the sudo tests, we have implemented several hosts, roles and utility classes and one topology controller for each topology. The following table describes the main idea behind each of these classes.
See the table
Class name/Subclass of
Description
ClientHost
Implements backup and restore methods for the client.
LDAPHost
Implements backup and restore methods for the LDAP server.
Opens and maintains connection to the LDAP server using python-ldap library.
SudoersTopologyController
Configures environment for the sudoers topology
Sets expected content of
/etc/nsswitch.confCreates backup of this setup and automatically restores its state when a test is finished
LDAPTopologyController
Configures environment for the LDAP topology
Sets expected content of
/etc/nsswitch.confConfigures SSSD for identity and authentication
Configures
/etc/ldap.confthat is read by sudoCreates backup of this setup and automatically restores its state when a test is finished
SSSDTopologyController
Configures environment for the SSSD topology
Sets expected content of
/etc/nsswitch.confConfigures SSSD for identity, authentication and sudo rules
Creates backup of this setup and automatically restores its state when a test is finished
Client
Implements
GenericProviderwhich defines interface for managing users, groups and sudoers.The implementation uses local files to store the content.
LDAP
Implements
GenericProviderwhich defines interface for managing users, groups and sudoers.The implementation uses LDAP to store the content.
LocalUsersUtils
Provides shareable implementation of local users and groups management.
Every user and group added during testing is automatically removed.
SUDOUtils
Implements methods to execute sudo and assert the result
See also
Look at the example code to see how this was implemented.
Enable pytest-mh in conftest.py
When the test framework is written and ready to use, we can tell pytest to start using it in our tests. First, configure pytest to load pytest-mh plugin and then inform pytest-mh which config class it should instantiate.
See the code
# Configuration file for multihost tests.
from __future__ import annotations
from framework.config import SUDOMultihostConfig
from pytest_mh import MultihostPlugin
# Load additional plugins
pytest_plugins = ("pytest_mh",)
# Setup pytest-mh
def pytest_plugin_registered(plugin) -> None:
if isinstance(plugin, MultihostPlugin):
plugin.config_class = SUDOMultihostConfig
Write the tests
The example code shows four tests in total, but 12 tests are executed when pytest is run because each test is run once per topology against different data sources. See Writing Tests to get more information on how to write the tests.
allow the user to run all commands, user must authenticate
allow the user to run all commands, without authentication
allow the user to run all commands if they are a member of a group, user must authenticate
allow the user to run all commands if they are a member of a group, without authentication
See the code
from __future__ import annotations
import pytest
from framework.roles.base import GenericProvider
from framework.roles.client import Client
from framework.roles.ldap import LDAP
from framework.topology import KnownTopologyGroup
@pytest.mark.topology(KnownTopologyGroup.AnyProvider)
def test_user__passwd(client: Client, provider: GenericProvider):
u = provider.user("tuser").add(password="Secret123")
provider.sudorule("test-rule").add(user=u, command="ALL")
# LDAP and SSSD topology uses SSSD for id and/or sudo rules
# Since sudo rules are fetch in periodic task, we must start SSSD after
# the rule is created in LDAP to avoid race conditions.
if isinstance(provider, LDAP):
client.svc.start("sssd.service")
assert client.sudo.list(u.name, "Secret123", expected=["(root) ALL"])
assert client.sudo.run(u.name, "Secret123", command="ls /root")
@pytest.mark.topology(KnownTopologyGroup.AnyProvider)
def test_user__nopasswd(client: Client, provider: GenericProvider):
u = provider.user("tuser").add(password="Secret123")
provider.sudorule("test-rule").add(user=u, command="ALL", nopasswd=True)
# LDAP and SSSD topology uses SSSD for id and/or sudo rules
# Since sudo rules are fetch in periodic task, we must start SSSD after
# the rule is created in LDAP to avoid race conditions.
if isinstance(provider, LDAP):
client.svc.start("sssd.service")
assert client.sudo.list(u.name, "Secret123", expected=["(root) NOPASSWD: ALL"])
assert client.sudo.run(u.name, command="ls /root")
from __future__ import annotations
import pytest
from framework.roles.base import GenericProvider
from framework.roles.client import Client
from framework.roles.ldap import LDAP
from framework.topology import KnownTopologyGroup
@pytest.mark.topology(KnownTopologyGroup.AnyProvider)
def test_group__passwd(client: Client, provider: GenericProvider):
u = provider.user("tuser").add(password="Secret123")
g = provider.group("tgroup").add().add_member(u)
provider.sudorule("test-rule").add(user=g, command="ALL")
# LDAP and SSSD topology uses SSSD for id and/or sudo rules
# Since sudo rules are fetch in periodic task, we must start SSSD after
# the rule is created in LDAP to avoid race conditions.
if isinstance(provider, LDAP):
client.svc.start("sssd.service")
assert client.sudo.list(u.name, "Secret123", expected=["(root) ALL"])
assert client.sudo.run(u.name, "Secret123", command="ls /root")
@pytest.mark.topology(KnownTopologyGroup.AnyProvider)
def test_group__nopasswd(client: Client, provider: GenericProvider):
u = provider.user("tuser").add(password="Secret123")
g = provider.group("tgroup").add().add_member(u)
provider.sudorule("test-rule").add(user=g, command="ALL", nopasswd=True)
# LDAP and SSSD topology uses SSSD for id and/or sudo rules
# Since sudo rules are fetch in periodic task, we must start SSSD after
# the rule is created in LDAP to avoid race conditions.
if isinstance(provider, LDAP):
client.svc.start("sssd.service")
assert client.sudo.list(u.name, "Secret123", expected=["(root) NOPASSWD: ALL"])
assert client.sudo.run(u.name, command="ls /root")
Run the tests
The example code provides a set of containers that can be started and used as hosts for testing. See the example readme.md to get the instruction on how to start the containers and install requirements.
When the containers or virtual machines are ready, it is possible to run the
tests with the pytest command that you are already familiar with. The only
additional thing needed to run pytest-mh tests is to provide the path to the
pytest-mh configuration file with --mh-config.
$ pytest --color=yes --mh-config=./mhc.yaml -vvv
Multihost configuration:
domains:
- id: sudo
hosts:
- hostname: master.ldap.test
conn:
type: ssh
host: 172.16.200.3
role: ldap
- hostname: client.test
conn:
type: ssh
host: 172.16.200.4
role: client
artifacts:
- /var/log/sssd
Detected topology:
- id: sudo
hosts:
ldap: 1
client: 1
Additional settings:
config file: ./example/mhc.yaml
log path: None
lazy ssh: False
topology filter:
require exact topology: False
collect artifacts: on-failure
artifacts directory: artifacts
collect logs: on-failure
============================= test session starts ==============================
platform linux -- Python 3.11.9, pytest-8.3.3, pluggy-1.5.0 -- /home/runner/work/pytest-mh/pytest-mh/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /home/runner/work/pytest-mh/pytest-mh/example
configfile: pytest.ini
collecting ...
Selected tests will use the following hosts:
client: client.test
ldap: master.ldap.test
collected 12 items
example/tests/test_group.py::test_group__passwd (ldap) PASSED [ 8%]
example/tests/test_group.py::test_group__nopasswd (ldap) PASSED [ 16%]
example/tests/test_user.py::test_user__passwd (ldap) PASSED [ 25%]
example/tests/test_user.py::test_user__nopasswd (ldap) PASSED [ 33%]
example/tests/test_group.py::test_group__passwd (sssd) PASSED [ 41%]
example/tests/test_group.py::test_group__nopasswd (sssd) PASSED [ 50%]
example/tests/test_user.py::test_user__passwd (sssd) PASSED [ 58%]
example/tests/test_user.py::test_user__nopasswd (sssd) PASSED [ 66%]
example/tests/test_group.py::test_group__passwd (sudoers) PASSED [ 75%]
example/tests/test_group.py::test_group__nopasswd (sudoers) PASSED [ 83%]
example/tests/test_user.py::test_user__passwd (sudoers) PASSED [ 91%]
example/tests/test_user.py::test_user__nopasswd (sudoers) PASSED [100%]
============================= 12 passed in 24.80s ==============================