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)
]