Using Salt Factories#

Salt factories simplifies testing Salt related code outside of Salt’s source tree. A great example is a salt-extension.

Let’s consider this echo-extension example.

The echo-extension provides an execution module:

examples/echo-extension/src/echoext/modules/echo_mod.py#
__virtualname__ = "echo"


def __virtual__():
    return __virtualname__


def text(string):
    """
    This function just returns any text that it's given.

    CLI Example:

    .. code-block:: bash

        salt '*' echo.text 'foo bar baz quo qux'
    """
    return __salt__["test.echo"](string)


def reverse(string):
    """
    This function just returns any text that it's given, reversed.

    CLI Example:

    .. code-block:: bash

        salt '*' echo.reverse 'foo bar baz quo qux'
    """
    return __salt__["test.echo"](string)[::-1]

And also a state module:

examples/echo-extension/src/echoext/states/echo_mod.py#
__virtualname__ = "echo"


def __virtual__():
    if "echo.text" not in __salt__:
        return False, "The 'echo' execution module is not available"
    return __virtualname__


def echoed(name):
    ret = {"name": name, "changes": {}, "result": False, "comment": ""}
    value = __salt__["echo.text"](name)
    if value == name:
        ret["result"] = True
        ret["comment"] = f"The 'echo.echoed' returned: '{value}'"
    return ret


def reversed(name):
    """
    This example function should be replaced
    """
    ret = {"name": name, "changes": {}, "result": False, "comment": ""}
    value = __salt__["echo.reverse"](name)
    if value == name[::-1]:
        ret["result"] = True
        ret["comment"] = f"The 'echo.reversed' returned: '{value}'"
    return ret

One could start off with something simple like unit testing the extension’s code.

Unit Tests#

examples/echo-extension/tests/unit/modules/test_echo.py#
import pytest
import salt.modules.test as testmod

import echoext.modules.echo_mod as echo_module


@pytest.fixture
def configure_loader_modules():
    module_globals = {
        "__salt__": {"test.echo": testmod.echo},
    }
    return {
        echo_module: module_globals,
    }


def test_text():
    echo_str = "Echoed!"
    assert echo_module.text(echo_str) == echo_str


def test_reverse():
    echo_str = "Echoed!"
    expected = echo_str[::-1]
    assert echo_module.reverse(echo_str) == expected
examples/echo-extension/tests/unit/states/test_echo.py#
import pytest
import salt.modules.test as testmod

import echoext.modules.echo_mod as echo_module
import echoext.states.echo_mod as echo_state


@pytest.fixture
def configure_loader_modules():
    return {
        echo_module: {
            "__salt__": {
                "test.echo": testmod.echo,
            },
        },
        echo_state: {
            "__salt__": {
                "echo.text": echo_module.text,
                "echo.reverse": echo_module.reverse,
            },
        },
    }


def test_echoed():
    echo_str = "Echoed!"
    expected = {
        "name": echo_str,
        "changes": {},
        "result": True,
        "comment": f"The 'echo.echoed' returned: '{echo_str}'",
    }
    assert echo_state.echoed(echo_str) == expected


def test_reversed():
    echo_str = "Echoed!"
    expected_str = echo_str[::-1]
    expected = {
        "name": echo_str,
        "changes": {},
        "result": True,
        "comment": f"The 'echo.reversed' returned: '{expected_str}'",
    }
    assert echo_state.reversed(echo_str) == expected

The magical piece of code in the above example is the configure_loader_modules fixture.

Integration Tests#

examples/echo-extension/tests/conftest.py#
import os

import pytest

from echoext import PACKAGE_ROOT
from saltfactories.utils import random_string


@pytest.fixture(scope="session")
def salt_factories_config():
    """
    Return a dictionary with the keyword arguments for FactoriesManager
    """
    coverage_rc_path = os.environ.get("COVERAGE_PROCESS_START")
    if coverage_rc_path:
        coverage_db_path = PACKAGE_ROOT / ".coverage"
    else:
        coverage_db_path = None
    return {
        "code_dir": str(PACKAGE_ROOT),
        "coverage_rc_path": coverage_rc_path,
        "coverage_db_path": coverage_db_path,
        "inject_sitecustomize": "COVERAGE_PROCESS_START" in os.environ,
        "start_timeout": 120 if os.environ.get("CI") else 60,
    }


@pytest.fixture(scope="package")
def master(salt_factories):
    return salt_factories.salt_master_daemon(random_string("master-"))


@pytest.fixture(scope="package")
def minion(master):
    return master.salt_minion_daemon(random_string("minion-"))
examples/echo-extension/tests/integration/conftest.py#
import pytest


@pytest.fixture(scope="package")
def master(master):
    with master.started():
        yield master


@pytest.fixture(scope="package")
def minion(minion):
    with minion.started():
        yield minion


@pytest.fixture
def salt_run_cli(master):
    return master.salt_run_cli()


@pytest.fixture
def salt_cli(master):
    return master.salt_cli()


@pytest.fixture
def salt_call_cli(minion):
    return minion.salt_call_cli()
examples/echo-extension/tests/integration/modules/test_echo.py#
import pytest

pytestmark = [
    pytest.mark.requires_salt_modules("echo.text"),
]


def test_text(salt_call_cli):
    echo_str = "Echoed!"
    ret = salt_call_cli.run("echo.text", echo_str)
    assert ret.returncode == 0
    assert ret.data
    assert ret.data == echo_str


def test_reverse(salt_call_cli):
    echo_str = "Echoed!"
    expected = echo_str[::-1]
    ret = salt_call_cli.run("echo.reverse", echo_str)
    assert ret.returncode == 0
    assert ret.data
    assert ret.data == expected
examples/echo-extension/tests/integration/states/test_echo.py#
import pytest

pytestmark = [
    pytest.mark.requires_salt_states("echo.text"),
]


def test_echoed(salt_call_cli):
    echo_str = "Echoed!"
    ret = salt_call_cli.run("state.single", "echo.echoed", echo_str)
    assert ret.returncode == 0
    assert ret.data
    assert ret.data == echo_str


def test_reversed(salt_call_cli):
    echo_str = "Echoed!"
    expected = echo_str[::-1]
    ret = salt_call_cli.run("state.single", "echo.reversed", echo_str)
    assert ret.returncode == 0
    assert ret.data
    assert ret.data == expected

What happened above?

  1. We started a salt master

  2. We started a salt minion

  3. The minion connects to the master

  4. The master accepted the minion’s key automatically

  5. We pinged the minion

A litle suggestion

Not all tests should be integration tests, in fact, only a small set of the test suite should be an integration test.