1. Usage

1.1. Supported environments

The pytest-easy-server package is supported in these environments:

  • Operating Systems: Linux, macOS / OS-X, native Windows, Linux subsystem in Windows, UNIX-like environments in Windows.

  • Python: 2.7, 3.4, and higher

1.2. Installation

The following command installs the pytest-easy-server package and its prerequisite packages into the active Python environment:

$ pip install pytest-easy-server

When Pytest runs, it will automatically find the plugin and will show its version, e.g.:

plugins: easy-server-0.5.0

1.3. Server file and vault file

The server file define the servers, server groups and a default server or group. It is described in the “easy-server” documentation in section Server files.

The vault file defines the secrets needed to access the servers and can stay encrypted in the file system while being used. It is described in the “easy-server” documentation in section Vault files.

The servers and groups are identified with user-defined nicknames.

1.4. Using the es_server fixture

If your pytest test function uses the es_server() fixture, the test function will be invoked for the server specified in the --es-nickname command line option, or the set of servers if the specified nickname is that of a server group.

From a perspective of the test function that is invoked, the fixture resolves to a single server item.

The following example shows a test function using this fixture and how it gets to the details for accessing the server:

from pytest_easy_server import es_server

def test_sample(es_server):
    """
    Example Pytest test function that tests something.

    Parameters:
      es_server (easy_server.Server): Pytest fixture; the server to be
        used for the test
    """

    # Standard properties from the server file:
    nickname = es_server.nickname
    description = es_server.description

    # User-defined additional properties from the server file:
    stuff = es_server.user_defined['stuff']

    # User-defined secrets from the vault file:
    host = es_server.secrets['host']
    username = es_server.secrets['username']
    password = es_server.secrets['password']

    # Session to server using a fictitious session class
    session = MySession(host, username, password)

    # Test something
    result = my_session.perform_function()
    assert ...

    # Cleanup
    session.close()

The example shows how to access the standard and user-defined properties from the “easy-server” file for demonstration purposes. The data structure of the user-defined properties in the server file and of the secrets in the vault file is completely up to you, so you could decide to have the host and userid in user-defined properties in the server file, and have only the password in the vault file.

The es_server parameter of the test function is a easy_server.Server object that represents a server item from the server file for testing against a single server. It includes the corresponding secrets item from the vault file.

1.5. Example pytest runs

The user-defined properties and vault secrets used in the test function shown in the previous section are from the examples located in the examples directory of the GitHub repo of the project. The pytest runs are performed from within that directory.

Example file es_server.yml:

vault_file: vault.yml

servers:

  myserver1:                            # Nickname of the server
    description: "my dev system 1"
    contact_name: "John Doe"
    access_via: "VPN to dev network"
    user_defined:                       # User-defined additional properties
      stuff: "more stuff"

  myserver2:
    description: "my dev system 2"
    contact_name: "John Doe"
    access_via: "intranet"
    user_defined:
      stuff: "more stuff"

server_groups:

  mygroup1:
    description: "my dev systems"
    members:
      - myserver1
      - myserver2
    user_defined:
      stuff: "more stuff"

default: mygroup1

Example file es_vault.yml:

secrets:

  myserver1:
    host: "10.11.12.13"                 # User-defined properties
    username: myuser1
    password: mypass1

  myserver2:
    host: "9.10.11.12"                  # User-defined properties
    username: myuser2
    password: mypass2

The directory also contains a test module test_function.py with a test function that uses the es_server() fixture and prints the attributes and secrets for the server it is invoked for.

The following pytest runs use the default server file (es_server.yml in the current directory) and the vault password had been prompted for already and is now found in the keyring service.

In pytest verbose mode, the pytest-easy-server plugin prints messages about which server file is used and where the vault password comes from.

In this pytest run, the default nickname is used (the default defined in the server file, which is group mygroup1 that contains servers myserver1 and myserver2):

$ pytest -s -v .
==================================================== test session starts ====================================================
platform darwin -- Python 3.8.7, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 -- /Users/maiera/virtualenvs/pytest-es38/bin/python
cachedir: .pytest_cache
rootdir: /Users/maiera/PycharmProjects/pytest-easy-server
plugins: easy-server-0.5.0.dev1
collecting ...
pytest-easy-server: Using server file /Users/maiera/PycharmProjects/pytest-easy-server/examples/es_server.yml
pytest-easy-server: Using vault password from prompt or keyring service.
collected 2 items

test_function.py::test_sample[es_server=myserver1]
MySession: host=10.11.12.13, username=myuser1, password=mypass1
PASSED
test_function.py::test_sample[es_server=myserver2]
MySession: host=9.10.11.12, username=myuser2, password=mypass2
PASSED

===================================================== 2 passed in 0.02s =====================================================

In this pytest run, the nickname is specified in the pytest command line using the --es-nickname option, to run only on server myserver1:

$ pytest -s -v . --es-nickname myserver1
==================================================== test session starts ====================================================
platform darwin -- Python 3.8.7, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 -- /Users/maiera/virtualenvs/pytest-es38/bin/python
cachedir: .pytest_cache
rootdir: /Users/maiera/PycharmProjects/pytest-easy-server
plugins: easy-server-0.5.0.dev1
collecting ...
pytest-easy-server: Using server file /Users/maiera/PycharmProjects/pytest-easy-server/examples/es_server.yml
pytest-easy-server: Using vault password from prompt or keyring service.
collected 1 item

test_function.py::test_sample[es_server=myserver1]
MySession: host=10.11.12.13, username=myuser1, password=mypass1
PASSED

===================================================== 1 passed in 0.02s =====================================================

1.6. Controlling which servers to test against

When pytest loads the pytest-easy-server plugin, its set of command line options gets extended by those contributed by the plugin. These options allow controlling which server file is used and wich server or server group is used to test against. These options are optional and have sensible defaults:

--es-file=FILE
                        Path name of the easy-server file to be used.
                        Default: es_server.yml in current directory.
--es-nickname=NICKNAME
                        Nickname of the server or server group to test against.
                        Default: The default from the server file.

1.7. Requiring that the vault file is encrypted

By default, the vault file may be encrypted or unencrypted. If the vault file is checked into a repository, it is useful to ensure that it is encrypted, to avoid unintentional checkin of an unencrypted vault file. The can be ensured by specifying the following pytest option:

--es-encrypted          Require that the vault file (if specified) is encrypted and error out otherwise.
                        Default: Tolerate unencrypted vault file.

1.8. Validating user-defined extensions in server and vault files

The pytest-easy-server plugin supports the following properties in the server and vault files that have a user-defined structure:

  • Property ‘servers.{nickname}.user_defined’ in server file

  • Property ‘secrets.{nickname}’ in vault file

The ‘server_groups.{nickname}.user_defined’ property is ignored, because it is not accessible in the es_server() fixture.

The pytest-easy-server plugin supports optional validation of the user-defined structure of these properties by specifying a schema file that defines the JSON schemas for validating these user-defined structures:

--es-schema-file=FILE
                        Path name of the schema file to be used for validating the structure of
                        user-defined properties in the easy-server server and vault files.
                        Default: No validation.

The schema file is in YAML format and specifies the JSON schema for the user-defined structure of each of the properties. The JSON schemas specified in the YAML file are simply the YAML representations of the JSON objects specifying the JSON schemas.

Example schema file es_schema.yml that validates the user-defined properties shown in the example server and vault files in the previous sections:

user_defined_schema:
  # JSON schema for 'servers.{nickname}.user_defined' property in server file:
  $schema: http://json-schema.org/draft-07/schema#
  type: object
  additionalProperties: false
  required: []
  properties:
    stuff:
      type: [string, "null"]
      description: |
        Some stuff for servers, or null for not specifying any stuff.
        Optional, default: null.

vault_server_schema:
  # JSON schema for 'secrets.{nickname}' property in vault file:
  $schema: http://json-schema.org/draft-07/schema#
  type: object
  additionalProperties: false
  required: [host]
  properties:
    host:
      type: string
      description: |
        Hostname or IP address of the server.
        Mandatory.
    username:
      type: [string, "null"]
      description: |
        User for logging on to the server, or null for not specifying a user.
        Optional, default: null.
    password:
      type: [string, "null"]
      description: |
        Password of that user, or null for not specifying a password.
        Optional, default: null.

The schema validation is performed using the jsonschema Python package. At this point, that package supports JSON schema versions up to draft-07. The JSON schema version to be used for validation is specified in the $schema property of the JSON schema (see the example above).

For details about JSON schema, see `https://json-schema.org/`_. If you want to look up specific JSON schema features, see `https://json-schema.org/understanding-json-schema/reference/index.html`_ or specifically for draft-07, see `https://json-schema.org/draft-07/json-schema-validation.html`_.

1.9. Running pytest as a developer

When running pytest with the pytest-easy-server plugin on your local system, you are prompted (in the command line) for the vault password upon first use of a particular vault file. The password is then stored in the keyring service of your local system to avoid future such prompts.

The section Running pytest in a CI/CD system describes the use of an environment variable to store the password which avoids the password prompt. For security reasons, you should not use this approach when you run pytest locally. The one password prompt can be afforded, and subsequent retrieval of the vault password from the keyring service avoids further prompts.

1.10. Running pytest in a CI/CD system

When running pytest with the pytest-easy-server plugin in a CI/CD system, you must set an environment variable named “ES_VAULT_PASSWORD” to the vault password.

This can be done in a secure way by defining a corresponding secret in the CI/CD system in your test run configuration (e.g. GitHub Actions workflow), and by setting that environment variable to the CI/CD system secret.

Here is a snippet from a GitHub Actions workflow that does that, using a secret that is also named “ES_VAULT_PASSWORD”:

- name: Run test
  env:
    ES_VAULT_PASSWORD: ${{ secrets.ES_VAULT_PASSWORD }}
  run: |
    pytest -s -v test_dir

The pytest-easy-server plugin picks up the vault password from the “ES_VAULT_PASSWORD” environment variable if set and the presence of that variable causes it not to prompt for the password and also not to store it in the keyring service (which would be useless since it is on the system that is used for the test run in the CI/CD system, and that is typically a new one for every run).

1.11. Security aspects

There are two kinds of secrets involved:

  • The secrets in the vault file.

  • The vault password.

The secrets in the vault file are protected if the vault file is encrypted in the file system. The functionality also works if the vault file is not encrypted, but the normal case should be that you keep it encrypted. If you store the vault file in a repository, make sure it is encrypted.

The vault password is protected in the following ways:

  • When running pytest with the pytest-easy-server plugin on your local system, there is no permanent storage of the vault password anywhere except in the keyring service of your local system. There are no commands that take the vault password in their command line. The way the password gets specified is only in a password prompt, and then it is immediately stored in the keyring service and in future pytest runs retrieved from there.

    Your Python test programs of course can get at the secrets from the vault file (that is the whole idea after all). They can also get at the vault password by using the keyring service access functions but they have no need to deal with the vault password. In any case, make sure that the test functions do not print or log the vault secrets (or the vault password).

  • When running pytest in a CI/CD system, the “ES_VAULT_PASSWORD” environment variable that needs to be set can be set from a secret stored in the CI/CD system. State of the art CI/CD systems go a long way of ensuring that these secrets cannot simply be echoed or otherwise revealed.

The keyring service provides access to the secrets stored there, for the user that is authorized to access it. The details on how this is done depend on the particular keyring service used and its configuration. For example, on macOS the keyring service periodically requires re-authentication.

1.12. Derived Pytest fixtures

If using the es_server() fixture in your test functions repeats boiler plate code for opening a session with the server, this can be put into a derived fixture.

The following fixture is an example for that. It opens and closes a session with a server using a fictitious class MySession:

In a file session_fixture.py:

import pytest
from pytest_easy_server import es_server

@pytest.fixture(scope='module')
def my_session(request, es_server):
    """
    Pytest fixture representing the set of MySession objects to use for
    testing against a server.
    """
    # Session to server using a fictitious session class
    session = MySession(
        host=es_server.secrets['host']
        username=es_server.secrets['username']
        password=es_server.secrets['password']
    )

    yield session

    # Cleanup
    session.close()

In your test functions, you can now use that fixture:

from pytest_easy_server import es_server  # Must still be imported
from session_fixture import my_session

def test_sample(my_session):
    """
    Example Pytest test function that tests something.

    Parameters:
      my_session (MySession): Pytest fixture; the session to the server
        to be used for the test
    """
    result = my_session.perform_function()  # Test something

A side note: Pylint and Flake8 do not recognize that ‘es_server’ and ‘my_session’ are fixtures that are interpreted by Pytest and thus complain about the unused ‘es_server’ and ‘my_session’ names, and about the ‘my_session’ parameter that redefines the global name. The following markup silences these tools:

# pylint: disable=unused-import
from pytest_easy_server import es_server  # noqa: F401
from session_fixture import my_session  # noqa: F401

def test_sample(my_session):  # pylint: disable=redefined-outer-name