From 0a5e7231578f0d631ad7fb09ebb40e949d422c7c Mon Sep 17 00:00:00 2001 From: David Wilding Date: Wed, 8 Oct 2025 13:10:25 +0800 Subject: [PATCH 01/17] add placeholder tests to cover update-status --- .../httpbin-demo/tests/unit/test_charm.py | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 8c9d7c25b..781dde15e 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -13,11 +13,66 @@ # limitations under the License. import ops -from ops import testing +from ops import pebble, testing from charm import CONTAINER_NAME, SERVICE_NAME, HttpbinDemoCharm +layer = pebble.Layer( + { + "services": { + SERVICE_NAME: { + "override": "replace", + "command": "mock-command", + "startup": "enabled", + }, + }, + } +) + + +def test_status_active(): + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container( + CONTAINER_NAME, + layers={"base": layer}, + service_statuses={SERVICE_NAME: pebble.ServiceStatus.ACTIVE}, + can_connect=True, + ) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.ActiveStatus() + + +def test_status_inactive(): + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container( + CONTAINER_NAME, + layers={"base": layer}, + service_statuses={SERVICE_NAME: pebble.ServiceStatus.INACTIVE}, + can_connect=True, + ) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload") + + +def test_status_container_down(): + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container(CONTAINER_NAME, can_connect=True) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") + + +def test_status_container_no_plan(): + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container(CONTAINER_NAME, can_connect=False) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") + + def test_httpbin_pebble_ready(): # Arrange: ctx = testing.Context(HttpbinDemoCharm) From 57caac83095129e8be11c617d48bf106b00c015b Mon Sep 17 00:00:00 2001 From: David Wilding Date: Wed, 8 Oct 2025 13:12:33 +0800 Subject: [PATCH 02/17] refactor try..except per James's suggestion --- examples/httpbin-demo/src/charm.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/httpbin-demo/src/charm.py b/examples/httpbin-demo/src/charm.py index d82ac922f..b71ff6220 100755 --- a/examples/httpbin-demo/src/charm.py +++ b/examples/httpbin-demo/src/charm.py @@ -67,9 +67,7 @@ def _on_collect_status(self, event: ops.CollectStatusEvent): except ValueError as e: event.add_status(ops.BlockedStatus(str(e))) try: - if not self.container.get_service(SERVICE_NAME).is_running(): - # We can connect to Pebble in the container, but the service hasn't started yet. - event.add_status(ops.MaintenanceStatus("waiting for workload")) + service = self.container.get_service(SERVICE_NAME) except ops.ModelError: # We can connect to Pebble in the container, but the service doesn't exist. This is # most likely because we haven't added a layer yet. @@ -82,6 +80,10 @@ def _on_collect_status(self, event: ops.CollectStatusEvent): # It's technically possible (but unlikely) for Pebble to have an internal error. logger.error("Unable to fetch service info from Pebble") raise + else: + if not service.is_running(): + # We can connect to Pebble in the container, but the service hasn't started yet. + event.add_status(ops.MaintenanceStatus("waiting for workload")) event.add_status(ops.ActiveStatus()) def _on_httpbin_pebble_ready(self, event: ops.PebbleReadyEvent): From 317d79e668b05d0efad8a844e2010f5e822df23e Mon Sep 17 00:00:00 2001 From: David Wilding Date: Wed, 8 Oct 2025 13:23:55 +0800 Subject: [PATCH 03/17] correct placeholder unit tests --- examples/httpbin-demo/tests/unit/test_charm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 781dde15e..ee3c15c6c 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -59,7 +59,7 @@ def test_status_inactive(): def test_status_container_down(): ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container(CONTAINER_NAME, can_connect=True) + container = testing.Container(CONTAINER_NAME, can_connect=False) state_in = testing.State(containers={container}) state_out = ctx.run(ctx.on.update_status(), state_in) assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") @@ -67,7 +67,7 @@ def test_status_container_down(): def test_status_container_no_plan(): ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container(CONTAINER_NAME, can_connect=False) + container = testing.Container(CONTAINER_NAME, can_connect=True) state_in = testing.State(containers={container}) state_out = ctx.run(ctx.on.update_status(), state_in) assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") From 6c346efe11c0ef7bb29ce6d97451a17d7b372726 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 12:33:08 +0800 Subject: [PATCH 04/17] remove old config-changed test for deferred event --- examples/httpbin-demo/tests/unit/test_charm.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index ee3c15c6c..5ae82d943 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -123,24 +123,6 @@ def test_config_changed_valid_can_connect(): 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(HttpbinDemoCharm) - container = testing.Container(CONTAINER_NAME, 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_valid_uppercase(): """Test a config-changed event when the config is valid and uppercase.""" # Arrange: From dff8d1cf955be4a9ca1e68f4d37e32d5364fc017 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 12:47:24 +0800 Subject: [PATCH 05/17] parametrize config-changed tests --- .../httpbin-demo/tests/unit/test_charm.py | 51 ++++++++----------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 5ae82d943..a67444e1c 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -13,11 +13,11 @@ # limitations under the License. import ops +import pytest from ops import pebble, testing from charm import CONTAINER_NAME, SERVICE_NAME, HttpbinDemoCharm - layer = pebble.Layer( { "services": { @@ -103,32 +103,19 @@ def test_httpbin_pebble_ready(): 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(HttpbinDemoCharm) # The default config will be read from charmcraft.yaml - container = testing.Container(CONTAINER_NAME, 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[SERVICE_NAME].environment["GUNICORN_CMD_ARGS"] - assert gunicorn_args == "--log-level debug" - assert state_out.unit_status == testing.ActiveStatus() - - -def test_config_changed_valid_uppercase(): - """Test a config-changed event when the config is valid and uppercase.""" +@pytest.mark.parametrize( + "user_log_level, gunicorn_log_level", + [ + ("debug", "debug"), + ("DEBUG", "debug"), + ], +) +def test_config_changed(user_log_level: str, gunicorn_log_level: str): + """Test a config-changed event when the config is valid.""" # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) - state_in = testing.State(containers={container}, config={"log-level": "DEBUG"}) + state_in = testing.State(containers={container}, config={"log-level": user_log_level}) # Act: state_out = ctx.run(ctx.on.config_changed(), state_in) @@ -136,21 +123,27 @@ def test_config_changed_valid_uppercase(): # Assert: updated_plan = state_out.get_container(container.name).plan gunicorn_args = updated_plan.services[SERVICE_NAME].environment["GUNICORN_CMD_ARGS"] - assert gunicorn_args == "--log-level debug" + assert gunicorn_args == f"--log-level {gunicorn_log_level}" assert isinstance(state_out.unit_status, testing.ActiveStatus) -def test_config_changed_invalid(): +@pytest.mark.parametrize( + "user_log_level", + [ + "", + "foobar", + ], +) +def test_config_changed_invalid(user_log_level: str): """Test a config-changed event when the config is invalid.""" # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) - invalid_level = "foobar" - state_in = testing.State(containers={container}, config={"log-level": invalid_level}) + state_in = testing.State(containers={container}, config={"log-level": user_log_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 + assert f"'{user_log_level}'" in state_out.unit_status.message From 7dc533ddfe896e4f79b9e9139be138de8aae34a6 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 12:50:11 +0800 Subject: [PATCH 06/17] remove blank lines --- examples/httpbin-demo/tests/unit/test_charm.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index a67444e1c..daf311397 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -78,10 +78,8 @@ def test_httpbin_pebble_ready(): ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) state_in = testing.State(containers={container}) - # Act: state_out = ctx.run(ctx.on.pebble_ready(container), state_in) - # Assert: updated_plan = state_out.get_container(container.name).plan expected_plan = { @@ -116,10 +114,8 @@ def test_config_changed(user_log_level: str, gunicorn_log_level: str): ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) state_in = testing.State(containers={container}, config={"log-level": user_log_level}) - # 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[SERVICE_NAME].environment["GUNICORN_CMD_ARGS"] @@ -140,10 +136,8 @@ def test_config_changed_invalid(user_log_level: str): ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) state_in = testing.State(containers={container}, config={"log-level": user_log_level}) - # Act: state_out = ctx.run(ctx.on.config_changed(), state_in) - # Assert: assert isinstance(state_out.unit_status, testing.BlockedStatus) assert f"'{user_log_level}'" in state_out.unit_status.message From bcd9a9d9cca6b666aa336731ae6e003bbe8ae1da Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 12:51:23 +0800 Subject: [PATCH 07/17] be consistent when checking active status --- examples/httpbin-demo/tests/unit/test_charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index daf311397..c5a181816 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -120,7 +120,7 @@ def test_config_changed(user_log_level: str, gunicorn_log_level: str): updated_plan = state_out.get_container(container.name).plan gunicorn_args = updated_plan.services[SERVICE_NAME].environment["GUNICORN_CMD_ARGS"] assert gunicorn_args == f"--log-level {gunicorn_log_level}" - assert isinstance(state_out.unit_status, testing.ActiveStatus) + assert state_out.unit_status == testing.ActiveStatus() @pytest.mark.parametrize( From 899f8a9ed5bb5bbce84c3b186f68ddaafe236aa7 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 12:53:05 +0800 Subject: [PATCH 08/17] add docstring to pebble-ready test --- examples/httpbin-demo/tests/unit/test_charm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index c5a181816..6089ff527 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -74,6 +74,7 @@ def test_status_container_no_plan(): def test_httpbin_pebble_ready(): + """Test that the charm correctly starts the service in the container.""" # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) From 246dab6bf20cd99810b8f1569e0c9430194a53e9 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 12:53:51 +0800 Subject: [PATCH 09/17] move update-status tests to end of file --- .../httpbin-demo/tests/unit/test_charm.py | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 6089ff527..290ef0846 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -31,48 +31,6 @@ ) -def test_status_active(): - ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container( - CONTAINER_NAME, - layers={"base": layer}, - service_statuses={SERVICE_NAME: pebble.ServiceStatus.ACTIVE}, - can_connect=True, - ) - state_in = testing.State(containers={container}) - state_out = ctx.run(ctx.on.update_status(), state_in) - assert state_out.unit_status == testing.ActiveStatus() - - -def test_status_inactive(): - ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container( - CONTAINER_NAME, - layers={"base": layer}, - service_statuses={SERVICE_NAME: pebble.ServiceStatus.INACTIVE}, - can_connect=True, - ) - state_in = testing.State(containers={container}) - state_out = ctx.run(ctx.on.update_status(), state_in) - assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload") - - -def test_status_container_down(): - ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container(CONTAINER_NAME, can_connect=False) - state_in = testing.State(containers={container}) - state_out = ctx.run(ctx.on.update_status(), state_in) - assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") - - -def test_status_container_no_plan(): - ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container(CONTAINER_NAME, can_connect=True) - state_in = testing.State(containers={container}) - state_out = ctx.run(ctx.on.update_status(), state_in) - assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") - - def test_httpbin_pebble_ready(): """Test that the charm correctly starts the service in the container.""" # Arrange: @@ -142,3 +100,45 @@ def test_config_changed_invalid(user_log_level: str): # Assert: assert isinstance(state_out.unit_status, testing.BlockedStatus) assert f"'{user_log_level}'" in state_out.unit_status.message + + +def test_status_active(): + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container( + CONTAINER_NAME, + layers={"base": layer}, + service_statuses={SERVICE_NAME: pebble.ServiceStatus.ACTIVE}, + can_connect=True, + ) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.ActiveStatus() + + +def test_status_inactive(): + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container( + CONTAINER_NAME, + layers={"base": layer}, + service_statuses={SERVICE_NAME: pebble.ServiceStatus.INACTIVE}, + can_connect=True, + ) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload") + + +def test_status_container_down(): + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container(CONTAINER_NAME, can_connect=False) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") + + +def test_status_container_no_plan(): + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container(CONTAINER_NAME, can_connect=True) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") From e08530c00daee51dc6dc80ed80862aa5cb131a70 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 16:00:20 +0800 Subject: [PATCH 10/17] rename update-status tests --- .../httpbin-demo/tests/unit/test_charm.py | 74 ++++++++----------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 290ef0846..3dafb175b 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -60,6 +60,38 @@ def test_httpbin_pebble_ready(): assert state_out.unit_status == testing.ActiveStatus() +def test_pebble_service_inactive(): + """Test that the charm goes into maintenance status if the service isn't active.""" + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container( + CONTAINER_NAME, + layers={"base": layer}, + service_statuses={SERVICE_NAME: pebble.ServiceStatus.INACTIVE}, + can_connect=True, + ) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload") + + +def test_pebble_no_service(): + """Test that the charm goes into maintenance status if the service hasn't been defined.""" + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container(CONTAINER_NAME, can_connect=True) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") + + +def test_pebble_unavailable(): + """Test that the charm goes into maintenance status if the container is down.""" + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container(CONTAINER_NAME, can_connect=False) + state_in = testing.State(containers={container}) + state_out = ctx.run(ctx.on.update_status(), state_in) + assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") + + @pytest.mark.parametrize( "user_log_level, gunicorn_log_level", [ @@ -100,45 +132,3 @@ def test_config_changed_invalid(user_log_level: str): # Assert: assert isinstance(state_out.unit_status, testing.BlockedStatus) assert f"'{user_log_level}'" in state_out.unit_status.message - - -def test_status_active(): - ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container( - CONTAINER_NAME, - layers={"base": layer}, - service_statuses={SERVICE_NAME: pebble.ServiceStatus.ACTIVE}, - can_connect=True, - ) - state_in = testing.State(containers={container}) - state_out = ctx.run(ctx.on.update_status(), state_in) - assert state_out.unit_status == testing.ActiveStatus() - - -def test_status_inactive(): - ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container( - CONTAINER_NAME, - layers={"base": layer}, - service_statuses={SERVICE_NAME: pebble.ServiceStatus.INACTIVE}, - can_connect=True, - ) - state_in = testing.State(containers={container}) - state_out = ctx.run(ctx.on.update_status(), state_in) - assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload") - - -def test_status_container_down(): - ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container(CONTAINER_NAME, can_connect=False) - state_in = testing.State(containers={container}) - state_out = ctx.run(ctx.on.update_status(), state_in) - assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") - - -def test_status_container_no_plan(): - ctx = testing.Context(HttpbinDemoCharm) - container = testing.Container(CONTAINER_NAME, can_connect=True) - state_in = testing.State(containers={container}) - state_out = ctx.run(ctx.on.update_status(), state_in) - assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") From 0e315a212ebf8b9ad8363b9a7db5e94f558ed169 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 16:00:43 +0800 Subject: [PATCH 11/17] remove 'httpbin' from pebble test name --- examples/httpbin-demo/tests/unit/test_charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 3dafb175b..7f3caca97 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -31,7 +31,7 @@ ) -def test_httpbin_pebble_ready(): +def test_pebble_ready(): """Test that the charm correctly starts the service in the container.""" # Arrange: ctx = testing.Context(HttpbinDemoCharm) From 71f632755a780e6b04fe3ce49efd3637c9a43c1d Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 17:52:26 +0800 Subject: [PATCH 12/17] include test of active status --- .../httpbin-demo/tests/unit/test_charm.py | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 7f3caca97..ca530ac11 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -18,7 +18,9 @@ from charm import CONTAINER_NAME, SERVICE_NAME, HttpbinDemoCharm -layer = pebble.Layer( +# A mock Pebble layer - useful for testing the charm's status reporting code. +# The status reporting code shouldn't start the service, so the layer doesn't need a real command. +MOCK_LAYER = pebble.Layer( { "services": { SERVICE_NAME: { @@ -32,7 +34,7 @@ def test_pebble_ready(): - """Test that the charm correctly starts the service in the container.""" + """Check that the charm correctly starts the service in the container.""" # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) @@ -60,35 +62,65 @@ def test_pebble_ready(): assert state_out.unit_status == testing.ActiveStatus() -def test_pebble_service_inactive(): - """Test that the charm goes into maintenance status if the service isn't active.""" +def test_status_service_active(): + """Check that the charm goes into active status if the service is active. + + This test is useful in addition to ``test_pebble_ready()`` because it checks that the charm + consistently sets active status, regardless of which event was handled. + """ + # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container( CONTAINER_NAME, - layers={"base": layer}, + layers={"base": MOCK_LAYER}, + service_statuses={SERVICE_NAME: pebble.ServiceStatus.ACTIVE}, + can_connect=True, + ) + state_in = testing.State(containers={container}) + # Act: + state_out = ctx.run(ctx.on.update_status(), state_in) + # Assert: + assert state_out.unit_status == testing.ActiveStatus() + + +def test_status_service_inactive(): + """Check that the charm goes into maintenance status if the service isn't active.""" + # Arrange: + ctx = testing.Context(HttpbinDemoCharm) + container = testing.Container( + CONTAINER_NAME, + layers={"base": MOCK_LAYER}, service_statuses={SERVICE_NAME: pebble.ServiceStatus.INACTIVE}, can_connect=True, ) state_in = testing.State(containers={container}) + # Act: state_out = ctx.run(ctx.on.update_status(), state_in) + # Assert: assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload") -def test_pebble_no_service(): - """Test that the charm goes into maintenance status if the service hasn't been defined.""" +def test_status_no_service(): + """Check that the charm goes into maintenance status if the service hasn't been defined.""" + # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) state_in = testing.State(containers={container}) + # Act: state_out = ctx.run(ctx.on.update_status(), state_in) + # Assert: assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") -def test_pebble_unavailable(): - """Test that the charm goes into maintenance status if the container is down.""" +def test_status_no_pebble(): + """Check that the charm goes into maintenance status if the container is down.""" + # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=False) state_in = testing.State(containers={container}) + # Act: state_out = ctx.run(ctx.on.update_status(), state_in) + # Assert: assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") From b8d6e8b4a45d270ff809650c12c2a6966cb8f786 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 17:53:11 +0800 Subject: [PATCH 13/17] only keep comments on the first test --- examples/httpbin-demo/tests/unit/test_charm.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index ca530ac11..41fb268fb 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -68,7 +68,6 @@ def test_status_service_active(): This test is useful in addition to ``test_pebble_ready()`` because it checks that the charm consistently sets active status, regardless of which event was handled. """ - # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container( CONTAINER_NAME, @@ -77,15 +76,12 @@ def test_status_service_active(): can_connect=True, ) state_in = testing.State(containers={container}) - # Act: state_out = ctx.run(ctx.on.update_status(), state_in) - # Assert: assert state_out.unit_status == testing.ActiveStatus() def test_status_service_inactive(): """Check that the charm goes into maintenance status if the service isn't active.""" - # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container( CONTAINER_NAME, @@ -94,33 +90,25 @@ def test_status_service_inactive(): can_connect=True, ) state_in = testing.State(containers={container}) - # Act: state_out = ctx.run(ctx.on.update_status(), state_in) - # Assert: assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload") def test_status_no_service(): """Check that the charm goes into maintenance status if the service hasn't been defined.""" - # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) state_in = testing.State(containers={container}) - # Act: state_out = ctx.run(ctx.on.update_status(), state_in) - # Assert: assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") def test_status_no_pebble(): """Check that the charm goes into maintenance status if the container is down.""" - # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=False) state_in = testing.State(containers={container}) - # Act: state_out = ctx.run(ctx.on.update_status(), state_in) - # Assert: assert state_out.unit_status == testing.MaintenanceStatus("waiting for workload container") @@ -133,13 +121,10 @@ def test_status_no_pebble(): ) def test_config_changed(user_log_level: str, gunicorn_log_level: str): """Test a config-changed event when the config is valid.""" - # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) state_in = testing.State(containers={container}, config={"log-level": user_log_level}) - # 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[SERVICE_NAME].environment["GUNICORN_CMD_ARGS"] assert gunicorn_args == f"--log-level {gunicorn_log_level}" @@ -155,12 +140,9 @@ def test_config_changed(user_log_level: str, gunicorn_log_level: str): ) def test_config_changed_invalid(user_log_level: str): """Test a config-changed event when the config is invalid.""" - # Arrange: ctx = testing.Context(HttpbinDemoCharm) container = testing.Container(CONTAINER_NAME, can_connect=True) state_in = testing.State(containers={container}, config={"log-level": user_log_level}) - # Act: state_out = ctx.run(ctx.on.config_changed(), state_in) - # Assert: assert isinstance(state_out.unit_status, testing.BlockedStatus) assert f"'{user_log_level}'" in state_out.unit_status.message From baed00081d37bb6cd343c40c1139d97b301052a9 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 17:57:38 +0800 Subject: [PATCH 14/17] clarify comment about the mock command --- examples/httpbin-demo/tests/unit/test_charm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 41fb268fb..8976c8a37 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -18,8 +18,8 @@ from charm import CONTAINER_NAME, SERVICE_NAME, HttpbinDemoCharm -# A mock Pebble layer - useful for testing the charm's status reporting code. -# The status reporting code shouldn't start the service, so the layer doesn't need a real command. +# A mock Pebble layer - useful for testing the charm's status reporting code. The status reporting +# code shouldn't care how the service was started, so the layer doesn't need the real command. MOCK_LAYER = pebble.Layer( { "services": { From e39c43c700a23f672ddccffd4e0ec8b2e20b673d Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 18:02:09 +0800 Subject: [PATCH 15/17] minor adjustment to wording --- examples/httpbin-demo/tests/unit/test_charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/httpbin-demo/tests/unit/test_charm.py b/examples/httpbin-demo/tests/unit/test_charm.py index 8976c8a37..e56856372 100644 --- a/examples/httpbin-demo/tests/unit/test_charm.py +++ b/examples/httpbin-demo/tests/unit/test_charm.py @@ -19,7 +19,7 @@ from charm import CONTAINER_NAME, SERVICE_NAME, HttpbinDemoCharm # A mock Pebble layer - useful for testing the charm's status reporting code. The status reporting -# code shouldn't care how the service was started, so the layer doesn't need the real command. +# code shouldn't care how the service is started, so the layer doesn't need the real command. MOCK_LAYER = pebble.Layer( { "services": { From 5d490a09ec81a583d5847d69528c857e8738a8b3 Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 18:05:14 +0800 Subject: [PATCH 16/17] add link from Harness migration guide --- docs/howto/legacy/migrate-unit-tests-from-harness.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/legacy/migrate-unit-tests-from-harness.md b/docs/howto/legacy/migrate-unit-tests-from-harness.md index 66279e4bb..2e057e9f6 100644 --- a/docs/howto/legacy/migrate-unit-tests-from-harness.md +++ b/docs/howto/legacy/migrate-unit-tests-from-harness.md @@ -541,5 +541,5 @@ For more information about state-transition testing, see: For more examples of collect-status event handlers, see: -- The [httpbin-demo charm](https://github.com/canonical/operator/blob/main/examples/httpbin-demo/src/charm.py) +- The [httpbin-demo charm](https://github.com/canonical/operator/blob/main/examples/httpbin-demo/src/charm.py) and [its unit tests](https://github.com/canonical/operator/blob/main/examples/httpbin-demo/tests/unit/test_charm.py). - [Update the unit status to reflect the relation state](#integrate-your-charm-with-postgresql-update-unit-status) in the Kubernetes charm tutorial From 69bc1c34d801d14268225b367ffa35f4b896845e Mon Sep 17 00:00:00 2001 From: David Wilding Date: Tue, 23 Dec 2025 18:13:51 +0800 Subject: [PATCH 17/17] remove full stop for consistency --- docs/howto/legacy/migrate-unit-tests-from-harness.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/legacy/migrate-unit-tests-from-harness.md b/docs/howto/legacy/migrate-unit-tests-from-harness.md index 2e057e9f6..86efdc29f 100644 --- a/docs/howto/legacy/migrate-unit-tests-from-harness.md +++ b/docs/howto/legacy/migrate-unit-tests-from-harness.md @@ -541,5 +541,5 @@ For more information about state-transition testing, see: For more examples of collect-status event handlers, see: -- The [httpbin-demo charm](https://github.com/canonical/operator/blob/main/examples/httpbin-demo/src/charm.py) and [its unit tests](https://github.com/canonical/operator/blob/main/examples/httpbin-demo/tests/unit/test_charm.py). +- The [httpbin-demo charm](https://github.com/canonical/operator/blob/main/examples/httpbin-demo/src/charm.py) and [its unit tests](https://github.com/canonical/operator/blob/main/examples/httpbin-demo/tests/unit/test_charm.py) - [Update the unit status to reflect the relation state](#integrate-your-charm-with-postgresql-update-unit-status) in the Kubernetes charm tutorial