Relation (Scenario)

Scenario > State > Relation

Scenario provides a Relation dataclass that wraps all mock relation data one might possibly need when testing charm relation code.

Relation data is nested in State under State.relations.

Therefore, in order to set up a scenario test with relations, one does:

from scenario import State, Relation
state = State(relations=[
  Relation("endpoint_1"), 
  Relation("endpoint_2"),
  ...
])

And in order to verify the state of a relation after an event has been fired, one does:

from scenario import Context
state_out = Context(...).run(...)

for relation in state_out.relations:
    if relation.endpoint == "endpoint_1":
        assert relation.local_unit_data == {...}

# or you can grab relations by endpoint name:
relations_1 = state_out.get_relations("endpoint_1")
... 

The only mandatory argument to Relation (and other relation types, see below) is endpoint. The interface will be derived from the charm’s metadata. When fully defaulted, a relation is ‘empty’. There are no remote units, the remote application is called 'remote' and only has a single unit remote/0, and the databags are still empty.

That is typically the state of a relation when the first unit (local or remote) joins it.

Non-regular relations

When you use Relation, you are specifying a regular (conventional) relation. But that is not the only type of relation. There are also peer relations and subordinate relations. While in the background the data model is the same, the data access rules and the consistency constraints on them are very different. For example, it does not make sense for a peer relation to have a different ‘remote app’ than its ‘local app’, because it’s the same application.

PeerRelation

To declare a peer relation, you should use scenario.state.PeerRelation. The core difference with regular relations is that peer relations do not have a “remote app” (it’s this app, in fact). So unlike Relation, a PeerRelation does not have remote_app_name or remote_app_data arguments. Also, it talks in terms of peers:

  • Relation.remote_unit_ids maps to PeerRelation.peers_ids
  • Relation.remote_units_data maps to PeerRelation.peers_data

Example usage

from scenario import PeerRelation

relation = PeerRelation(
    endpoint="peers",
    peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}},
)

be mindful when using PeerRelation not to include “this unit”'s ID in peers_data or peers_ids, as that would be flagged by the Consistency Checker:

from scenario import State, PeerRelation, Context

state_in = State(relations=[
    PeerRelation(
        endpoint="peers",
        peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}},
    )],
    unit_id=1)

Context(...).run("start", state_in)  
# invalid: this unit's id cannot be the ID of a peer.

SubordinateRelation

To declare a subordinate relation, you should use scenario.state.SubordinateRelation. The core difference with regular relations is that subordinate relations always have exactly one remote unit (there is always exactly one primary unit that this unit can see). So unlike Relation, a SubordinateRelation does not have a remote_units_data argument.

Instead, it has a remote_unit_data taking a single Dict[str:str], and takes the remote unit ID as a separate argument, since there is a single remote unit (the primary or the subordinate unit).

  • Relation.remote_unit_ids becomes SubordinateRelation.remote_unit_id (a single ID instead of a list of IDs)
  • Relation.remote_units_data becomes SubordinateRelation.remote_unit_data (a single databag instead of a mapping from unit IDs to databags)
  • Relation.remote_app_name remains unchanged

Example usage

from scenario.state import SubordinateRelation

relation = SubordinateRelation(
  endpoint="sub",
  remote_unit_data={"foo": "bar"},
  remote_app_name="zookeeper",
  remote_unit_id=42
)

assert relation.remote_unit_name == "zookeeper/42"