원클릭으로
scenario-tests
Writing unit tests for charms with ops.testing (Scenario)
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Writing unit tests for charms with ops.testing (Scenario)
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Known-good Juju bundle shapes (COS Lite, 12-Factor + COS, Identity Platform, Charmed Kubeflow) — canonical app lists and relation edges
Implementing Juju actions for operational tasks in charms. WHEN: add Juju actions to a charm, implement backup / rotate-credentials / restore actions, declare actions in charmcraft.yaml, write action handlers, test actions with Scenario, run actions with juju run.
Adding and validating charm configuration options. WHEN: add charm configuration options, declare config in charmcraft.yaml, validate config values in config-changed, apply config to a Pebble layer, apply config to a machine charm, write Scenario tests for config.
End-to-end workflow for building ops-framework charms for custom applications (K8s and machine). WHEN: build a custom Juju charm, write src/charm.py with Pebble, write a machine charm with systemd, decide K8s vs machine substrate, scaffold a non-paas-charm with charmcraft init, customise the ops framework charm lifecycle.
Workflow for charming infrastructure software (databases, caches, message brokers, proxies, monitoring). WHEN: charm infrastructure software, charm a database / cache / message broker / proxy / monitoring system, implement peer relations, leader election, primary/replica failover, backup and restore actions, clustering.
Expert guidance for developing, building, testing, and publishing Juju charms using charmcraft. WHEN: edit charmcraft.yaml, run charmcraft pack, run charmcraft init, manage charm libraries, publish a charm to Charmhub, set up multi-base builds, configure relations / config options, set up storage or containers.
| name | scenario-tests |
| description | Writing unit tests for charms with ops.testing (Scenario) |
| globs | ["tests/unit/**","src/charm.py","src/**/charm.py"] |
Use ops.testing (commonly called Scenario) for all charm unit tests. Never use Harness — it is deprecated.
Scenario tests are state-transition tests: you declare an input State, fire an event, and assert on the output State. No mocking of Juju internals is needed.
Context — wraps your charm class; provides ctx.on.<event>() to fire eventsState — immutable snapshot of the Juju world: relations, config, containers, secrets, storagectx.run(event, state) — fires the event against the state, returns the output stateAdd ops[testing] to the charm's pyproject.toml dependency groups:
[dependency-groups]
unit = ["ops[testing]", "pytest", "coverage"]
Charm dependencies live in pyproject.toml, not requirements.txt. The
charmcraft init --profile kubernetes (or machine) scaffolding produces
the right layout.
import ops
from ops import testing
from src.charm import MyCharm
def test_start_sets_active():
ctx = testing.Context(MyCharm)
state = testing.State()
out = ctx.run(ctx.on.start(), state)
assert out.unit_status == testing.ActiveStatus("ready")
Context reads metadata directly from charmcraft.yaml when handed the
real charm class — do not pass meta= or config= overrides. Those
kwargs are leftovers from older Scenario versions and are unnecessary
once the test imports the actual charm class.
def test_database_relation_joined():
rel = testing.Relation(
endpoint="database",
interface="mysql",
remote_app_data={"host": "db.local", "port": "3306"},
)
state = testing.State(relations=[rel])
ctx = testing.Context(MyCharm)
out = ctx.run(ctx.on.relation_joined(rel), state)
assert out.unit_status == testing.ActiveStatus()
Use pytest.mark.parametrize for config-validation tests so each invalid
value produces a clearly-named failure:
import pytest
@pytest.mark.parametrize(
"log_level,expected",
[
("debug", testing.ActiveStatus()),
("info", testing.ActiveStatus()),
("verbose", testing.BlockedStatus("invalid log-level")),
],
)
def test_config_validation(log_level, expected):
state = testing.State(config={"log-level": log_level})
ctx = testing.Context(MyCharm)
out = ctx.run(ctx.on.config_changed(), state)
assert out.unit_status == expected
For tests that exercise files the charm pushes into a container, fire
pebble_ready (not start) and read the result with get_filesystem:
def test_pebble_ready_pushes_config():
container = testing.Container(name="workload", can_connect=True)
state = testing.State(containers={container})
ctx = testing.Context(MyCharm)
out = ctx.run(ctx.on.pebble_ready(container), state)
# Read pushed files via get_filesystem — no mount setup required.
root = testing.get_filesystem(ctx, "workload")
config = (root / "etc" / "workload" / "config.yaml").read_text()
assert "log-level: info" in config
plan = out.get_container("workload").plan
assert "workload" in plan.services
get_filesystem(ctx, container_name) returns a real temporary directory
populated with whatever the charm pushed. It avoids the older
mount-based testing pattern entirely.
collect_statusFor charms that compute their unit status in collect_status, drive the
status by firing update_status with the relevant container layers and
service statuses, and use equality (==) rather than isinstance:
def test_collect_status_active_when_workload_running():
container = testing.Container(
name="workload",
can_connect=True,
layers={"workload": testing.Layer({"services": {"workload": {}}})},
service_statuses={"workload": ops.pebble.ServiceStatus.ACTIVE},
)
state = testing.State(containers={container})
ctx = testing.Context(MyCharm)
out = ctx.run(ctx.on.update_status(), state)
assert out.unit_status == testing.ActiveStatus()
def test_backup_action():
ctx = testing.Context(MyCharm)
state = testing.State()
out = ctx.run(ctx.on.action("backup", params={"path": "/data"}), state)
assert out.action_results == {"status": "success"}
def test_secret_changed():
secret = testing.Secret(
tracked_content={"password": "old"},
latest_content={"password": "new"},
)
state = testing.State(secrets=[secret])
ctx = testing.Context(MyCharm)
out = ctx.run(ctx.on.secret_changed(secret), state)
assert out.unit_status == testing.ActiveStatus()
State is immutable. To carry state between events in a sequence,
use dataclasses.replace() to derive a new state from the output of the
previous event:
import dataclasses
def test_install_then_config_change():
ctx = testing.Context(MyCharm)
initial = testing.State()
after_start = ctx.run(ctx.on.start(), initial)
assert after_start.unit_status == testing.ActiveStatus()
# Carry forward whatever start() produced; tweak only the config.
next_state = dataclasses.replace(after_start, config={"log-level": "debug"})
final = ctx.run(ctx.on.config_changed(), next_state)
assert final.unit_status == testing.ActiveStatus()
Test state transitions, not implementation. Assert on the output State (status, relation data, config), not on internal charm attributes.
One event per test. Each test fires exactly one event. To simulate a sequence, chain ctx.run() calls and use dataclasses.replace() between them — never mutate a State in place.
Use ctx.on.<event>() to construct events — never instantiate event objects directly.
Leader tests. Set leader=True on the State to test leader-only behaviour:
state = testing.State(leader=True)
Test both happy and error paths. Check that the charm sets BlockedStatus or WaitingStatus when preconditions are not met.
Deferred events. Check out.deferred to verify events were deferred when expected.
Equality, not isinstance. Compare statuses with == (e.g. out.unit_status == testing.ActiveStatus("ready")) so the message is checked too.
State.get_relationState.get_relation accepts either a relation ID or a relation object.
When you pass the object you submitted with the input State, the
return type is narrowed to the same kind (peer, regular, subordinate):
rel_in = testing.Relation(endpoint="database", interface="postgresql")
state_in = testing.State(relations={rel_in})
state_out = ctx.run(ctx.on.relation_changed(rel_in), state_in)
# Pass the object itself — cleaner than pulling `.id` off first.
rel_out = state_out.get_relation(rel_in)
testing.Context.run now leaves sys.breakpointhook alone, so you
can drop a plain breakpoint() call into charm code under test and
get a pdb session when pytest -s tests/unit/test_charm.py reaches
it. Unlike the live-charm Framework.breakpoint() + juju debug-code combo, this needs no deployment — useful when you're
isolating a single state transition.
Remove the breakpoint before committing; CI running without -s
will hang on a pdb prompt.
For charms that use a charmcraft extension (the 12-factor
paas-charm family, for example), Scenario now autoloads the
extension-expanded metadata, config, and actions the same way
charmcraft pack does before writing the per-file YAML. You do not
need to manually reconstruct the extended metadata in your tests —
testing.Context(MyCharm) picks it up automatically as long as the
charm's charmcraft.yaml is discoverable from the charm class.
When a deployed charm misbehaves and you want to write a regression test
that reproduces the exact databag and storage state, use
jhack scenario snapshot <unit> to dump the live state in
Scenario-friendly form. Paste the snapshot into a test file as the input
State and you have a deterministic reproduction of the production bug.
Run the scenario_coverage tool against the charm directory. It maps every
framework.observe(self.on.X, self._on_Y) registration to the test functions
under tests/unit/ that reference the handler or event name, and flags two
event-shape gaps pytest-cov cannot see: missing container.can_connect=False
tests (when the charm has containers) and missing relation-broken tests
(when the charm has any relation). Use it before declaring a suite "done".
meta= or config= to Context() — Scenario reads
these from charmcraft.yaml automatically.unittest.mock.patch on Juju internals. Scenario handles all Juju interactions through the State.ops.testing inside functions — keep imports at module level.can_connect=False is the default. Set it to True when testing Pebble interactions.pebble_ready, not start, for tests that exercise container file operations or service plans.