Skip to content

Commit

Permalink
feat: use the newer ops.testing classes for the charm unit tests (#1933)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
tonyandrewmeyer and dimaqq authored Oct 9, 2024
1 parent 8410347 commit f9d17b0
Show file tree
Hide file tree
Showing 7 changed files with 99 additions and 78 deletions.
25 changes: 11 additions & 14 deletions charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 2 additions & 0 deletions charmcraft/templates/init-kubernetes/tox.ini.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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} \
Expand All @@ -64,6 +65,7 @@ commands =
description = Run static type checks
deps =
pyright
ops[testing]
-r {tox_root}/requirements.txt
commands =
pyright {posargs}
Expand Down
22 changes: 9 additions & 13 deletions charmcraft/templates/init-machine/tests/unit/test_charm.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 2 additions & 0 deletions charmcraft/templates/init-machine/tox.ini.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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} \
Expand All @@ -64,6 +65,7 @@ commands =
description = Run static type checks
deps =
pyright
ops[testing]
-r {tox_root}/requirements.txt
commands =
pyright {posargs}
Expand Down
4 changes: 2 additions & 2 deletions charmcraft/templates/init-simple/src/charm.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
120 changes: 71 additions & 49 deletions charmcraft/templates/init-simple/tests/unit/test_charm.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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
2 changes: 2 additions & 0 deletions charmcraft/templates/init-simple/tox.ini.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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} \
Expand All @@ -64,6 +65,7 @@ commands =
description = Run static type checks
deps =
pyright
ops[testing]
-r {tox_root}/requirements.txt
commands =
pyright {posargs}
Expand Down

0 comments on commit f9d17b0

Please sign in to comment.