How to write unit tests for a charm¶
Setting up your environment¶
First of all, install the Ops testing framework. To do this in a virtual environment while we’re developing, use pip
or a different package manager. For example:
pip install ops[testing]
When we want to run repeatable unit tests, we’ll normally pin ops[testing]
to the latest minor version in the dependency group for our unit tests. For example, in a test-requirements.txt
file:
ops[testing] ~= 2.19
Or in pyproject.toml
:
[dependency-groups]
test = [
"ops[testing] ~= 2.19",
]
Creating the charm and test files¶
So that we have a charm to test, declare a placeholder charm type in charm.py
:
class MyCharm(ops.CharmBase):
pass
Then open a new test_foo.py
file for the test code and import the ops.testing
framework:
import ops
from ops import testing
Writing a test¶
To write a test function, use a Context
object to encapsulate the charm type (MyCharm
) and any necessary metadata. The test should then define the initial State
and call Context.run
with an event
and initial State
.
This follows the typical test structure:
Arrange inputs, mock necessary functions/system calls, and initialise the charm
Act by calling
Context.run
Assert expected outputs or function calls.
For example, suppose that MyCharm
uses Container.Push
to write a YAML config file on the pebble-ready event:
def _on_pebble_ready(self, event: ops.PebbleReadyEvent):
container = event.workload
container.push('/etc/config.yaml', 'message: Hello, world!', make_dirs=True)
# ...
A test for this behaviour might look like:
import yaml
from ops import testing
from charm import MyCharm
def test_pebble_ready_writes_config_file():
"""Test that on pebble-ready, a config file is written."""
# Arrange: setting up the inputs
ctx = testing.Context(MyCharm)
container = testing.Container(name="some-container", can_connect=True)
state_in = testing.State(
containers=[container],
leader=True,
)
# Act:
ctx.run(ctx.on.pebble_ready(container=container), state_in)
# Assert:
container_fs = state_out.get_container("some-container").get_filesystem(ctx)
cfg_file = container_fs / "etc" / "config.yaml"
config = yaml.safe_load(cfg_file.read_text())
assert config["message"] == "Hello, world!"
Note
If you prefer to use unittest, you should rewrite this as a method of a TestCase
subclass.
Mocking beyond the State¶
If you wish to use the framework to test an existing charm type, you will probably need to mock out certain calls that are not covered by the State
data structure. In that case, you will have to manually mock, patch or otherwise simulate those calls.
For example, suppose that the charm we’re testing uses the lightkube client to talk to Kubernetes. To mock that object, modify the test file to contain:
from unittest.mock import MagicMock, patch
import pytest
from ops import testing
from charm import MyCharm
@pytest.fixture
def my_charm():
with patch("charm.lightkube.Client"):
yield MyCharm
Then you should rewrite the test to pass the patched charm type to the Context
, instead of the unpatched one. In code:
def test_charm_runs(my_charm):
# Arrange:
# Create a Context to specify what code we will be running
ctx = testing.Context(my_charm)
# ...
Note
If you use pytest, you should put the my_charm
fixture in a top level conftest.py
, as it will likely be shared between all your unit tests.