Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: unify run() and run_action() and simplify context managers #162

Merged
merged 20 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,8 +726,8 @@ storage = scenario.Storage("foo")
# Setup storage with some content:
(storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld")

with ctx.manager(ctx.on.update_status(), scenario.State(storages={storage})) as mgr:
foo = mgr.charm.model.storages["foo"][0]
with ctx(ctx.on.update_status(), scenario.State(storages={storage})) as manager:
foo = manager.charm.model.storages["foo"][0]
loc = foo.location
path = loc / "myfile.txt"
assert path.exists()
Expand Down Expand Up @@ -899,9 +899,9 @@ import pathlib

ctx = scenario.Context(MyCharm, meta={'name': 'juliette', "resources": {"foo": {"type": "oci-image"}}})
resource = scenario.Resource(name='foo', path='/path/to/resource.tar')
with ctx.manager(ctx.on.start(), scenario.State(resources={resource})) as mgr:
with ctx(ctx.on.start(), scenario.State(resources={resource})) as manager:
# If the charm, at runtime, were to call self.model.resources.fetch("foo"), it would get '/path/to/resource.tar' back.
path = mgr.charm.model.resources.fetch('foo')
path = manager.charm.model.resources.fetch('foo')
assert path == pathlib.Path('/path/to/resource.tar')
```

Expand Down Expand Up @@ -963,7 +963,6 @@ class MyVMCharm(ops.CharmBase):
An action is a special sort of event, even though `ops` handles them almost identically.
In most cases, you'll want to inspect the 'results' of an action, or whether it has failed or
logged something while executing. Many actions don't have a direct effect on the output state.
For this reason, the output state is less prominent in the return type of `Context.run_action`.

How to test actions with scenario:

Expand All @@ -975,18 +974,31 @@ def test_backup_action():

# If you didn't declare do_backup in the charm's metadata,
# the `ConsistencyChecker` will slap you on the wrist and refuse to proceed.
out: scenario.ActionOutput = ctx.run_action(ctx.on.action("do_backup"), scenario.State())
state = ctx.run(ctx.on.action("do_backup"), scenario.State())

# You can assert action results, logs, failure using the ActionOutput interface:
assert out.logs == ['baz', 'qux']

if out.success:
# If the action did not fail, we can read the results:
assert out.results == {'foo': 'bar'}
# You can assert action results and logs using the action history:
assert ctx.action_history[0].logs == ['baz', 'qux']
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
assert ctx.action_history[0].results == {'foo': 'bar'}
```

## Failing Actions

If the charm code calls `event.fail()` to indicate that the action has failed,
an `ActionFailed` exception will be raised. This avoids having to include
`assert ctx.action_history[0].status == "completed"` code in every test where
the action is successful.

```python
def test_backup_action_failed():
ctx = scenario.Context(MyCharm)

with pytest.raises(ActionFailed) as exc_info:
ctx.run(ctx.on.action("do_backup"), scenario.State())
assert exc_info.value.message == "sorry, couldn't do the backup"

else:
# If the action fails, we can read a failure message:
assert out.failure == 'boo-hoo'
# You can still assert action results and logs that occured before the failure:
assert ctx.action_history[0].logs == ['baz', 'qux']
assert ctx.action_history[0].results == {'foo': 'bar'}
```

## Parametrized Actions
Expand All @@ -999,7 +1011,7 @@ def test_backup_action():

# If the parameters (or their type) don't match what is declared in the metadata,
# the `ConsistencyChecker` will slap you on the other wrist.
out: scenario.ActionOutput = ctx.run_action(
out: scenario.ActionOutput = ctx.run(
ctx.on.action("do_backup", params={'a': 'b'}),
scenario.State()
)
Expand Down Expand Up @@ -1105,7 +1117,7 @@ Scenario is a black-box, state-transition testing framework. It makes it trivial
B, but not to assert that, in the context of this charm execution, with this state, a certain charm-internal method was called and returned a
given piece of data, or would return this and that _if_ it had been called.

Scenario offers a cheekily-named context manager for this use case specifically:
The Scenario `Context` object can be used as a context manager for this use case specifically:

```python notest
from charms.bar.lib_name.v1.charm_lib import CharmLib
Expand All @@ -1127,8 +1139,7 @@ class MyCharm(ops.CharmBase):

def test_live_charm_introspection(mycharm):
ctx = scenario.Context(mycharm, meta=mycharm.META)
# If you want to do this with actions, you can use `Context.action_manager` instead.
with ctx.manager("start", scenario.State()) as manager:
with ctx(ctx.on.start(), scenario.State()) as manager:
# This is your charm instance, after ops has set it up:
charm: MyCharm = manager.charm

Expand All @@ -1149,8 +1160,8 @@ def test_live_charm_introspection(mycharm):
assert state_out.unit_status == ...
```

Note that you can't call `manager.run()` multiple times: the manager is a context that ensures that `ops.main` 'pauses' right
before emitting the event to hand you some introspection hooks, but for the rest this is a regular scenario test: you
Note that you can't call `manager.run()` multiple times: the object is a context that ensures that `ops.main` 'pauses' right
before emitting the event to hand you some introspection hooks, but for the rest this is a regular Scenario test: you
can't emit multiple events in a single charm execution.

# The virtual charm root
Expand Down
1 change: 1 addition & 0 deletions docs/custom_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,5 +311,6 @@ def _compute_navigation_tree(context):
('py:class', '_Event'),
('py:class', 'scenario.state._DCBase'),
('py:class', 'scenario.state._EntityStatus'),
('py:class', 'scenario.state._Event'),
('py:class', 'scenario.state._max_posargs.<locals>._MaxPositionalArgs'),
]
1 change: 0 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ scenario.Context

.. automodule:: scenario.context


scenario.consistency_checker
============================

Expand Down
9 changes: 7 additions & 2 deletions scenario/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
# Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details.
from scenario.context import ActionOutput, Context
from scenario.context import Context, Manager, Task
from scenario.state import (
ActionFailed,
ActiveStatus,
Address,
BindAddress,
Expand All @@ -21,6 +22,7 @@
Network,
Notice,
PeerRelation,
Port,
Relation,
Resource,
Secret,
Expand All @@ -37,7 +39,8 @@
)

__all__ = [
"ActionOutput",
"Task",
"ActionFailed",
"CheckInfo",
"CloudCredential",
"CloudSpec",
Expand All @@ -56,6 +59,7 @@
"Address",
"BindAddress",
"Network",
"Port",
"ICMPPort",
"TCPPort",
"UDPPort",
Expand All @@ -70,4 +74,5 @@
"MaintenanceStatus",
"ActiveStatus",
"UnknownStatus",
"Manager",
]
Loading
Loading