How to test charm libraries with Scenario

In this guide we will go through how to write Scenario tests for a charm library we are developing:

<charm root>/lib/charms/my_charm/v0/my_lib.py

The intended behaviour of this library (requirer side) is to copy data from the provider app databags and collate it in the own application databag. The requirer side library does not interact with any lifecycle event; it only listens to relation events.

Setup

Assuming you have a library file already set up and ready to go (see charmcraft create-lib otherwise), you now need to

pip install ops-scenario and create a test file in <charm root>/tests/scenario/test_my_lib.py

Base test

#  `<charm root>/tests/scenario/test_my_lib.py`
import pytest
import ops
from scenario import Context, State
from lib.charms.my_Charm.v0.my_lib import MyObject

class MyTestCharm(ops.CharmBase):
    META = {
        "name": "my-charm"
    }
    def __init__(self, framework):
        super().__init__(framework)
        self.obj = MyObject(self)
        framework.observe(self.on.start, self._on_start)
        
    def _on_start(self, _):
        pass

    
@pytest.fixture
def context():
    return Context(MyTestCharm, meta=MyTestCharm.META)

@pytest.mark.parametrize('event', (
    'start', 'install', 'stop', 'remove', 'update-status', #...
))
def test_charm_runs(context, event):
    """Verify that MyObject can initialize and process any event except relation events."""
    # arrange
    state_in = State()
    # act
    context.run(event, state_in)

Simple use cases

Relation endpoint wrapper lib

If MyObject is a relation endpoint wrapper such as traefik's ingress-per-unit lib, a frequent pattern is to allow customizing the name of the endpoint that the object is wrapping. We can write a scenario test like so:

#  `<charm root>/tests/scenario/test_my_lib.py`
import pytest
import ops
from scenario import Context, State, Relation
from lib.charms.my_Charm.v0.my_lib import MyObject


@pytest.fixture(params=["foo", "bar"])
def endpoint(request):
    return request.param


@pytest.fixture
def my_charm_type(endpoint):
    class MyTestCharm(ops.CharmBase):
        META = {
            "name": "my-charm",
            "requires":
                {endpoint: {"interface": "my_interface"}}
        }

        def __init__(self, framework):
            super().__init__(framework)
            self.obj = MyObject(self, endpoint=endpoint)
            framework.observe(self.on.start, self._on_start)

        def _on_start(self, _):
            pass

    return MyTestCharm


@pytest.fixture
def context(my_charm_type):
    return Context(my_charm_type, meta=my_charm_type.META)


def test_charm_runs(context):
    """Verify that the charm executes regardless of how we name the requirer endpoint."""
    # arrange
    state_in = State()
    # act
    context.run('start', state_in)


@pytest.mark.parametrize('n_relations', (1, 2, 7))
def test_charm_runs_with_relations(context, endpoint, n_relations):
    """Verify that the charm executes when there are one or more relations on the endpoint."""
    # arrange
    state_in = State(relations=[
        Relation(endpoint=endpoint, interface='my-interface', remote_app_name=f"remote_{n}") for n in range(n_relations)
    ])
    # act
    state_out = context.run('start', state_in)
    # assert
    for relation in state_out.relations:
        assert not relation.local_app_data  # remote side didn't publish any data.


@pytest.mark.parametrize('n_relations', (1, 2, 7))
def test_relation_changed_behaviour(context, endpoint, n_relations):
    """Verify that the charm lib does what it should on relation changed."""
    # arrange
    relations = [Relation(
        endpoint=endpoint, interface='my-interface', remote_app_name=f"remote_{n}",
        remote_app_data={"foo": f"my-data-{n}"}
    ) for n in range(n_relations)]
    state_in = State(relations=relations)
    # act
    state_out: State = context.run(relations[0].changed_event, state_in)
    # assert
    for relation in state_out.relations:
        assert relation.local_app_data == {"collation": ';'.join(f"my-data-{n}" for n in range(n_relations))}

Advanced use cases

Testing internal (charm-facing) library APIs

Suppose that MyObject has a data method that exposes to the charm a list containing the remote databag contents (the my-data-N we have seen above). We can use scenario.Context.manager to run code within the lifetime of the Context like so:

import pytest
import ops
from scenario import Context, State, Relation
from lib.charms.my_Charm.v0.my_lib import MyObject

@pytest.mark.parametrize('n_relations', (1, 2, 7))
def test_my_object_data(context, endpoint, n_relations):
    """Verify that the charm lib does what it should on relation changed."""
    # arrange
    relations = [Relation(
        endpoint=endpoint, interface='my-interface', remote_app_name=f"remote_{n}",
        remote_app_data={"foo": f"my-data-{n}"}
    ) for n in range(n_relations)]
    state_in = State(relations=relations)
    
    with context.manager(relations[0].changed_event, state_in) as mgr:
        # act
        state_out = mgr.run()  # this will emit the event on the charm 
        # control is handed back to us before ops is torn down

        # assert
        charm = mgr.charm  # the MyTestCharm instance ops is working with
        obj: MyObject = charm.obj
        assert obj.data == [
            f"my-data-{n}" for n in range(n_relations)
        ]