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.

See more:

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.