Repository: canonical/operator
Pull request #1381 (2024-09-18 10:27:10)

feat: optionally install Scenario with `ops[testing]` and expose the names in ops.testing
Add a new optional install `testing`, e.g. `pip install ops[testing]`. This pulls in a compatible version of `ops-scenario`, and exposes the Scenario names in the `ops.testing` namespace, alongside Harness.

`pip install ops[harness]` is also supported to ease the (far off, presumably 3.0) transition to Harness being moved out of the base install. It currently installs no extra dependencies, so is the same as `pip install ops` but a forward-looking charm would use `pip install ops[harness]` (or `pip install ops[harness, testing]`) if using Harness.

Requires ops-scenario 7.0.5, which has the required adjustments to support insertion into `ops.testing`.

The `ActionFailed` name exists in both `ops._private.harness` and `scenario.context`. This is handled by adjusting the Harness class to support the functionality of both and monkeypatching that into Scenario until Scenario starts using it. It's compatible with both Harness and Scenario, but will have empty data in an attribute (which attribute depends on which framework is used).

The `Container` name in `ops.testing`, which is only present for backwards compatibility, is also overwritten if ops-scenario is installed. If anyone is actually using `ops.testing.Container` instead of `ops.Container` then they'll need to fix their code before using `ops[testing]` (or ops-scenario combined with the release of ops with this change).

A very basic unit test is added to make sure that Scenario tests work (all the actual Scenario tests are in the ops-scenario repo) if ops-scenario/ops[testing] is installed (this is the case for `tox -e unit`). A test is also added to ensure that all of the Scenario names are documented, since `automodule` isn't used any more.

Also adjusts the documentation to include the new framework in ops.testing.

tonyandrewmeyer: @PietroPasotti @benhoyt @dimaqq I imagine this PR will have a bit of back-and-forth before it's completely done. Would you please give it an initial look-over? Probably focusing on the main approaches rather than specifics for now, and then once everyone is happy with that there can be a second review for approval that goes deeper. Thanks!
tonyandrewmeyer: (I'll look into the failing test with Python 3.10 - I hadn't checked different Pythons locally, so will do that).
dimaqq: CI fail: `TypeError: EventBase.__init__() got an unexpected keyword argument 'app'`

3.12 was not run / did not get to complete because 3.10 failed. 
tonyandrewmeyer: > CI fail: `TypeError: EventBase.__init__() got an unexpected keyword argument 'app'`
> 
> 3.12 was not run / did not get to complete because 3.10 failed.

Yeah, the tests all run locally for me with 3.8, 3.9, 3.10, 3.11, 3.12. Still looking into this, but it seems unlikely that it needs to block a high-level review.
dimaqq: A point for discussion about the overall approach:
- do we want to keep separate pypi:ops-scenario going forward?
- or do shall we vendor scenario code and rename things?
tonyandrewmeyer: > * do we want to keep separate pypi:ops-scenario going forward?
> * or do shall we vendor scenario code and rename things?

We have to have a separate package in order to (cleanly) have one set of code that gets used when building charms and an augmented set of code that gets used when testing charms. I very strongly feel that the test framework should not be in the bundled charm. I think the correct way to do that is to have a package for testing (eventually two: one for Harness and one for Scenario) rather than things like charmcraft ripping out the folder when packing.

We are intending to add the contents of the ops-scenario repository to the operator repository, but my preference is to do that as a separate PR after this one. We would then publish two packages from the operator repository. I still feel that's going to be a better maintenance burden than having two repos. I don't really see a benefit in renaming things as part of this, but that can be discussed when we get to it.

We could publish Scenario under a new name, like `ops-testing`. I don't see much value in that either, though, and I like having a small nod to the history of how this framework came to be.
tonyandrewmeyer: I can reproduce the failing test locally if I turn pytest-xdist off (probably other small numbers work too). I assume some other test is leaking into the one that's failing, so it only fails when both of those tests are executed in the same runner.
tonyandrewmeyer: @tmihoc the code changes won't be interesting to you, but you might like to review the doc changes? It's probably easiest to look over the [generated docs](https://ops--1381.org.readthedocs.build/en/1381/) since it pulls in the definitions from ops-scenario as well (which you already reviewed, of course).

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d4ed43f..1649f28 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v4.5.0
     hooks:
       - id: check-added-large-files
       - id: check-ast
@@ -24,3 +24,10 @@ repos:
         args: [ --preview ]
       - id: ruff-format
         args: [ --preview ]
+  # Spellcheck the code.
+  - repo: https://github.com/codespell-project/codespell
+    rev: v2.2.4
+    hooks:
+    - id: codespell
+      additional_dependencies:
+        - tomli
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index b5bc1b1..0732215 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -6,7 +6,7 @@ In the interest of fostering an open and welcoming environment, we as
 contributors and maintainers pledge to making participation in our project and
 our community a harassment-free experience for everyone, regardless of age, body
 size, disability, ethnicity, sex characteristics, gender identity and expression,
-level of experience, education, socio-economic status, nationality, personal
+level of experience, education, socioeconomic status, nationality, personal
 appearance, race, religion, or sexual identity and orientation.
 
 ## Our Standards
diff --git a/docs/custom_conf.py b/docs/custom_conf.py
index f567b91..fb14823 100644
--- a/docs/custom_conf.py
+++ b/docs/custom_conf.py
@@ -18,33 +18,17 @@ import sys
 
 from docutils import nodes
 
+import inspect
+import sphinx.ext.autodoc
 from sphinx import addnodes
 from sphinx.util.docutils import SphinxDirective
 
-import furo
-import furo.navigation
 
 sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
 
-# Furo patch to get local TOC to show in sidebar (as sphinx-rtd-theme did)
-# See https://github.com/pradyunsg/furo/blob/490527b2aef00b1198770c3389a1979911ee1fcb/src/furo/__init__.py#L115-L128
-
-_old_compute_navigation_tree = furo._compute_navigation_tree
-
-
-def _compute_navigation_tree(context):
-    tree_html = _old_compute_navigation_tree(context)
-    if not tree_html and context.get('toc'):
-        tree_html = furo.navigation.get_navigation_tree(context['toc'])
-    return tree_html
-
-
-furo._compute_navigation_tree = _compute_navigation_tree
-
 # Pull in fix from https://github.com/sphinx-doc/sphinx/pull/11222/files to fix
 # "invalid signature for autoattribute ('ops.pebble::ServiceDict.backoff-delay')"
-import re  # noqa: E402
-import sphinx.ext.autodoc  # noqa: E402
+import re
 
 sphinx.ext.autodoc.py_ext_sig_re = re.compile(
     r"""^ ([\w.]+::)?            # explicit module name
@@ -216,7 +200,9 @@ custom_extensions = [
 # pyspelling, sphinx, sphinx-autobuild, sphinx-copybutton, sphinx-design,
 # sphinx-notfound-page, sphinx-reredirects, sphinx-tabs, sphinxcontrib-jquery,
 # sphinxext-opengraph
-custom_required_modules = []
+custom_required_modules = [
+    'ops-scenario>=7.0.5,<8',
+]
 
 # Add files or directories that should be excluded from processing.
 custom_excludes = [
@@ -315,6 +301,8 @@ nitpick_ignore = [
     # Please keep this list sorted alphabetically.
     ('py:class', '_ChangeDict'),
     ('py:class', '_CheckInfoDict'),
+    ('py:class', '_EntityStatus'),
+    ('py:class', '_Event'),
     ('py:class', '_FileInfoDict'),
     ('py:class', '_NoticeDict'),
     ('py:class', '_ProgressDict'),
@@ -326,6 +314,8 @@ nitpick_ignore = [
     ('py:class', '_TextOrBinaryIO'),
     ('py:class', '_WarningDict'),
     ('py:class', '_Writeable'),
+    ('py:class', 'AnyJson'),
+    ('py:class', 'CharmType'),
     ('py:obj', 'ops._private.harness.CharmType'),
     ('py:class', 'ops._private.harness.CharmType'),
     ('py:class', 'ops.charm._ContainerBaseDict'),
@@ -345,9 +335,34 @@ nitpick_ignore = [
     ('py:class', 'ops.testing._ConfigOption'),
     ('py:class', 'ops.testing.CharmType'),
     ('py:obj', 'ops.testing.CharmType'),
+    ('py:class', 'scenario.state._EntityStatus'),
+    ('py:class', 'scenario.state._Event'),
+    ('py:class', 'scenario.state._max_posargs.<locals>._MaxPositionalArgs'),
 ]
 
 
+# Monkeypatch Sphinx to look for __init__ rather than __new__ for the subclasses
+# of _MaxPositionalArgs.
+_real_get_signature = sphinx.ext.autodoc.ClassDocumenter._get_signature
+
+
+def _custom_get_signature(self):
+    if any(p.__name__ == '_MaxPositionalArgs' for p in self.object.__mro__):
+        signature = inspect.signature(self.object)
+        parameters = []
+        for position, param in enumerate(signature.parameters.values()):
+            if position >= self.object._max_positional_args:
+                parameters.append(param.replace(kind=inspect.Parameter.KEYWORD_ONLY))
+            else:
+                parameters.append(param)
+        signature = signature.replace(parameters=parameters)
+        return None, None, signature
+    return _real_get_signature(self)
+
+
+sphinx.ext.autodoc.ClassDocumenter._get_signature = _custom_get_signature
+
+
 # This is very strongly based on
 # https://github.com/sphinx-doc/sphinx/blob/03b9134ee00e98df4f8b5f6d22f345cdafe31870/sphinx/domains/changeset.py#L46
 # Unfortunately, the built-in class is not easily subclassable without also
diff --git a/docs/harness.rst b/docs/harness.rst
new file mode 100644
index 0000000..a03cded
--- /dev/null
+++ b/docs/harness.rst
@@ -0,0 +1,39 @@
+.. _harness:
+
+Harness (legacy unit testing)
+=============================
+
+.. deprecated:: 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:
+
+- :class:`ops.testing.Harness`, a class to set up the simulated environment,
+  that provides:
+
+  - :meth:`~ops.testing.Harness.add_relation` method, to declare a relation
+    (integration) with another app.
+  - :meth:`~ops.testing.Harness.begin` and :meth:`~ops.testing.Harness.cleanup`
+    methods to start and end the testing lifecycle.
+  - :meth:`~ops.testing.Harness.evaluate_status` method, which aggregates the
+    status of the charm after test interactions.
+  - :attr:`~ops.testing.Harness.model` attribute, which exposes e.g. the
+    :attr:`~ops.Model.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 <https://juju.is/docs/sdk/testing>`_.
+
+
+.. autoclass:: ops.testing.ActionFailed
+   :noindex:
+.. autoclass:: ops.testing.ActionOutput
+.. autoclass:: ops.testing.ExecArgs
+.. autoclass:: ops.testing.ExecResult
+.. autoclass:: ops.testing.Harness
diff --git a/docs/index.rst b/docs/index.rst
index c27e2bb..24f0a7f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,79 +1,72 @@
-
-ops library API reference
-=========================
+API reference
+=============
 
 The `ops` library is a Python framework for writing and testing Juju charms.
 
   See more: `Charm SDK documentation <https://juju.is/docs/sdk>`_
 
-The library provides:
+The library (`available on PyPI`_) provides:
 
-- :ref:`ops_main_entry_point`, used to initialise and run your charm;
 - :ref:`ops_module`, the API to respond to Juju events and manage the application;
+- :ref:`ops_main_entry_point`, used to initialise and run your charm;
 - :ref:`ops_pebble_module`, the Pebble client, a low-level API for Kubernetes containers;
-- :ref:`ops_testing_module`, the framework for unit testing charms in a simulated environment;
+- the APIs for unit testing charms in a simulated environment:
+
+  - :doc:`State-transition testing </state-transition-testing>`. This is the
+    recommended approach (it was previously known as 'Scenario').
+  - :doc:`Harness </harness>`. This is a deprecated framework, and has issues,
+    particularly with resetting the charm state between Juju events.
 
 You can structure your charm however you like, but with the `ops` library, you
 get a framework that promotes consistency and readability by following best
 practices. It also helps you organise your code better by separating different
-aspects of the charm, such as managing the application’s state, handling
+aspects of the charm, such as managing the application's state, handling
 integrations with other services, and making the charm easier to test.
 
+.. _available on PyPI: https://pypi.org/project/ops/
 
 .. toctree::
+   :hidden:
    :maxdepth: 2
-   :caption: Contents:
 
+   self
+   state-transition-testing
+   harness
 
 .. _ops_module:
 
-ops module
-==========
+ops
+---
 
 .. automodule:: ops
    :exclude-members: main
 
-
 .. _ops_main_entry_point:
 
 ops.main entry point
-====================
+--------------------
 
 The main entry point to initialise and run your charm.
 
 .. autofunction:: ops.main
 
-
 legacy main module
 ------------------
 
 .. automodule:: ops.main
    :noindex:
 
-
 .. _ops_pebble_module:
 
-ops.pebble module
-=================
+ops.pebble
+----------
 
 .. automodule:: ops.pebble
 
-
 .. _ops_testing_module:
 
-ops.testing module
-==================
-
-.. autoclass:: ops.testing.ActionFailed
-.. autoclass:: ops.testing.ActionOutput
-.. autoclass:: ops.testing.ExecArgs
-.. autoclass:: ops.testing.ExecResult
-.. autoclass:: ops.testing.Harness
-
 
 Indices
 =======
 
 * :ref:`genindex`
-* :ref:`modindex`
-* :ref:`search`
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 340a580..0c4e6f1 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,5 +1,5 @@
 #
-# This file is autogenerated by pip-compile with Python 3.12
+# This file is autogenerated by pip-compile with Python 3.11
 # by the following command:
 #
 #    pip-compile --extra=docs --output-file=docs/requirements.txt pyproject.toml
@@ -73,6 +73,10 @@ mdurl==0.1.2
     # via markdown-it-py
 myst-parser==4.0.0
     # via ops (pyproject.toml)
+ops==2.16.1
+    # via ops-scenario
+ops-scenario==7.0.5
+    # via ops (pyproject.toml)
 packaging==24.1
     # via sphinx
 pygments==2.18.0
@@ -85,7 +89,9 @@ pyspelling==2.10
 pyyaml==6.0.2
     # via
     #   myst-parser
+    #   ops
     #   ops (pyproject.toml)
+    #   ops-scenario
     #   pyspelling
 requests==2.32.3
     # via
@@ -160,6 +166,8 @@ wcmatch==9.0
 webencodings==0.5.1
     # via html5lib
 websocket-client==1.8.0
-    # via ops (pyproject.toml)
+    # via
+    #   ops
+    #   ops (pyproject.toml)
 websockets==12.0
     # via sphinx-autobuild
diff --git a/ops/__init__.py b/ops/__init__.py
index 0e34ef1..b4c05f3 100644
--- a/ops/__init__.py
+++ b/ops/__init__.py
@@ -14,7 +14,7 @@
 
 """The API to respond to Juju events and manage the application.
 
-This module provides code freatures to your charm, including:
+This module provides core features to your charm, including:
 
 - :class:`~ops.CharmBase`, the base class for charms and :class:`~ops.Object`,
   the base class for charm libraries.
diff --git a/ops/_private/harness.py b/ops/_private/harness.py
index 0933440..2be1e45 100644
--- a/ops/_private/harness.py
+++ b/ops/_private/harness.py
@@ -64,6 +64,17 @@ from ops.jujucontext import _JujuContext
 from ops.model import Container, RelationNotFoundError, StatusName, _NetworkDict
 from ops.pebble import ExecProcess
 
+if typing.TYPE_CHECKING:
+    try:
+        from ops.testing import State  # type: ignore
+    except ImportError:
+        # This is used in the ActionFailed type annotations: it will never be
+        # used in this case, because it's only relevant when ops.testing has
+        # the State class, so we just define it as a dummy class.
+        class State:
+            pass
+
+
 ReadableBuffer = Union[bytes, str, StringIO, BytesIO, BinaryIO]
 _StringOrPath = Union[str, pathlib.PurePosixPath, pathlib.Path]
 _FileKwargs = TypedDict(
@@ -161,18 +172,35 @@ class ActionOutput:
     """The action's results, as set or updated by :meth:`ops.ActionEvent.set_results`."""
 
 
-class ActionFailed(Exception):  # noqa
-    """Raised when :code:`event.fail()` is called during a :meth:`Harness.run_action` call."""
+class ActionFailed(Exception):  # noqa: N818 (name doesn't end with "Error")
+    """Raised when :code:`event.fail()` is called during an action handler."""
 
     message: str
     """Optional details of the failure, as provided by :meth:`ops.ActionEvent.fail`."""
 
     output: ActionOutput
-    """Any logs and results set by the Charm."""
+    """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: typing.Optional['State']
+    """The Juju state after the action has been run.
+
+    When using Harness.run_action, this will be None.
+    """
 
-    def __init__(self, message: str, output: ActionOutput):
+    def __init__(
+        self,
+        message: str,
+        output: typing.Optional[ActionOutput] = None,
+        *,
+        state: typing.Optional['State'] = None,
+    ):
         self.message = message
-        self.output = output
+        self.output = output or ActionOutput([], {})
+        self.state = state
 
     def __str__(self):
         if self.message:
@@ -286,6 +314,13 @@ class Harness(Generic[CharmType]):
             juju_debug_at=self._juju_context.debug_at,
         )
 
+        warnings.warn(
+            'Harness is deprecated; we recommend using state transition testing '
+            "(previously known as 'Scenario') instead",
+            PendingDeprecationWarning,
+            stacklevel=2,
+        )
+
     def _event_context(self, event_name: str):
         """Configures the Harness to behave as if an event hook were running.
 
@@ -380,7 +415,7 @@ class Harness(Generic[CharmType]):
         if self._charm is not None:
             raise RuntimeError('cannot call the begin method on the harness more than once')
 
-        # The Framework adds attributes to class objects for events, etc. As such, we can't re-use
+        # The Framework adds attributes to class objects for events, etc. As such, we can't reuse
         # the original class against multiple Frameworks. So create a locally defined class
         # and register it.
         # TODO: jam 2020-03-16 We are looking to changes this to Instance attributes instead of
diff --git a/ops/charm.py b/ops/charm.py
index edef8db..46ee2db 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -118,10 +118,11 @@ class ActionEvent(EventBase):
     invokes a Juju Action. Callbacks bound to these events may be used
     for responding to the administrator's Juju Action request.
 
-    To read the parameters for the action, see the instance variable :attr:`params`.
-    To respond with the result of the action, call :meth:`set_results`. To add
-    progress messages that are visible as the action is progressing use
-    :meth:`log`.
+    To read the parameters for the action, see the instance variable
+    :attr:`~ops.ActionEvent.params`.
+    To respond with the result of the action, call
+    :meth:`~ops.ActionEvent.set_results`. To add progress messages that are
+    visible as the action is progressing use :meth:`~ops.ActionEvent.log`.
     """
 
     id: str = ''
@@ -240,7 +241,7 @@ class StartEvent(HookEvent):
     """Event triggered immediately after first configuration change.
 
     This event is triggered immediately after the first
-    :class:`ConfigChangedEvent`. Callback methods bound to the event should be
+    :class:`~ops.ConfigChangedEvent`. Callback methods bound to the event should be
     used to ensure that the charm's software is in a running state. Note that
     the charm's software should be configured so as to persist in this state
     through reboots without further intervention on Juju's part.
@@ -298,8 +299,8 @@ class ConfigChangedEvent(HookEvent):
       specifically has changed in the config).
     - Right after the unit starts up for the first time.
       This event notifies the charm of its initial configuration.
-      Typically, this event will fire between an :class:`install <InstallEvent>`
-      and a :class:`start <StartEvent>` during the startup sequence
+      Typically, this event will fire between an :class:`~ops.InstallEvent`
+      and a :class:~`ops.StartEvent` during the startup sequence
       (when a unit is first deployed), but in general it will fire whenever
       the unit is (re)started, for example after pod churn on Kubernetes, on unit
       rescheduling, on unit upgrade or refresh, and so on.
@@ -323,7 +324,7 @@ class UpdateStatusEvent(HookEvent):
     to this event should determine the "health" of the application and
     set the status appropriately.
 
-    The interval between :class:`update-status <UpdateStatusEvent>` events can
+    The interval between :class:`~ops.UpdateStatusEvent` events can
     be configured model-wide, e.g.  ``juju model-config
     update-status-hook-interval=1m``.
     """
@@ -355,7 +356,7 @@ class PreSeriesUpgradeEvent(HookEvent):
     It can be assumed that only after all units on a machine have executed the
     callback method associated with this event, the administrator will initiate
     steps to actually upgrade the series.  After the upgrade has been completed,
-    the :class:`PostSeriesUpgradeEvent` will fire.
+    the :class:`~ops.PostSeriesUpgradeEvent` will fire.
 
     .. jujuremoved:: 4.0
     """
@@ -412,7 +413,7 @@ class LeaderSettingsChangedEvent(HookEvent):
     .. deprecated:: 2.4.0
         This event has been deprecated in favor of using a Peer relation,
         and having the leader set a value in the Application data bag for
-        that peer relation. (See :class:`RelationChangedEvent`.)
+        that peer relation. (See :class:`~ops.RelationChangedEvent`.)
     """
 
 
@@ -550,7 +551,7 @@ class RelationCreatedEvent(RelationEvent):
 
     This is triggered when a new integration with another app is added in Juju. This
     can occur before units for those applications have started. All existing
-    relations will trigger `RelationCreatedEvent` before :class:`StartEvent` is
+    relations will trigger `RelationCreatedEvent` before :class:`~ops.StartEvent` is
     emitted.
     """
 
@@ -582,7 +583,7 @@ class RelationChangedEvent(RelationEvent):
     to see the new information, where ``event`` is the event object passed to
     the callback method bound to this event.
 
-    This event always fires once, after :class:`RelationJoinedEvent`, and
+    This event always fires once, after :class:`~ops.RelationJoinedEvent`, and
     will subsequently fire whenever that remote unit changes its data for
     the relation. Callback methods bound to this event should be the only ones
     that rely on remote relation data. They should not error if the data
@@ -597,7 +598,7 @@ class RelationChangedEvent(RelationEvent):
 class RelationDepartedEvent(RelationEvent):
     """Event triggered when a unit leaves a relation.
 
-    This is the inverse of the :class:`RelationJoinedEvent`, representing when a
+    This is the inverse of the :class:`~ops.RelationJoinedEvent`, representing when a
     unit is leaving the relation (the unit is being removed, the app is being
     removed, the relation is being removed). For remaining units, this event is
     emitted once for each departing unit.  For departing units, this event is
@@ -610,7 +611,7 @@ class RelationDepartedEvent(RelationEvent):
     unit has already shut down.
 
     Once all callback methods bound to this event have been run for such a
-    relation, the unit agent will fire the :class:`RelationBrokenEvent`.
+    relation, the unit agent will fire the :class:`~ops.RelationBrokenEvent`.
     """
 
     unit: model.Unit  # pyright: ignore[reportIncompatibleVariableOverride]
@@ -670,7 +671,7 @@ class RelationBrokenEvent(RelationEvent):
     The event indicates that the current relation is no longer valid, and that
     the charm's software must be configured as though the relation had never
     existed. It will only be called after every callback method bound to
-    :class:`RelationDepartedEvent` has been run. If a callback method
+    :class:`~ops.RelationDepartedEvent` has been run. If a callback method
     bound to this event is being executed, it is guaranteed that no remote units
     are currently known locally.
     """
@@ -741,7 +742,7 @@ class StorageAttachedEvent(StorageEvent):
 
     Callback methods bound to this event allow the charm to run code
     when storage has been added. Such methods will be run before the
-    :class:`InstallEvent` fires, so that the installation routine may
+    :class:`~ops.InstallEvent` fires, so that the installation routine may
     use the storage. The name prefix of this hook will depend on the
     storage key defined in the ``metadata.yaml`` file.
     """
@@ -755,7 +756,7 @@ class StorageDetachingEvent(StorageEvent):
 
     Callback methods bound to this event allow the charm to run code
     before storage is removed. Such methods will be run before storage
-    is detached, and always before the :class:`StopEvent` fires, thereby
+    is detached, and always before the :class:`~ops.StopEvent` fires, thereby
     allowing the charm to gracefully release resources before they are
     removed and before the unit terminates. The name prefix of the
     hook will depend on the storage key defined in the ``metadata.yaml``
@@ -905,7 +906,7 @@ class PebbleCheckFailedEvent(PebbleCheckEvent):
     """Event triggered when a Pebble check exceeds the configured failure threshold.
 
     Note that the check may have started passing by the time this event is
-    emitted (which will mean that a :class:`PebbleCheckRecoveredEvent` will be
+    emitted (which will mean that a :class:`~ops.PebbleCheckRecoveredEvent` will be
     emitted next). If the handler is executing code that should only be done
     if the check is currently failing, check the current status with
     ``event.info.status == ops.pebble.CheckStatus.DOWN``.
@@ -941,7 +942,12 @@ class SecretEvent(HookEvent):
         until :meth:`Secret.get_content()` is called.
         """
         backend = self.framework.model._backend
-        return model.Secret(backend=backend, id=self._id, label=self._label)
+        return model.Secret(
+            backend=backend,
+            id=self._id,
+            label=self._label,
+            _secret_set_cache=self.framework.model._cache._secret_set_cache,
+        )
 
     def snapshot(self) -> Dict[str, Any]:
         """Used by the framework to serialize the event to disk.
@@ -1105,12 +1111,13 @@ class CollectStatusEvent(LifecycleEvent):
 
     The order of priorities is as follows, from highest to lowest:
 
-    * error
     * blocked
     * maintenance
     * waiting
     * active
-    * unknown
+
+    It is an error to call :meth:`add_status` with an instance of
+    :class:`ErrorStatus` or :class:`UnknownStatus`.
 
     If there are multiple statuses with the same priority, the first one added
     wins (and if an event is observed multiple times, the handlers are called
@@ -1151,6 +1158,10 @@ class CollectStatusEvent(LifecycleEvent):
         """
         if not isinstance(status, model.StatusBase):
             raise TypeError(f'status should be a StatusBase, not {type(status).__name__}')
+        if status.name not in model._SETTABLE_STATUS_NAMES:
+            raise model.InvalidStatusError(
+                f'status.name must be in {model._SETTABLE_STATUS_NAMES}, not {status.name!r}'
+            )
         model_ = self.framework.model
         if self.handle.kind == 'collect_app_status':
             if not isinstance(status, model.ActiveStatus):
@@ -1185,59 +1196,59 @@ class CharmEvents(ObjectEvents):
     #       each event class's docstring. Please keep in sync.
 
     install = EventSource(InstallEvent)
-    """Triggered when a charm is installed (see :class:`InstallEvent`)."""
+    """Triggered when a charm is installed (see :class:`~ops.InstallEvent`)."""
 
     start = EventSource(StartEvent)
-    """Triggered immediately after first configuration change (see :class:`StartEvent`)."""
+    """Triggered immediately after first configuration change (see :class:`~ops.StartEvent`)."""
 
     stop = EventSource(StopEvent)
-    """Triggered when a charm is shut down (see :class:`StopEvent`)."""
+    """Triggered when a charm is shut down (see :class:`~ops.StopEvent`)."""
 
     remove = EventSource(RemoveEvent)
-    """Triggered when a unit is about to be terminated (see :class:`RemoveEvent`)."""
+    """Triggered when a unit is about to be terminated (see :class:`~ops.RemoveEvent`)."""
 
     update_status = EventSource(UpdateStatusEvent)
     """Triggered periodically by a status update request from Juju (see
-    :class:`UpdateStatusEvent`).
+    :class:`~ops.UpdateStatusEvent`).
     """
 
     config_changed = EventSource(ConfigChangedEvent)
-    """Triggered when a configuration change occurs (see :class:`ConfigChangedEvent`)."""
+    """Triggered when a configuration change occurs (see :class:`~ops.ConfigChangedEvent`)."""
 
     upgrade_charm = EventSource(UpgradeCharmEvent)
-    """Triggered by request to upgrade the charm (see :class:`UpgradeCharmEvent`)."""
+    """Triggered by request to upgrade the charm (see :class:`~ops.UpgradeCharmEvent`)."""
 
     pre_series_upgrade = EventSource(PreSeriesUpgradeEvent)
-    """Triggered to prepare a unit for series upgrade (see :class:`PreSeriesUpgradeEvent`).
+    """Triggered to prepare a unit for series upgrade (see :class:`~ops.PreSeriesUpgradeEvent`).
 
     .. jujuremoved:: 4.0
     """
 
     post_series_upgrade = EventSource(PostSeriesUpgradeEvent)
-    """Triggered after a series upgrade (see :class:`PostSeriesUpgradeEvent`).
+    """Triggered after a series upgrade (see :class:`~ops.PostSeriesUpgradeEvent`).
 
     .. jujuremoved:: 4.0
     """
 
     leader_elected = EventSource(LeaderElectedEvent)
-    """Triggered when a new leader has been elected (see :class:`LeaderElectedEvent`)."""
+    """Triggered when a new leader has been elected (see :class:`~ops.LeaderElectedEvent`)."""
 
     leader_settings_changed = EventSource(LeaderSettingsChangedEvent)
     """Triggered when leader changes any settings (see
-    :class:`LeaderSettingsChangedEvent`).
+    :class:`~ops.LeaderSettingsChangedEvent`).
 
     .. deprecated:: 2.4.0
     """
 
     collect_metrics = EventSource(CollectMetricsEvent)
-    """Triggered by Juju to collect metrics (see :class:`CollectMetricsEvent`).
+    """Triggered by Juju to collect metrics (see :class:`~ops.CollectMetricsEvent`).
 
     .. jujuremoved:: 4.0
     """
 
     secret_changed = EventSource(SecretChangedEvent)
     """Triggered by Juju on the observer when the secret owner changes its contents (see
-    :class:`SecretChangedEvent`).
+    :class:`~ops.SecretChangedEvent`).
 
     .. jujuadded:: 3.0
         Charm secrets added in Juju 3.0, user secrets added in Juju 3.3
@@ -1245,33 +1256,33 @@ class CharmEvents(ObjectEvents):
 
     secret_expired = EventSource(SecretExpiredEvent)
     """Triggered by Juju on the owner when a secret's expiration time elapses (see
-    :class:`SecretExpiredEvent`).
+    :class:`~ops.SecretExpiredEvent`).
 
     .. jujuadded:: 3.0
     """
 
     secret_rotate = EventSource(SecretRotateEvent)
     """Triggered by Juju on the owner when the secret's rotation policy elapses (see
-    :class:`SecretRotateEvent`).
+    :class:`~ops.SecretRotateEvent`).
 
     .. jujuadded:: 3.0
     """
 
     secret_remove = EventSource(SecretRemoveEvent)
     """Triggered by Juju on the owner when a secret revision can be removed (see
-    :class:`SecretRemoveEvent`).
+    :class:`~ops.SecretRemoveEvent`).
 
     .. jujuadded:: 3.0
     """
 
     collect_app_status = EventSource(CollectStatusEvent)
     """Triggered on the leader at the end of every hook to collect app statuses for evaluation
-    (see :class:`CollectStatusEvent`).
+    (see :class:`~ops.CollectStatusEvent`).
     """
 
     collect_unit_status = EventSource(CollectStatusEvent)
     """Triggered at the end of every hook to collect unit statuses for evaluation
-    (see :class:`CollectStatusEvent`).
+    (see :class:`~ops.CollectStatusEvent`).
     """
 
 
diff --git a/ops/model.py b/ops/model.py
index d03c4b5..14c44b9 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -14,6 +14,7 @@
 
 """Representations of Juju's model, application, unit, and other entities."""
 
+import collections
 import dataclasses
 import datetime
 import enum
@@ -304,7 +305,13 @@ class Model:
             # Canonicalize to "secret:<id>" form for consistency in backend calls.
             id = Secret._canonicalize_id(id, self.uuid)
         content = self._backend.secret_get(id=id, label=label)
-        return Secret(self._backend, id=id, label=label, content=content)
+        return Secret(
+            self._backend,
+            id=id,
+            label=label,
+            content=content,
+            _secret_set_cache=self._cache._secret_set_cache,
+        )
 
     def get_cloud_spec(self) -> 'CloudSpec':
         """Get details of the cloud in which the model is deployed.
@@ -333,6 +340,9 @@ class _ModelCache:
     def __init__(self, meta: 'ops.charm.CharmMeta', backend: '_ModelBackend'):
         self._meta = meta
         self._backend = backend
+        self._secret_set_cache: collections.defaultdict[str, Dict[str, Any]] = (
+            collections.defaultdict(dict)
+        )
         self._weakrefs: _WeakCacheType = weakref.WeakValueDictionary()
 
     @typing.overload
@@ -502,7 +512,13 @@ class Application:
             rotate=rotate,
             owner='application',
         )
-        return Secret(self._backend, id=id, label=label, content=content)
+        return Secret(
+            self._backend,
+            id=id,
+            label=label,
+            content=content,
+            _secret_set_cache=self._cache._secret_set_cache,
+        )
 
 
 def _calculate_expiry(
@@ -685,7 +701,13 @@ class Unit:
             rotate=rotate,
             owner='unit',
         )
-        return Secret(self._backend, id=id, label=label, content=content)
+        return Secret(
+            self._backend,
+            id=id,
+            label=label,
+            content=content,
+            _secret_set_cache=self._cache._secret_set_cache,
+        )
 
     def open_port(
         self, protocol: typing.Literal['tcp', 'udp', 'icmp'], port: Optional[int] = None
@@ -1270,6 +1292,7 @@ class Secret:
         id: Optional[str] = None,
         label: Optional[str] = None,
         content: Optional[Dict[str, str]] = None,
+        _secret_set_cache: Optional['collections.defaultdict[str, Dict[str, Any]]'] = None,
     ):
         if not (id or label):
             raise TypeError('Must provide an id or label, or both')
@@ -1279,6 +1302,9 @@ class Secret:
         self._id = id
         self._label = label
         self._content = content
+        self._secret_set_cache: collections.defaultdict[str, Dict[str, Any]] = (
+            collections.defaultdict(dict) if _secret_set_cache is None else _secret_set_cache
+        )
 
     def __repr__(self):
         fields: List[str] = []
@@ -1483,7 +1509,26 @@ class Secret:
         self._validate_content(content)
         if self._id is None:
             self._id = self.get_info().id
-        self._backend.secret_set(typing.cast(str, self.id), content=content)
+
+        # If `_backend.secret_set` has already been called, this call will
+        # overwrite anything done in the previous call (even if it was in a
+        # different event handler, as long as it was in the same Juju hook
+        # context). We want to provide more predictable behavior, so we cache
+        # the values and provide them in subsequent calls.
+        cached_data = self._secret_set_cache[self._id]
+        expire = _calculate_expiry(cached_data['expire']) if 'expire' in cached_data else None
+        # Don't store the actual secret content in memory, but put a sentinel
+        # there to indicate that the content has been set during this hook.
+        cached_data['content'] = object()
+
+        self._backend.secret_set(
+            typing.cast(str, self.id),
+            content=content,
+            label=cached_data.get('label'),
+            description=cached_data.get('description'),
+            expire=expire,
+            rotate=cached_data.get('rotate'),
+        )
         # We do not need to invalidate the cache here, as the content is the
         # same until `refresh` is used, at which point the cache is invalidated.
 
@@ -1513,12 +1558,33 @@ class Secret:
         """
         if label is None and description is None and expire is None and rotate is None:
             raise TypeError(
-                'Must provide a label, description, expiration time, ' 'or rotation policy'
+                'Must provide a label, description, expiration time, or rotation policy'
             )
         if self._id is None:
             self._id = self.get_info().id
+
+        # If `_backend.secret_set` has already been called, this call will
+        # overwrite anything done in the previous call (even if it was in a
+        # different event handler, as long as it was in the same Juju hook
+        # context). We want to provide more predictable behavior, so we cache
+        # the values and provide them in subsequent calls.
+        cached_data = self._secret_set_cache[self._id]
+        label = cached_data.get('label') if label is None else label
+        cached_data['label'] = label
+        description = cached_data.get('description') if description is None else description
+        cached_data['description'] = description
+        expire = cached_data.get('expire') if expire is None else expire
+        cached_data['expire'] = expire
+        rotate = cached_data.get('rotate') if rotate is None else rotate
+        cached_data['rotate'] = rotate
+        # Get the previous content from the unit agent's cache.
+        content = (
+            self._backend.secret_get(id=self._id, peek=True) if 'content' in cached_data else None
+        )
+
         self._backend.secret_set(
             typing.cast(str, self.id),
+            content=content,
             label=label,
             description=description,
             expire=_calculate_expiry(expire),
@@ -1982,7 +2048,7 @@ class UnknownStatus(StatusBase):
     charm has not called status-set yet.
 
     This status is read-only; trying to set unit or application status to
-    ``UnknownStatus`` will raise :class:`InvalidStatusError`.
+    ``UnknownStatus`` will raise :class:`~ops.InvalidStatusError`.
     """
 
     name = 'unknown'
@@ -2002,7 +2068,7 @@ class ErrorStatus(StatusBase):
     human intervention in order to operate correctly).
 
     This status is read-only; trying to set unit or application status to
-    ``ErrorStatus`` will raise :class:`InvalidStatusError`.
+    ``ErrorStatus`` will raise :class:`~ops.InvalidStatusError`.
     """
 
     name = 'error'
@@ -3133,7 +3199,7 @@ def _format_action_result_dict(
     parent_key: Optional[str] = None,
     output: Optional[Dict[str, str]] = None,
 ) -> Dict[str, str]:
-    """Turn a nested dictionary into a flattened dictionary, using '.' as a key seperator.
+    """Turn a nested dictionary into a flattened dictionary, using '.' as a key separator.
 
     This is used to allow nested dictionaries to be translated into the dotted format required by
     the Juju `action-set` hook tool in order to set nested data on an action.
@@ -3672,7 +3738,8 @@ class _ModelBackend:
         if expire is not None:
             args.extend(['--expire', expire.isoformat()])
         if rotate is not None:
-            args += ['--rotate', rotate.value]
+            args.extend(['--rotate', rotate.value])
+
         with tempfile.TemporaryDirectory() as tmp:
             # The content is None or has already been validated with Secret._validate_content
             for k, v in (content or {}).items():
@@ -3699,9 +3766,9 @@ class _ModelBackend:
         if expire is not None:
             args.extend(['--expire', expire.isoformat()])
         if rotate is not None:
-            args += ['--rotate', rotate.value]
+            args.extend(['--rotate', rotate.value])
         if owner is not None:
-            args += ['--owner', owner]
+            args.extend(['--owner', owner])
         with tempfile.TemporaryDirectory() as tmp:
             # The content has already been validated with Secret._validate_content
             for k, v in content.items():
@@ -3973,7 +4040,7 @@ class CloudSpec:
     """A list of CA certificates."""
 
     skip_tls_verify: bool = False
-    """Whether to skip TLS verfication."""
+    """Whether to skip TLS verification."""
 
     is_controller_cloud: bool = False
     """If this is the cloud used by the controller, defaults to False."""
diff --git a/pyproject.toml b/pyproject.toml
index 4bfa2d1..d4763ba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,8 +35,15 @@ docs = [
     "sphinx-notfound-page",
     "sphinx-tabs",
     "sphinxcontrib-jquery",
-    "sphinxext-opengraph"
+    "sphinxext-opengraph",
+    "ops-scenario>=7.0.5,<8",
 ]
+testing = [
+    "ops-scenario>=7.0.5,<8",
+]
+# Empty for now, because Harness is bundled with the base install, but allow
+# specifying the extra to ease transition later.
+harness = []
 
 [project.urls]
 "Homepage" = "https://juju.is/docs/sdk"
@@ -224,3 +231,7 @@ reportUnnecessaryComparison = false
 reportUnnecessaryTypeIgnoreComment = "error"
 disableBytesTypePromotions = false
 stubPath = ""
+
+[tool.codespell]
+skip = './docs/_build,.venv,venv,build'
+quiet-level = 3
diff --git a/tox.ini b/tox.ini
index cf097a1..fd6cf3d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -68,9 +68,11 @@ commands =
 description = Check code against coding style standards
 deps =
     ruff==0.4.5
+    codespell==2.3.0
 commands =
     ruff check --preview
     ruff format --preview --check
+    codespell
 
 [testenv:static]
 description = Run static type checker
@@ -80,6 +82,7 @@ deps =
     pyright==1.1.377
     pytest~=7.2
     typing_extensions~=4.2
+    ops-scenario>=7.0.5,<8.0
 commands =
     pyright {posargs}
 
@@ -94,6 +97,7 @@ deps =
     pytest~=7.2
     pytest-xdist~=3.6
     typing_extensions~=4.2
+    ops-scenario>=7.0.5,<8.0
 commands =
     pytest -n auto --ignore={[vars]tst_path}smoke -v --tb native {posargs}
 


Test patch:
diff --git a/.github/workflows/db-charm-tests.yaml b/.github/workflows/db-charm-tests.yaml
index 0ddfa23..4d938f1 100644
--- a/.github/workflows/db-charm-tests.yaml
+++ b/.github/workflows/db-charm-tests.yaml
@@ -15,15 +15,13 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/postgresql-operator
-            commit: 01cfd3e268ecd5024ded6bb6aede43ff85417b78  # 2024-09-11T16:07:20Z
+            commit: 7219d1ed1a118abbfca23aeacf3b7bf67e81a937  # rev480 rev479 2024-09-23T16:12:27Z
           - charm-repo: canonical/postgresql-k8s-operator
-            commit: f203e4d9d3ab9856b5dc0786217fce8428384e90  # 2024-09-16T12:00:58Z
-          # Waiting for an upstream PR:
-          # https://github.com/canonical/mysql-operator/pull/519
-          #- charm-repo: canonical/mysql-operator
-          #  commit: 4ff2dc867a1586d43a93d0017cc648d591fc9886  # 2024-09-13T15:38:12Z
+            commit: 5c2ae046546a7d163b9d040d6a12d1abc0ca1f70  # rev398 rev397 2024-09-24T19:23:37Z
+          - charm-repo: canonical/mysql-operator
+            commit: 9fe60aa9f0a00636e06902d0849d36e1e1525df2  # 2024-09-22
           - charm-repo: canonical/mysql-k8s-operator
-            commit: 2009919583eddad22656b9b4ebe95adbcd518c0f  # rev199 rev198 2024-09-16T18:14:16Z
+            commit: 61db833ba8318773de565d7d9b56031baf3e75f7  # 2024-09-19T11:48:20Z
     steps:
       - name: Checkout the ${{ matrix.charm-repo }} repository
         uses: actions/checkout@v4
diff --git a/.github/workflows/observability-charm-tests.yaml b/.github/workflows/observability-charm-tests.yaml
index af1879a..8acf27a 100644
--- a/.github/workflows/observability-charm-tests.yaml
+++ b/.github/workflows/observability-charm-tests.yaml
@@ -16,7 +16,7 @@ jobs:
       matrix:
         include:
           - charm-repo: canonical/alertmanager-k8s-operator
-            commit: 4b2f567c43bc67e9d1fe24f018ba186dcd0396ed  # 2024-08-28T11:32:28Z
+            commit: 7518ec8343941ca91248614fd8f2d50fdd9e068c  # rev135 2024-09-06T12:10:02Z
           # Waiting for an upstream PR:
           # https://github.com/canonical/prometheus-k8s-operator/pull/639
           #- charm-repo: canonical/prometheus-k8s-operator
diff --git a/docs/state-transition-testing.rst b/docs/state-transition-testing.rst
new file mode 100644
index 0000000..1568480
--- /dev/null
+++ b/docs/state-transition-testing.rst
@@ -0,0 +1,126 @@
+.. _state-transition-tests:
+
+Unit testing (was: Scenario)
+============================
+
+Install ops with the ``testing`` extra to use this API; for example:
+``pip install ops[testing]``
+
+State-transition tests, previously known as 'Scenario', expect you to define the
+Juju state all at once, define the Juju context against which to test the charm,
+and fire a single event on the charm to execute its logic. The tests can then
+assert that the Juju state has changed as expected.
+
+A very simple test, where the charm has no config, no integrations, the unit
+is the leader, and has a `start` handler that sets the status to active might
+look like this:
+
+.. code-block:: python
+
+    from ops import testing
+
+    def test_base():
+        ctx = testing.Context(MyCharm)
+        state = testing.State(leader=True)
+        out = ctx.run(ctx.on.start(), state)
+        assert out.unit_status == testing.ActiveStatus()
+
+These 'state-transition' tests give charm authors a way to test
+how the state changes in reaction to events. They are not
+necessarily tests of individual methods or functions;
+they are testing the 'contract' of the charm: given
+a certain state, when a certain event happens, the charm should transition to
+another state. Unlike integration tests, they do not test using a real Juju
+controller and model, and focus on a single Juju unit.
+For simplicity, we refer to them as 'unit' tests.
+
+Writing these tests should nudge you into thinking of a charm as a black-box
+'input to output' function. The inputs are:
+
+- Event: why am I, the charm, being executed
+- State: am I the leader? what is my integration data? what is my config?
+- Context: what integrations can I have? what containers can I have?
+
+The output is another `State`: the state after
+the charm has interacted with the mocked Juju model.
+The output state is the same type of data structure as the input state.
+
+.. image:: https://raw.githubusercontent.com/canonical/ops-scenario/main/resources/state-transition-model.png
+   :alt: Transition diagram, with the input state and event on the left, the context including the charm in the centre, and the state out on the right
+
+Writing unit tests for a charm, then, means verifying that:
+
+- the output state (as compared with the input state) is as expected
+- the charm does not raise uncaught exceptions while handling the event
+
+A test consists of three broad steps:
+
+- **Arrange**:
+    - declare the context
+    - declare the input state
+- **Act**:
+    - run an event (ie. obtain the output state, given the input state and the event)
+- **Assert**:
+    - verify that the output state is what you expect it to be
+    - verify that the charm has seen a certain sequence of statuses, events, and `juju-log` calls
+
+.. note::
+    Unit testing is only one aspect of a comprehensive testing strategy. For more
+    on testing charms, see `Charm SDK | Testing <https://juju.is/docs/sdk/testing>`_.
+
+
+..
+   _The list here is manually maintained, because the `automodule` directive
+   expects to document names defined in the module, and not imported ones, and
+   we're doing the opposite of that - and we also want to use the 'ops.testing'
+   namespace, not expose the 'ops._private.harness' and 'scenario' ones.
+   Ideally, someone will figure out a nicer way to do this that doesn't require
+   keeping this list in sync (see test/test_infra.py for a check that we are ok).
+
+.. autoclass:: ops.testing.ActionFailed
+.. autoclass:: ops.testing.ActiveStatus
+.. autoclass:: ops.testing.Address
+.. autoclass:: ops.testing.BindAddress
+.. autoclass:: ops.testing.BlockedStatus
+.. autoclass:: ops.testing.CharmEvents
+.. autoclass:: ops.testing.CheckInfo
+.. autoclass:: ops.testing.CloudCredential
+.. autoclass:: ops.testing.CloudSpec
+.. autoclass:: ops.testing.Container
+.. autoclass:: ops.testing.Context
+   :special-members: __call__
+.. autoclass:: ops.testing.DeferredEvent
+.. autoclass:: ops.testing.ErrorStatus
+.. autoclass:: ops.testing.Exec
+.. autoclass:: ops.testing.ICMPPort
+.. autoclass:: ops.testing.JujuLogLine
+.. autoclass:: ops.testing.MaintenanceStatus
+.. autoclass:: ops.testing.Manager
+.. autoclass:: ops.testing.Model
+.. autoclass:: ops.testing.Mount
+.. autoclass:: ops.testing.Network
+.. autoclass:: ops.testing.Notice
+.. autoclass:: ops.testing.PeerRelation
+.. autoclass:: ops.testing.Port
+.. autoclass:: ops.testing.Relation
+.. autoclass:: ops.testing.RelationBase
+.. autoclass:: ops.testing.Resource
+.. autoclass:: ops.testing.Secret
+.. autoclass:: ops.testing.State
+.. autoclass:: ops.testing.Storage
+.. autoclass:: ops.testing.StoredState
+.. autoclass:: ops.testing.SubordinateRelation
+.. autoclass:: ops.testing.TCPPort
+.. autoclass:: ops.testing.UDPPort
+.. autoclass:: ops.testing.UnknownStatus
+.. autoclass:: ops.testing.WaitingStatus
+.. autoclass:: ops.testing.errors.ContextSetupError
+.. autoclass:: ops.testing.errors.AlreadyEmittedError
+.. autoclass:: ops.testing.errors.ScenarioRuntimeError
+.. autoclass:: ops.testing.errors.UncaughtCharmError
+.. autoclass:: ops.testing.errors.InconsistentScenarioError
+.. autoclass:: ops.testing.errors.StateValidationError
+.. autoclass:: ops.testing.errors.MetadataNotFoundError
+.. autoclass:: ops.testing.errors.ActionMissingFromContextError
+.. autoclass:: ops.testing.errors.NoObserverError
+.. autoclass:: ops.testing.errors.BadOwnerPath
diff --git a/ops/testing.py b/ops/testing.py
index e93ba7b..fc916ef 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -32,6 +32,11 @@ The module includes:
     on testing charms, see `Charm SDK | Testing <https://juju.is/docs/sdk/testing>`_.
 """
 
+# ruff: noqa: F401 (unused import)
+# pyright: reportUnusedImport=false
+
+import importlib.metadata
+
 from ._private.harness import (
     ActionFailed,
     ActionOutput,
@@ -56,27 +61,140 @@ from ._private.harness import (
     storage,
 )
 
-# The Harness testing framework.
-_ = ActionFailed
-_ = ActionOutput
-_ = AppUnitOrName
-_ = CharmType
-_ = ExecArgs
-_ = ExecHandler
-_ = ExecResult
-_ = Harness
-_ = ReadableBuffer
-_ = YAMLStringOrFile
+# The Harness unit testing framework.
+__all__ = [
+    'ActionFailed',
+    'ActionOutput',
+    'AppUnitOrName',
+    'CharmType',
+    'ExecArgs',
+    'ExecHandler',
+    'ExecResult',
+    'Harness',
+    'ReadableBuffer',
+    'YAMLStringOrFile',
+]
+
+# If the 'ops.testing' optional extra is installed, make those
+# names available in this namespace.
+try:
+    _version = importlib.metadata.version('ops-scenario')
+except importlib.metadata.PackageNotFoundError:
+    pass
+else:
+    if _version and int(_version.split('.', 1)[0]) >= 7:
+        from scenario import (
+            ActiveStatus,
+            Address,
+            AnyJson,
+            BindAddress,
+            BlockedStatus,
+            CheckInfo,
+            CloudCredential,
+            CloudSpec,
+            Container,
+            Context,
+            DeferredEvent,
+            ErrorStatus,
+            Exec,
+            ICMPPort,
+            JujuLogLine,
+            MaintenanceStatus,
+            Manager,
+            Model,
+            Mount,
+            Network,
+            Notice,
+            PeerRelation,
+            Port,
+            RawDataBagContents,
+            RawSecretRevisionContents,
+            Relation,
+            RelationBase,
+            Resource,
+            Secret,
+            State,
+            Storage,
+            StoredState,
+            SubordinateRelation,
+            TCPPort,
+            UDPPort,
+            UnitID,
+            UnknownStatus,
+            WaitingStatus,
+            errors,
+        )
+
+        # This can be imported in the group above after Scenario exposes it at the top level.
+        # https://github.com/canonical/ops-scenario/pull/200
+        from scenario.context import CharmEvents
+
+        # The Scenario unit testing framework.
+        __all__.extend([
+            'ActiveStatus',
+            'Address',
+            'AnyJson',
+            'BindAddress',
+            'BlockedStatus',
+            'CharmEvents',
+            'CheckInfo',
+            'CloudCredential',
+            'CloudSpec',
+            'Container',
+            'Context',
+            'DeferredEvent',
+            'ErrorStatus',
+            'Exec',
+            'ICMPPort',
+            'JujuLogLine',
+            'MaintenanceStatus',
+            'Manager',
+            'Model',
+            'Mount',
+            'Network',
+            'Notice',
+            'PeerRelation',
+            'Port',
+            'RawDataBagContents',
+            'RawSecretRevisionContents',
+            'Relation',
+            'RelationBase',
+            'Resource',
+            'Secret',
+            'State',
+            'Storage',
+            'StoredState',
+            'SubordinateRelation',
+            'TCPPort',
+            'UDPPort',
+            'UnitID',
+            'UnknownStatus',
+            'WaitingStatus',
+            'errors',
+        ])
+
+        # Until Scenario uses the ops._private.harness.ActionFailed, we need to
+        # monkeypatch it in, so that the ops.testing.ActionFailed object is the
+        # one that we expect, even if people are mixing Harness and Scenario.
+        # https://github.com/canonical/ops-scenario/issues/201
+        import scenario.context as _context
+        import scenario.runtime as _runtime
+
+        _context.ActionFailed = ActionFailed  # type: ignore[reportPrivateImportUsage]
+        _runtime.ActionFailed = ActionFailed  # type: ignore[reportPrivateImportUsage]
 
 # Names exposed for backwards compatibility
-_ = CharmBase
-_ = CharmMeta
-_ = Container
-_ = ExecProcess
-_ = RelationNotFoundError
-_ = RelationRole
-_ = charm
-_ = framework
-_ = model
-_ = pebble
-_ = storage
+_compatibility_names = [
+    'CharmBase',
+    'CharmMeta',
+    'Container',  # If Scenario has been installed, then this will be scenario.Container.
+    'ExecProcess',
+    'RelationNotFoundError',
+    'RelationRole',
+    'charm',
+    'framework',
+    'model',
+    'pebble',
+    'storage',
+]
+__all__.extend(_compatibility_names)  # type: ignore[reportUnsupportedDunderAll]
diff --git a/test/test_charm.py b/test/test_charm.py
index 0939fbb..ecb9cb5 100644
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -797,6 +797,32 @@ def test_secret_events(request: pytest.FixtureRequest):
     ]
 
 
+def test_secret_event_caches_secret_set(request: pytest.FixtureRequest, fake_script: FakeScript):
+    class MyCharm(ops.CharmBase):
+        def __init__(self, framework: ops.Framework):
+            super().__init__(framework)
+            self.secrets: typing.List[ops.Secret] = []
+            self.framework.observe(self.on.secret_changed, self.on_secret_changed)
+
+        def on_secret_changed(self, event: ops.SecretChangedEvent):
+            event.secret.set_info(description='desc')
+            event.secret.set_content({'key': 'value'})
+            self.secrets.append(event.secret)
+
+    fake_script.write('secret-get', """echo '{"key": "value"}'""")
+    fake_script.write('secret-set', 'exit 0')
+
+    framework = create_framework(request)
+    charm = MyCharm(framework)
+
+    charm.on.secret_changed.emit('secret:changed', None)
+    charm.on.secret_changed.emit('secret:changed', None)
+    cache = charm.secrets[0]._secret_set_cache
+    assert cache is charm.secrets[1]._secret_set_cache
+    assert charm.secrets[0]._secret_set_cache['secret:changed']['description'] == 'desc'
+    assert 'content' in cache['secret:changed']
+
+
 def test_collect_app_status_leader(request: pytest.FixtureRequest, fake_script: FakeScript):
     class MyCharm(ops.CharmBase):
         def __init__(self, framework: ops.Framework):
@@ -959,7 +985,6 @@ def test_add_status_type_error(request: pytest.FixtureRequest, fake_script: Fake
         (['waiting', 'blocked'], 'blocked'),
         (['waiting', 'maintenance'], 'maintenance'),
         (['active', 'waiting'], 'waiting'),
-        (['active', 'unknown'], 'active'),
     ],
 )
 def test_collect_status_priority_valid(
@@ -994,6 +1019,7 @@ def test_collect_status_priority_valid(
     [
         ['blocked', 'error'],
         ['unknown'],
+        ['active', 'unknown'],
     ],
 )
 def test_collect_status_priority_invalid(
@@ -1015,7 +1041,7 @@ def test_collect_status_priority_invalid(
 
     framework = create_framework(request)
     charm = MyCharm(framework, statuses=statuses)
-    with pytest.raises(ModelError):
+    with pytest.raises(ops.InvalidStatusError):
         ops.charm._evaluate_status(charm)
 
 
diff --git a/test/test_infra.py b/test/test_infra.py
index 445bbce..47eaec3 100644
--- a/test/test_infra.py
+++ b/test/test_infra.py
@@ -16,9 +16,12 @@ import os
 import pathlib
 import subprocess
 import sys
+import typing
 
 import pytest
 
+import ops.testing
+
 
 @pytest.mark.parametrize(
     'mod_name',
@@ -45,3 +48,40 @@ def test_import(mod_name: str, tmp_path: pathlib.Path):
 
     proc = subprocess.run([sys.executable, testfile], env=environ)
     assert proc.returncode == 0
+
+
+@pytest.mark.skipif(
+    not hasattr(ops.testing, 'Context'), reason='requires optional ops[testing] install'
+)
+def test_ops_testing_doc():
+    """Ensure that ops.testing's documentation includes all the expected names."""
+    prefix = '.. autoclass:: ops.testing.'
+    # We don't document the type aliases.
+    expected_names = set(
+        name
+        for name in ops.testing.__all__
+        if name != 'errors'
+        and name not in ops.testing._compatibility_names
+        and getattr(ops.testing, name).__class__.__module__ != 'typing'
+    )
+    expected_names.update(
+        f'errors.{name}' for name in dir(ops.testing.errors) if not name.startswith('_')
+    )
+    # ops.testing.UnitID is `int` - we don't document it, but it's hard to fit
+    # into the above logic, so we just exclude it here.
+    expected_names.discard('UnitID')
+    # ops.testing.Container is a documented class when Scenario is installed,
+    # but exported for compatibility when not, so we do want to have it present
+    # even though the above compatibility_names logic would exclude it.
+    expected_names.add('Container')
+
+    found_names: typing.Set[str] = set()
+    for test_doc in ('docs/harness.rst', 'docs/state-transition-testing.rst'):
+        with open(test_doc) as testing_doc:
+            found_names.update({
+                line.split(prefix, 1)[1].strip()
+                for line in testing_doc
+                if line.strip().startswith(prefix)
+            })
+
+    assert expected_names == found_names
diff --git a/test/test_model.py b/test/test_model.py
index 015dbc0..7eb2ffa 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -3831,6 +3831,67 @@ class TestSecretClass:
         with pytest.raises(TypeError):
             secret.set_info()  # no args provided
 
+    def test_set_content_then_info(self, model: ops.Model, fake_script: FakeScript):
+        fake_script.write('secret-set', """exit 0""")
+        fake_script.write('secret-get', """echo '{"foo": "bar"}'""")
+
+        secret = self.make_secret(model, id='q')
+        secret.set_content({'foo': 'bar'})
+        description = 'desc'
+        secret.set_info(description=description)
+
+        calls = fake_script.calls(clear=True)
+        assert calls[0][:-1] == ['secret-set', f'secret://{model._backend.model_uuid}/q']
+        assert calls[0][-1].startswith('foo#file=') and calls[0][-1].endswith('/foo')
+        assert calls[1] == [
+            'secret-get',
+            f'secret://{model._backend.model_uuid}/q',
+            '--peek',
+            '--format=json',
+        ]
+        assert calls[2][:-1] == [
+            'secret-set',
+            f'secret://{model._backend.model_uuid}/q',
+            '--description',
+            description,
+        ]
+        assert calls[2][-1].startswith('foo#file=') and calls[0][-1].endswith('/foo')
+
+    def test_set_info_then_content(self, model: ops.Model, fake_script: FakeScript):
+        fake_script.write('secret-set', """exit 0""")
+
+        secret = self.make_secret(model, id='q')
+        description = 'desc'
+        secret.set_info(description=description)
+        secret.set_content({'foo': 'bar'})
+
+        calls = fake_script.calls(clear=True)
+        assert calls[0] == [
+            'secret-set',
+            f'secret://{model._backend.model_uuid}/q',
+            '--description',
+            description,
+        ]
+        assert calls[1][:-1] == [
+            'secret-set',
+            f'secret://{model._backend.model_uuid}/q',
+            '--description',
+            description,
+        ]
+        assert calls[1][-1].startswith('foo#file=') and calls[1][-1].endswith('/foo')
+
+    def test_set_content_aggregates(self, model: ops.Model, fake_script: FakeScript):
+        fake_script.write('secret-set', """exit 0""")
+
+        secret = self.make_secret(model, id='q')
+        secret.set_content({'foo': 'bar'})
+        secret.set_content({'baz': 'qux', 'foo': 'newbar'})
+
+        calls = fake_script.calls(clear=True)
+        assert calls[0][:-1] == ['secret-set', f'secret://{model._backend.model_uuid}/q']
+        assert calls[0][:-1] == ['secret-set', f'secret://{model._backend.model_uuid}/q']
+        assert fake_script.secrets() == {'foo': 'newbar', 'baz': 'qux'}
+
     def test_grant(self, model: ops.Model, fake_script: FakeScript):
         fake_script.write('relation-list', """echo '[]'""")
         fake_script.write('secret-grant', """exit 0""")
diff --git a/test/test_testing.py b/test/test_testing.py
index 965ebc8..8321427 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -7025,3 +7025,12 @@ class TestCloudSpec:
         harness.begin()
         with pytest.raises(ops.ModelError):
             harness.model.get_cloud_spec()
+
+
+@pytest.mark.skipif(
+    not hasattr(ops.testing, 'Context'), reason='requires optional ops[testing] install'
+)
+def test_scenario_available():
+    ctx = ops.testing.Context(ops.CharmBase, meta={'name': 'foo'})
+    state = ctx.run(ctx.on.start(), ops.testing.State())
+    assert isinstance(state.unit_status, ops.testing.UnknownStatus)