Harness (legacy unit testing)

Deprecated since version 2.17: The Harness framework is deprecated and will be moved out of the base install in a future ops release. Charm authors that don’t want to upgrade will still be able to use it with pip install ops[harness].

The Harness API includes:

  • ops.testing.Harness, a class to set up the simulated environment, that provides:

    • add_relation() method, to declare a relation (integration) with another app.

    • begin() and cleanup() methods to start and end the testing lifecycle.

    • evaluate_status() method, which aggregates the status of the charm after test interactions.

    • model attribute, which exposes e.g. the unit attribute for detailed assertions on the unit’s state.

Warning

The Harness API has flaws with resetting the charm state between Juju events. Care must be taken when emitting multiple events with the same Harness object.

Note

Unit testing is only one aspect of a comprehensive testing strategy. For more on testing charms, see Charm SDK | Testing.

class ops.testing.ActionFailed(message: str, output: ActionOutput | None = None, *, state: State | None = None)[source]

Bases: Exception

Raised when event.fail() is called during an action handler.

message: str

Optional details of the failure, as provided by ops.ActionEvent.fail().

output: ActionOutput

Any logs and results set by the Charm.

When using Context.run, both logs and results will be empty - these can be found in Context.action_logs and Context.action_results.

state: State | None

The Juju state after the action has been run.

When using Harness.run_action, this will be None.

class ops.testing.ActionOutput(logs: List[str], results: Dict[str, Any])[source]

Bases: object

Contains the logs and results from a Harness.run_action() call.

logs: List[str]

Messages generated by the Charm using ops.ActionEvent.log().

results: Dict[str, Any]

The action’s results, as set or updated by ops.ActionEvent.set_results().

class ops.testing.ExecArgs(command: List[str], environment: Dict[str, str], working_dir: str | None, timeout: float | None, user_id: int | None, user: str | None, group_id: int | None, group: str | None, stdin: str | bytes | None, encoding: str | None, combine_stderr: bool)[source]

Bases: object

Represent arguments captured from the ops.Container.exec() method call.

These arguments will be passed to the Harness.handle_exec() handler function. See ops.pebble.Client.exec() for documentation of properties.

combine_stderr: bool
command: List[str]
encoding: str | None
environment: Dict[str, str]
group: str | None
group_id: int | None
stdin: str | bytes | None
timeout: float | None
user: str | None
user_id: int | None
working_dir: str | None
class ops.testing.ExecResult(exit_code: int = 0, stdout: str | bytes = b'', stderr: str | bytes = b'')[source]

Bases: object

Represents the result of a simulated process execution.

This class is typically used to return the output and exit code from the Harness.handle_exec() result or handler function.

exit_code: int = 0
stderr: str | bytes = b''
stdout: str | bytes = b''
class ops.testing.Harness(charm_cls: Type[CharmType], *, meta: str | TextIO | None = None, actions: str | TextIO | None = None, config: str | TextIO | None = None)[source]

Bases: Generic[CharmType]

This class represents a way to build up the model that will drive a test suite.

The model created is from the viewpoint of the charm that is being tested.

Always call harness.cleanup() after creating a Harness:

@pytest.fixture()
def harness():
    harness = Harness(MyCharm)
    yield harness
    harness.cleanup()

Below is an example test using begin_with_initial_hooks() that ensures the charm responds correctly to config changes (the parameter harness in the test function is a pytest fixture that does setup/teardown, see Harness):

def test_foo(harness):
    # Instantiate the charm and trigger events that Juju would on startup
    harness.begin_with_initial_hooks()

    # Update charm config and trigger config-changed
    harness.update_config({'log_level': 'warn'})

    # Check that charm properly handled config-changed, for example,
    # the charm added the correct Pebble layer
    plan = harness.get_container_pebble_plan('prometheus')
    assert '--log.level=warn' in plan.services['prometheus'].command

To set up the model without triggering events (or calling charm code), perform the harness actions before calling begin(). Below is an example that adds a relation before calling begin, and then updates config to trigger the config-changed event in the charm (the parameter harness in the test function is a pytest fixture that does setup/teardown, see Harness):

def test_bar(harness):
    # Set up model before "begin" (no events triggered)
    harness.set_leader(True)
    harness.add_relation('db', 'postgresql', unit_data={'key': 'val'})

    # Now instantiate the charm to start triggering events as the model changes
    harness.begin()
    harness.update_config({'some': 'config'})

    # Check that charm has properly handled config-changed, for example,
    # has written the app's config file
    root = harness.get_filesystem_root('container')
    assert (root / 'etc' / 'app.conf').exists()
Parameters:
  • charm_cls – The Charm class to test.

  • meta – A string or file-like object containing the contents of metadata.yaml. If not supplied, we will look for a metadata.yaml file in the parent directory of the Charm, and if not found fall back to a trivial name: test-charm metadata.

  • actions – A string or file-like object containing the contents of actions.yaml. If not supplied, we will look for an actions.yaml file in the parent directory of the Charm.

  • config – A string or file-like object containing the contents of config.yaml. If not supplied, we will look for a config.yaml file in the parent directory of the Charm.

add_model_secret(owner: str | Application | Unit, content: Dict[str, str]) str[source]

Add a secret owned by the remote application or unit specified.

This is named add_model_secret instead of add_secret to avoid confusion with the ops.Application.add_secret() and ops.Unit.add_secret() methods used by secret owner charms.

Parameters:
  • owner – The name of the remote application (or specific remote unit) that will own the secret.

  • content – A key-value mapping containing the payload of the secret, for example {"password": "foo123"}.

Returns:

The ID of the newly-secret added.

add_network(address: str, *, endpoint: str | None = None, relation_id: int | None = None, cidr: str | None = None, interface: str = 'eth0', ingress_addresses: Iterable[str] | None = None, egress_subnets: Iterable[str] | None = None)[source]

Add simulated network data for the given relation endpoint (binding).

Calling this multiple times with the same (binding, relation_id) combination will replace the associated network data.

Example:

# Set network info for default binding
harness.add_network('10.0.0.10')

# Or set network info for specific endpoint
harness.add_network('10.0.0.10', endpoint='db')

After either of those calls, the following will be true (in the first case, the simulated network-get will fall back to the default binding):

binding = harness.model.get_binding('db')
assert binding.network.bind_address == ipaddress.IPv4Address('10.0.0.10'))
Parameters:
  • address – Binding’s IPv4 or IPv6 address.

  • endpoint – Name of relation endpoint (binding) to add network data for. If not provided, add info for the default binding.

  • relation_id – Relation ID for the binding. If provided, the endpoint argument must be provided and correspond. If not provided, add network data for the endpoint’s default binding.

  • cidr – Binding’s CIDR. Defaults to “<address>/24” if address is an IPv4 address, or “<address>/64” if address is IPv6 (the host bits are cleared).

  • interface – Name of network interface.

  • ingress_addresses – List of ingress addresses. Defaults to [address].

  • egress_subnets – List of egress subnets. Defaults to [cidr].

Raises:
  • ModelError – If the endpoint is not a known relation name, or the relation_id is incorrect or doesn’t match the endpoint.

  • ValueError – If address is not an IPv4 or IPv6 address.

add_oci_resource(resource_name: str, contents: Mapping[str, str] | None = None) None[source]

Add OCI resources to the backend.

This will register an OCI resource and create a temporary file for processing metadata about the resource. A default set of values will be used for all the file contents unless a specific contents dict is provided.

Parameters:
  • resource_name – Name of the resource to add custom contents to.

  • contents – Optional custom dict to write for the named resource.

add_relation(relation_name: str, remote_app: str, *, app_data: Mapping[str, str] | None = None, unit_data: Mapping[str, str] | None = None) int[source]

Declare that there is a new relation between this application and remote_app.

This function creates a relation with an application and triggers a RelationCreatedEvent. To match Juju’s behaviour, it also creates a default network binding on this endpoint. If you want to associate a custom network to this binding (or a global default network), provide one using add_network() before calling this function.

If app_data or unit_data are provided, also add a new unit (<remote_app>/0) to the relation and trigger RelationJoinedEvent. Then update the application data if app_data is provided and the unit data if unit_data is provided, triggering RelationChangedEvent after each update. Alternatively, charm tests can call add_relation_unit() and update_relation_data() explicitly.

For peer relations defined in the charm’s metadata, begin_with_initial_hooks() will create them automatically, so the caller doesn’t need to call add_relation(). If the caller chooses to add a peer relation by themselves, make sure to call add_relation() before begin_with_initial_hooks() so that Harness won’t create it again.

Example usage:

secret_id = harness.add_model_secret('mysql', {'password': 'SECRET'})
harness.add_relation('db', 'mysql', unit_data={
    'host': 'mysql.localhost,
    'username': 'appuser',
    'secret-id': secret_id,
})
Parameters:
  • relation_name – The relation on the charm that is being integrated with.

  • remote_app – The name of the application that is being integrated with. To add a peer relation, set to the name of this application.

  • app_data – If provided, also add a new unit to the relation (triggering relation-joined) and set the application relation data (triggering relation-changed).

  • unit_data – If provided, also add a new unit to the relation (triggering relation-joined) and set the unit relation data (triggering relation-changed).

Returns:

The ID of the relation created.

add_relation_unit(relation_id: int, remote_unit_name: str) None[source]

Add a new unit to a relation.

This will trigger a relation_joined event. This would naturally be followed by a relation_changed event, which can be triggered with update_relation_data(). This separation is artificial in the sense that Juju will always fire the two, but is intended to make testing relations and their data bags slightly more natural.

Unless finer-grained control is needed, most charm tests can call add_relation() with the app_data or unit_data argument instead of using this function.

Example:

rel_id = harness.add_relation('db', 'postgresql')
harness.add_relation_unit(rel_id, 'postgresql/0')
Parameters:
  • relation_id – The integer relation identifier (as returned by add_relation()).

  • remote_unit_name – A string representing the remote unit that is being added.

add_resource(resource_name: str, content: AnyStr) None[source]

Add content for a resource to the backend.

This will register the content, so that a call to model.resources.fetch(resource_name) will return a path to a file containing that content.

Parameters:
  • resource_name – The name of the resource being added

  • content – Either string or bytes content, which will be the content of the filename returned by resource-get. If contents is a string, it will be encoded in utf-8

add_storage(storage_name: str, count: int = 1, *, attach: bool = False) List[str][source]

Create a new storage device and attach it to this unit.

To have repeatable tests, each device will be initialized with location set to /[tmpdir]/<storage_name>N, where N is the counter and will be a number from [0,total_num_disks-1].

The test harness uses symbolic links to imitate storage mounts, which may lead to some inconsistencies compared to the actual charm.

Parameters:
  • storage_name – The storage backend name on the Charm

  • count – Number of disks being added

  • attach – True to also attach the storage mount; if begin() has been called a True value will also emit storage-attached

Returns:

A list of storage IDs, e.g. [“my-storage/1”, “my-storage/2”].

add_user_secret(content: Dict[str, str]) str[source]

Add a secret owned by the user, simulating the juju add-secret command.

Parameters:

content – A key-value mapping containing the payload of the secret, for example {"password": "foo123"}.

Returns:

The ID of the newly-added secret.

Example usage (the parameter harness in the test function is a pytest fixture that does setup/teardown, see Harness):

# charmcraft.yaml
config:
  options:
    mysec:
      type: secret
      description: "tell me your secrets"

# charm.py
class MyVMCharm(ops.CharmBase):
    def __init__(self, framework: ops.Framework):
        super().__init__(framework)
        framework.observe(self.on.config_changed, self._on_config_changed)

    def _on_config_changed(self, event: ops.ConfigChangedEvent):
        mysec = self.config.get('mysec')
        if mysec:
            sec = self.model.get_secret(id=mysec, label="mysec")
            self.config_from_secret = sec.get_content()

# test_charm.py
def test_config_changed(harness):
    secret_content = {'password': 'foo'}
    secret_id = harness.add_user_secret(secret_content)
    harness.grant_secret(secret_id, 'test-charm')
    harness.begin()
    harness.update_config({'mysec': secret_id})
    secret = harness.model.get_secret(id=secret_id).get_content()
    assert harness.charm.config_from_secret == secret.get_content()
attach_storage(storage_id: str) None[source]

Attach a storage device.

The intent of this function is to simulate a juju attach-storage call. If called after begin() and hooks are not disabled, it will trigger a storage-attached hook if the storage unit in question exists and is presently marked as detached.

The test harness uses symbolic links to imitate storage mounts, which may lead to some inconsistencies compared to the actual charm.

Parameters:

storage_id – The full storage ID of the storage unit being attached, including the storage key, e.g. my-storage/0.

begin() None[source]

Instantiate the Charm and start handling events.

Before calling begin(), there is no Charm instance, so changes to the Model won’t emit events. Call begin() for charm to be valid.

Should only be called once.

begin_with_initial_hooks() None[source]

Fire the same hooks that Juju would fire at startup.

This triggers install, relation-created, config-changed, start, pebble-ready (for any containers), and any relation-joined hooks based on what relations have been added before begin was called. Note that all of these are fired before returning control to the test suite, so to introspect what happens at each step, fire them directly (for example, Charm.on.install.emit()).

To use this with all the normal hooks, instantiate the harness, setup any relations that should be active when the charm starts, and then call this method. This method will automatically create and add peer relations that are specified in metadata.yaml.

If the charm metadata specifies containers, this sets can_connect to True for all containers (in addition to triggering pebble-ready for each).

Example:

harness = Harness(MyCharm)
# Do initial setup here
# Add storage if needed before begin_with_initial_hooks() is called
storage_ids = harness.add_storage('data', count=1)[0]
storage_id = storage_id[0] # we only added one storage instance
harness.add_relation('db', 'postgresql', unit_data={'key': 'val'})
harness.set_leader(True)
harness.update_config({'initial': 'config'})
harness.begin_with_initial_hooks()
# This will cause
# install, db-relation-created('postgresql'), leader-elected, config-changed, start
# db-relation-joined('postgresql/0'), db-relation-changed('postgresql/0')
# To be fired.
property charm: CharmType

Return the instance of the charm class that was passed to __init__.

Note that the Charm is not instantiated until begin() is called. Until then, attempting to access this property will raise an exception.

cleanup() None[source]

Called by the test infrastructure to clean up any temporary directories/files/etc.

Always call self.addCleanup(harness.cleanup) after creating a Harness.

container_pebble_ready(container_name: str)[source]

Fire the pebble_ready hook for the associated container.

This will switch the given container’s can_connect state to True before the hook function is called.

It will do nothing if begin() has not been called.

detach_storage(storage_id: str) None[source]

Detach a storage device.

The intent of this function is to simulate a juju detach-storage call. It will trigger a storage-detaching hook if the storage unit in question exists and is presently marked as attached.

Note that the Charm is not instantiated until begin() is called. Until then, attempting to use this method will raise an exception.

Parameters:

storage_id – The full storage ID of the storage unit being detached, including the storage key, e.g. my-storage/0.

disable_hooks() None[source]

Stop emitting hook events when the model changes.

This can be used by developers to stop changes to the model from emitting events that the charm will react to. Call enable_hooks() to re-enable them.

enable_hooks() None[source]

Re-enable hook events from charm.on when the model is changed.

By default, hook events are enabled once begin() is called, but if disable_hooks() is used, this method will enable them again.

evaluate_status() None[source]

Trigger the collect-status events and set application and/or unit status.

This will always trigger collect_unit_status, and set the unit status if any statuses were added.

If running on the leader unit (set_leader() has been called with True), this will trigger collect_app_status, and set the application status if any statuses were added.

Tests should normally call this and then assert that self.model.app.status or self.model.unit.status is the value expected.

Evaluation is not “additive”; this method resets the added statuses before triggering each collect-status event.

property framework: Framework

Return the Framework that is being driven by this Harness.

get_container_pebble_plan(container_name: str) Plan[source]

Return the current plan that Pebble is executing for the given container.

Parameters:

container_name – The simple name of the associated container

Returns:

The Pebble plan for this container. Use Plan.to_yaml to get a string form for the content.

Raises:

KeyError – if no Pebble client exists for that container name (should only happen if container is not present in metadata.yaml).

get_filesystem_root(container: str | Container) Path[source]

Return the temp directory path harness will use to simulate the container filesystem.

In a real container runtime, each container has an isolated root filesystem. To simulate this behaviour, the testing harness manages a temporary directory for each container. Any Pebble filesystem API calls will be translated and mapped to this directory, as if the directory was the container’s filesystem root.

This process is quite similar to the chroot command. Charm tests should treat the returned directory as the container’s root directory (/). The testing harness will not create any files or directories inside the simulated container’s root directory; it’s up to the test to populate the container’s root directory with any files or directories the charm needs.

Regarding the file ownership: unprivileged users are unable to create files with distinct ownership. To circumvent this limitation, the testing harness maps all user and group options related to file operations to match the current user and group.

Example usage (the parameter harness in the test function is a pytest fixture that does setup/teardown, see Harness):

# charm.py
class ExampleCharm(ops.CharmBase):
    def __init__(self, *args):
        super().__init__(*args)
        self.framework.observe(self.on["mycontainer"].pebble_ready,
                               self._on_pebble_ready)

    def _on_pebble_ready(self, event: ops.PebbleReadyEvent):
        self.hostname = event.workload.pull("/etc/hostname").read()

# test_charm.py
def test_hostname(harness):
    root = harness.get_filesystem_root("mycontainer")
    (root / "etc").mkdir()
    (root / "etc" / "hostname").write_text("hostname.example.com")
    harness.begin_with_initial_hooks()
    assert harness.charm.hostname == "hostname.example.com"
Parameters:

container – The name of the container or the container instance.

Returns:

The path of the temporary directory associated with the specified container.

get_pod_spec() Tuple[Mapping[Any, Any], Mapping[Any, Any]][source]

Return the content of the pod spec as last set by the charm.

This returns both the pod spec and any k8s_resources that were supplied. See the signature of Pod.set_spec.

get_relation_data(relation_id: int, app_or_unit: str | Application | Unit) Mapping[str, str][source]

Get the relation data bucket for a single app or unit in a given relation.

This ignores all of the safety checks of who can and can’t see data in relations (eg, non-leaders can’t read their own application’s relation data because there are no events that keep that data up-to-date for the unit).

Parameters:
  • relation_id – The relation whose content we want to look at.

  • app_or_unit – An Application or Unit instance, or its name, whose data we want to read.

Returns:

A dict containing the relation data for app_or_unit or None.

Raises:

KeyError – if relation_id doesn’t exist

get_secret_grants(secret_id: str, relation_id: int) Set[str][source]

Return the set of app and unit names granted to secret for this relation.

Parameters:
  • secret_id – The ID of the secret to get grants for.

  • relation_id – The ID of the relation granted access.

get_secret_revisions(secret_id: str) List[int][source]

Return the list of revision IDs for the given secret, oldest first.

Parameters:

secret_id – The ID of the secret to get revisions for.

get_workload_version() str[source]

Read the workload version that was set by the unit.

grant_secret(secret_id: str, observer: str | Application | Unit)[source]

Grant read access to this secret for the given observer application or unit.

For user secrets, grant access to the application, simulating the juju grant-secret command.

If the given application or unit has already been granted access to this secret, do nothing.

Parameters:
  • secret_id – The ID of the secret to grant access to. This should normally be the return value of add_model_secret().

  • observer – The name of the application (or specific unit) to grant access to. A relation between this application and the charm under test must already have been created.

handle_exec(container: str | Container, command_prefix: Sequence[str], *, handler: Callable[[ExecArgs], None | ExecResult] | None = None, result: int | str | bytes | ExecResult | None = None)[source]

Register a handler to simulate the Pebble command execution.

This allows a test harness to simulate the behavior of running commands in a container. When ops.Container.exec() is triggered, the registered handler is used to generate stdout and stderr for the simulated execution.

A handler or a result may be provided, but not both:

  • A handler is a function accepting ops.testing.ExecArgs and returning ops.testing.ExecResult as the simulated process outcome. For cases that have side effects but don’t return output, the handler can return None, which is equivalent to returning ExecResult().

  • A result is for simulations that don’t need to inspect the exec arguments; the output or exit code is provided directly. Setting result to str or bytes means use that string as stdout (with exit code 0); setting result to int means return that exit code (and no stdout).

If handle_exec is called more than once with overlapping command prefixes, the longest match takes precedence. The registration of an execution handler can be updated by re-registering with the same command prefix.

The execution handler receives the timeout value in the ExecArgs. If needed, it can raise a TimeoutError to inform the harness that a timeout occurred.

If ops.Container.exec() is called with combine_stderr=True, the execution handler should, if required, weave the simulated standard error into the standard output. The harness checks the result and will raise an exception if stderr is non-empty.

Parameters:
  • container – The specified container or its name.

  • command_prefix – The command prefix to register against.

  • handler – A handler function that simulates the command’s execution.

  • result – A simplified form to specify the command’s simulated result.

Example usage:

# produce no output and return 0 for every command
harness.handle_exec('container', [], result=0)

# simple example that just produces output (exit code 0)
harness.handle_exec('webserver', ['ls', '/etc'], result='passwd\nprofile\n')

# slightly more complex (use stdin)
harness.handle_exec(
    'c1', ['sha1sum'],
    handler=lambda args: ExecResult(stdout=hashlib.sha1(args.stdin).hexdigest()))

# more complex example using args.command
def docker_handler(args: testing.ExecArgs) -> testing.ExecResult:
    match args.command:
        case ['docker', 'run', image]:
            return testing.ExecResult(stdout=f'running {image}')
        case ['docker', 'ps']:
            return testing.ExecResult(stdout='CONTAINER ID   IMAGE ...')
        case _:
            return testing.ExecResult(exit_code=1, stderr='unknown command')

harness.handle_exec('database', ['docker'], handler=docker_handler)

# handle timeout
def handle_timeout(args: testing.ExecArgs) -> int:
    if args.timeout is not None and args.timeout < 10:
        raise TimeoutError
    return 0

harness.handle_exec('database', ['foo'], handler=handle_timeout)
hooks_disabled()[source]

A context manager to run code with hooks disabled.

Example:

with harness.hooks_disabled():
    # things in here don't fire events
    harness.set_leader(True)
    harness.update_config(unset=['foo', 'bar'])
# things here will again fire events
property model: Model

Return the Model that is being driven by this Harness.

pebble_notify(container_name: str, key: str, *, data: Dict[str, str] | None = None, repeat_after: timedelta | None = None, type: NoticeType = NoticeType.CUSTOM) str[source]

Record a Pebble notice with the specified key and data.

If begin() has been called and the notice is new or was repeated, this will trigger a notice event of the appropriate type, for example ops.PebbleCustomNoticeEvent.

Parameters:
  • container_name – Name of workload container.

  • key – Notice key; must be in “example.com/path” format.

  • data – Data fields for this notice.

  • repeat_after – Only allow this notice to repeat after this duration has elapsed (the default is to always repeat).

  • type – Notice type (currently only “custom” notices are supported).

Returns:

The notice’s ID.

populate_oci_resources() None[source]

Populate all OCI resources.

property reboot_count: int

Number of times the charm has called ops.Unit.reboot().

remove_relation(relation_id: int) None[source]

Remove a relation.

Parameters:

relation_id – The relation ID for the relation to be removed.

Raises:

RelationNotFoundError – if relation id is not valid

remove_relation_unit(relation_id: int, remote_unit_name: str) None[source]

Remove a unit from a relation.

Example:

rel_id = harness.add_relation('db', 'postgresql')
harness.add_relation_unit(rel_id, 'postgresql/0')
...
harness.remove_relation_unit(rel_id, 'postgresql/0')

This will trigger a relation_departed event. This would normally be followed by a relation_changed event triggered by Juju. However, when using the test harness, a relation_changed event must be triggered using update_relation_data(). This deviation from normal Juju behaviour facilitates testing by making each step in the charm life cycle explicit.

Parameters:
  • relation_id – The integer relation identifier (as returned by add_relation()).

  • remote_unit_name – A string representing the remote unit that is being removed.

remove_storage(storage_id: str) None[source]

Detach a storage device.

The intent of this function is to simulate a juju remove-storage call. It will trigger a storage-detaching hook if the storage unit in question exists and is presently marked as attached. Then it will remove the storage unit from the testing backend.

Parameters:

storage_id – The full storage ID of the storage unit being removed, including the storage key, e.g. my-storage/0.

Raises:

RuntimeError – if the storage is not in the metadata.

reset_planned_units() None[source]

Reset the planned units override.

This allows the harness to fall through to the built in methods that will try to guess at a value for planned units, based on the number of peer relations that have been setup in the testing harness.

revoke_secret(secret_id: str, observer: str | Application | Unit)[source]

Revoke read access to this secret for the given observer application or unit.

If the given application or unit does not have access to this secret, do nothing.

Parameters:
  • secret_id – The ID of the secret to revoke access for. This should normally be the return value of add_model_secret().

  • observer – The name of the application (or specific unit) to revoke access to. A relation between this application and the charm under test must have already been created.

run_action(action_name: str, params: Dict[str, Any] | None = None) ActionOutput[source]

Simulates running a charm action, as with juju run.

Use this only after calling begin().

Validates that no required parameters are missing, and that additional parameters are not provided if that is not permitted. Does not validate the types of the parameters - you can use the jsonschema package to do this in your tests; for example:

schema = harness.charm.meta.actions["action-name"].parameters
try:
    jsonschema.validate(instance=params, schema=schema)
except jsonschema.ValidationError:
    # Do something about the invalid params.
    ...
harness.run_action("action-name", params)
Parameters:
  • action_name – the name of the action to run, as found in actions.yaml.

  • params – override the default parameter values found in actions.yaml. If a parameter is not in params, or params is None, then the default value from actions.yaml will be used.

Raises:

ActionFailed – if ops.ActionEvent.fail() is called. Note that this will be raised at the end of the run_action call, not immediately when fail() is called, to match the run-time behaviour.

set_can_connect(container: str | Container, val: bool)[source]

Change the simulated connection status of a container’s underlying Pebble client.

After calling this, ops.Container.can_connect() will return val.

set_cloud_spec(spec: CloudSpec)[source]

Set cloud specification (metadata) including credentials.

Call this method before the charm calls ops.Model.get_cloud_spec().

Example usage (the parameter harness in the test function is a pytest fixture that does setup/teardown, see Harness):

# charm.py
class MyVMCharm(ops.CharmBase):
    def __init__(self, framework: ops.Framework):
        super().__init__(framework)
        framework.observe(self.on.start, self._on_start)

    def _on_start(self, event: ops.StartEvent):
        self.cloud_spec = self.model.get_cloud_spec()

# test_charm.py
def test_start(harness):
    cloud_spec = ops.model.CloudSpec.from_dict({
        'name': 'localhost',
        'type': 'lxd',
        'endpoint': 'https://127.0.0.1:8443',
        'credential': {
            'auth-type': 'certificate',
            'attrs': {
                'client-cert': 'foo',
                'client-key': 'bar',
                'server-cert': 'baz'
            },
        },
    })
    harness.set_cloud_spec(cloud_spec)
    harness.begin()
    harness.charm.on.start.emit()
    assert harness.charm.cloud_spec == cloud_spec
set_leader(is_leader: bool = True) None[source]

Set whether this unit is the leader or not.

If this charm becomes a leader then leader_elected will be triggered. If begin() has already been called, then the charm’s peer relation should usually be added prior to calling this method (with add_relation()) to properly initialise and make available relation data that leader elected hooks may want to access.

Parameters:

is_leader – Whether this unit is the leader.

set_model_info(name: str | None = None, uuid: str | None = None) None[source]

Set the name and UUID of the model that this is representing.

Cannot be called once begin() has been called. Use it to set the value that will be returned by Model.name and Model.uuid.

This is a convenience method to invoke both set_model_name() and set_model_uuid() at once.

set_model_name(name: str) None[source]

Set the name of the Model that this is representing.

Cannot be called once begin() has been called. Use it to set the value that will be returned by Model.name.

set_model_uuid(uuid: str) None[source]

Set the uuid of the Model that this is representing.

Cannot be called once begin() has been called. Use it to set the value that will be returned by Model.uuid.

set_planned_units(num_units: int) None[source]

Set the number of “planned” units.

This is the value that Application.planned_units should return.

In real world circumstances, this number will be the number of units in the application. That is, this number will be the number of peers this unit has, plus one, as we count our own unit in the total.

A change to the return from planned_units will not generate an event. Typically, a charm author would check planned units during a config or install hook, or after receiving a peer relation joined event.

set_secret_content(secret_id: str, content: Dict[str, str])[source]

Update a secret’s content, add a new revision, and fire secret-changed.

Parameters:
  • secret_id – The ID of the secret to update. This should normally be the return value of add_model_secret().

  • content – A key-value mapping containing the new payload.

trigger_secret_expiration(secret_id: str, revision: int, *, label: str | None = None)[source]

Trigger a secret-expired event for the given secret.

This event is fired by Juju when a secret’s expiration time elapses, however, time-based events cannot be simulated appropriately in the harness, so this fires it manually.

Parameters:
  • secret_id – The ID of the secret associated with the event.

  • revision – Revision number to provide to the event. This should be an item from the list returned by get_secret_revisions().

  • label – Label value to send to the event. If None, the secret’s label is used.

trigger_secret_removal(secret_id: str, revision: int, *, label: str | None = None)[source]

Trigger a secret-remove event for the given secret and revision.

This event is fired by Juju for a specific revision when all the secret’s observers have refreshed to a later revision, however, in the harness call this method to fire the event manually.

Parameters:
  • secret_id – The ID of the secret associated with the event.

  • revision – Revision number to provide to the event. This should be an item from the list returned by get_secret_revisions().

  • label – Label value to send to the event. If None, the secret’s label is used.

trigger_secret_rotation(secret_id: str, *, label: str | None = None)[source]

Trigger a secret-rotate event for the given secret.

This event is fired by Juju when a secret’s rotation time elapses, however, time-based events cannot be simulated appropriately in the harness, so this fires it manually.

Parameters:
  • secret_id – The ID of the secret associated with the event.

  • label – Label value to send to the event. If None, the secret’s label is used.

update_config(key_values: Mapping[str, str | int | float | bool] | None = None, unset: Iterable[str] = ()) None[source]

Update the config as seen by the charm.

This will trigger a config_changed event.

Note that the key_values mapping will only add or update configuration items. To remove existing ones, see the unset parameter.

Parameters:
  • key_values – A Mapping of key:value pairs to update in config.

  • unset – An iterable of keys to remove from config. This sets the value to the default if defined, otherwise removes the key altogether.

Raises:

ValueError – if the key is not present in the config.

update_relation_data(relation_id: int, app_or_unit: str, key_values: Mapping[str, str]) None[source]

Update the relation data for a given unit or application in a given relation.

This also triggers the relation_changed event for the given relation_id.

Unless finer-grained control is needed, most charm tests can call add_relation() with the app_data or unit_data argument instead of using this function.

Parameters:
  • relation_id – The integer relation ID representing this relation.

  • app_or_unit – The unit or application name that is being updated. This can be the local or remote application.

  • key_values – Each key/value will be updated in the relation data.