From f9d17b0d1b4b10f365c833c5ab5cbe2daecec881 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 10 Oct 2024 02:33:24 +1300 Subject: [PATCH] feat: use the newer ops.testing classes for the charm unit tests (#1933) Updates the `simple`, `machine`, and `kubernetes` profiles to use the ops.testing state transition classes (previously: Scenario) rather than the legacy Harness API. The content of what each set of tests is testing remains unchanged, it's just using the new API to do the tests. `ops[testing]` is installed via the tox dependencies (but should likely be in an optional dependencies group in the future, once CC005 is finalised). I've included it in the `static` dependencies as well, because as the charm gains additional tests those should also be statically checked and that will require the `ops[testing]` classes. --------- Co-authored-by: Dima Tisnek --- .../tests/unit/test_charm.py.j2 | 25 ++-- .../templates/init-kubernetes/tox.ini.j2 | 2 + .../init-machine/tests/unit/test_charm.py.j2 | 22 ++-- charmcraft/templates/init-machine/tox.ini.j2 | 2 + .../templates/init-simple/src/charm.py.j2 | 4 +- .../init-simple/tests/unit/test_charm.py.j2 | 120 +++++++++++------- charmcraft/templates/init-simple/tox.ini.j2 | 2 + 7 files changed, 99 insertions(+), 78 deletions(-) diff --git a/charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2 b/charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2 index 6d43b2357..a67e465b3 100644 --- a/charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2 +++ b/charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2 @@ -3,22 +3,19 @@ # # Learn more about testing at: https://juju.is/docs/sdk/testing -import ops -import ops.testing -import pytest +from ops import testing + from charm import {{ class_name }} -@pytest.fixture -def harness(): - harness = ops.testing.Harness({{ class_name }}) - harness.begin() - yield harness - harness.cleanup() +def test_pebble_ready(): + # Arrange: + ctx = testing.Context({{ class_name }}) + container = testing.Container("some-container", can_connect=True) + state_in = testing.State(containers={container}) + # Act: + state_out = ctx.run(ctx.on.pebble_ready(container), state_in) -def test_pebble_ready(harness: ops.testing.Harness[{{ class_name }}]): - # Simulate the container coming up and emission of pebble-ready event - harness.container_pebble_ready("some-container") - # Ensure we set an ActiveStatus with no message - assert harness.model.unit.status == ops.ActiveStatus() + # Assert: + assert state_out.unit_status == testing.ActiveStatus() diff --git a/charmcraft/templates/init-kubernetes/tox.ini.j2 b/charmcraft/templates/init-kubernetes/tox.ini.j2 index 1b06be988..f30c25577 100644 --- a/charmcraft/templates/init-kubernetes/tox.ini.j2 +++ b/charmcraft/templates/init-kubernetes/tox.ini.j2 @@ -49,6 +49,7 @@ description = Run unit tests deps = pytest coverage[toml] + ops[testing] -r {tox_root}/requirements.txt commands = coverage run --source={[vars]src_path} \ @@ -64,6 +65,7 @@ commands = description = Run static type checks deps = pyright + ops[testing] -r {tox_root}/requirements.txt commands = pyright {posargs} diff --git a/charmcraft/templates/init-machine/tests/unit/test_charm.py.j2 b/charmcraft/templates/init-machine/tests/unit/test_charm.py.j2 index 0120cfbab..bd7968cf6 100644 --- a/charmcraft/templates/init-machine/tests/unit/test_charm.py.j2 +++ b/charmcraft/templates/init-machine/tests/unit/test_charm.py.j2 @@ -5,19 +5,15 @@ import unittest -import ops -import ops.testing -from charm import {{ class_name }} - +from ops import testing -class TestCharm(unittest.TestCase): - def setUp(self): - self.harness = ops.testing.Harness({{ class_name }}) - self.addCleanup(self.harness.cleanup) +from charm import {{ class_name }} - def test_start(self): - # Simulate the charm starting - self.harness.begin_with_initial_hooks() - # Ensure we set an ActiveStatus with no message - self.assertEqual(self.harness.model.unit.status, ops.ActiveStatus()) +def test_start(): + # Arrange: + ctx = testing.Context({{ class_name }}) + # Act: + state_out = ctx.run(ctx.on.start(), testing.State()) + # Assert: + assert state_out.unit_status == testing.ActiveStatus() diff --git a/charmcraft/templates/init-machine/tox.ini.j2 b/charmcraft/templates/init-machine/tox.ini.j2 index 1b06be988..f30c25577 100644 --- a/charmcraft/templates/init-machine/tox.ini.j2 +++ b/charmcraft/templates/init-machine/tox.ini.j2 @@ -49,6 +49,7 @@ description = Run unit tests deps = pytest coverage[toml] + ops[testing] -r {tox_root}/requirements.txt commands = coverage run --source={[vars]src_path} \ @@ -64,6 +65,7 @@ commands = description = Run static type checks deps = pyright + ops[testing] -r {tox_root}/requirements.txt commands = pyright {posargs} diff --git a/charmcraft/templates/init-simple/src/charm.py.j2 b/charmcraft/templates/init-simple/src/charm.py.j2 index 63012b32f..334f5cb87 100644 --- a/charmcraft/templates/init-simple/src/charm.py.j2 +++ b/charmcraft/templates/init-simple/src/charm.py.j2 @@ -75,10 +75,10 @@ class {{ class_name }}(ops.CharmBase): else: # We were unable to connect to the Pebble API, so we defer this event event.defer() - self.unit.status = ops.WaitingStatus("waiting for Pebble API") + self.unit.status = ops.MaintenanceStatus("waiting for Pebble API") else: # In this case, the config option is bad, so block the charm and notify the operator. - self.unit.status = ops.BlockedStatus("invalid log level: '{log_level}'") + self.unit.status = ops.BlockedStatus(f"invalid log level: '{log_level}'") @property def _pebble_layer(self) -> ops.pebble.LayerDict: diff --git a/charmcraft/templates/init-simple/tests/unit/test_charm.py.j2 b/charmcraft/templates/init-simple/tests/unit/test_charm.py.j2 index 67ac35c9d..88dd0dd00 100644 --- a/charmcraft/templates/init-simple/tests/unit/test_charm.py.j2 +++ b/charmcraft/templates/init-simple/tests/unit/test_charm.py.j2 @@ -4,21 +4,23 @@ # Learn more about testing at: https://juju.is/docs/sdk/testing import ops -import ops.testing -import pytest +import ops.pebble +from ops import testing + from charm import {{ class_name }} -@pytest.fixture -def harness(): - harness = ops.testing.Harness({{ class_name }}) - harness.begin() - yield harness - harness.cleanup() +def test_httpbin_pebble_ready(): + # Arrange: + ctx = testing.Context({{ class_name }}) + container = testing.Container("httpbin", can_connect=True) + state_in = testing.State(containers={container}) + # Act: + state_out = ctx.run(ctx.on.pebble_ready(container), state_in) -def test_httpbin_pebble_ready(harness: ops.testing.Harness[{{ class_name }}]): - # Expected plan after Pebble ready with default config + # Assert: + updated_plan = state_out.get_container(container.name).plan expected_plan = { "services": { "httpbin": { @@ -30,43 +32,63 @@ def test_httpbin_pebble_ready(harness: ops.testing.Harness[{{ class_name }}]): } }, } - # Simulate the container coming up and emission of pebble-ready event - harness.container_pebble_ready("httpbin") - # Get the plan now we've run PebbleReady - updated_plan = harness.get_container_pebble_plan("httpbin").to_dict() - # Check we've got the plan we expected assert expected_plan == updated_plan - # Check the service was started - service = harness.model.unit.get_container("httpbin").get_service("httpbin") - assert service.is_running() - # Ensure we set an ActiveStatus with no message - assert harness.model.unit.status == ops.ActiveStatus() - - -def test_config_changed_valid_can_connect(harness: ops.testing.Harness[{{ class_name }}]): - # Ensure the simulated Pebble API is reachable - harness.set_can_connect("httpbin", True) - # Trigger a config-changed event with an updated value - harness.update_config({"log-level": "debug"}) - # Get the plan now we've run PebbleReady - updated_plan = harness.get_container_pebble_plan("httpbin").to_dict() - updated_env = updated_plan["services"]["httpbin"]["environment"] - # Check the config change was effective - assert updated_env == {"GUNICORN_CMD_ARGS": "--log-level debug"} - assert harness.model.unit.status == ops.ActiveStatus() - - -def test_config_changed_valid_cannot_connect(harness: ops.testing.Harness[{{ class_name }}]): - # Trigger a config-changed event with an updated value - harness.update_config({"log-level": "debug"}) - # Check the charm is in WaitingStatus - assert isinstance(harness.model.unit.status, ops.WaitingStatus) - - -def test_config_changed_invalid(harness: ops.testing.Harness[{{ class_name }}]): - # Ensure the simulated Pebble API is reachable - harness.set_can_connect("httpbin", True) - # Trigger a config-changed event with an updated value - harness.update_config({"log-level": "foobar"}) - # Check the charm is in BlockedStatus - assert isinstance(harness.model.unit.status, ops.BlockedStatus) + assert ( + state_out.get_container(container.name).service_statuses["httpbin"] + == ops.pebble.ServiceStatus.ACTIVE + ) + assert state_out.unit_status == testing.ActiveStatus() + + +def test_config_changed_valid_can_connect(): + """Test a config-changed event when the config is valid and the container can be reached.""" + # Arrange: + ctx = testing.Context({{ class_name }}) # The default config will be read from charmcraft.yaml + container = testing.Container("httpbin", can_connect=True) + state_in = testing.State( + containers={container}, + config={"log-level": "debug"}, # This is the config the charmer passed with `juju config` + ) + + # Act: + state_out = ctx.run(ctx.on.config_changed(), state_in) + + # Assert: + updated_plan = state_out.get_container(container.name).plan + gunicorn_args = updated_plan.services["httpbin"].environment["GUNICORN_CMD_ARGS"] + assert gunicorn_args == "--log-level debug" + assert state_out.unit_status == testing.ActiveStatus() + + +def test_config_changed_valid_cannot_connect(): + """Test a config-changed event when the config is valid but the container cannot be reached. + + We expect to end up in MaintenanceStatus waiting for the deferred event to + be retried. + """ + # Arrange: + ctx = testing.Context({{ class_name }}) + container = testing.Container("httpbin", can_connect=False) + state_in = testing.State(containers={container}, config={"log-level": "debug"}) + + # Act: + state_out = ctx.run(ctx.on.config_changed(), state_in) + + # Assert: + assert isinstance(state_out.unit_status, testing.MaintenanceStatus) + + +def test_config_changed_invalid(): + """Test a config-changed event when the config is invalid.""" + # Arrange: + ctx = testing.Context({{ class_name }}) + container = testing.Container("httpbin", can_connect=True) + invalid_level = "foobar" + state_in = testing.State(containers={container}, config={"log-level": invalid_level}) + + # Act: + state_out = ctx.run(ctx.on.config_changed(), state_in) + + # Assert: + assert isinstance(state_out.unit_status, testing.BlockedStatus) + assert invalid_level in state_out.unit_status.message diff --git a/charmcraft/templates/init-simple/tox.ini.j2 b/charmcraft/templates/init-simple/tox.ini.j2 index 1b06be988..f30c25577 100644 --- a/charmcraft/templates/init-simple/tox.ini.j2 +++ b/charmcraft/templates/init-simple/tox.ini.j2 @@ -49,6 +49,7 @@ description = Run unit tests deps = pytest coverage[toml] + ops[testing] -r {tox_root}/requirements.txt commands = coverage run --source={[vars]src_path} \ @@ -64,6 +65,7 @@ commands = description = Run static type checks deps = pyright + ops[testing] -r {tox_root}/requirements.txt commands = pyright {posargs}