Instruction file imported from canonical/tenant-service-operator (
.github/instructions/testing.instructions.md). Copyright stays with the author.
Testing Guidelines
Reference Implementation
The pattern reference is hydra-operator and kratos-operator, NOT hook-service-operator.
File Structure
One file per concern:
| File | Scope |
|---|---|
test_charm.py |
Lifecycle events, holistic handler, collect-status, relation events |
test_actions.py |
Juju action handlers (tenant CRUD, user management) |
test_integrations.py |
Integration wrapper classes tested in isolation |
test_cli.py |
CLI wrapper methods with mocked Container |
test_configs.py |
CharmConfig validation and env var output |
Unit Tests (tests/unit/)
- Framework:
ops.testing(Scenario). Do not use legacyHarness. - State factory: Use
create_state()— a module-level factory function inconftest.py(NOT a fixture). Import it in test files. - Do NOT use
dataclasses.replace()orreplace_state()to modify states. Always create a fresh state viacreate_state(). - Group tests in classes by event or feature (e.g.,
TestPebbleReadyEvent,TestCollectStatusEvent).
create_state() Factory Pattern
create_state() lives in conftest.py as a plain function. Test files import it directly:
from unit.conftest import create_state
# Minimal state (leader=True, can_connect=True, no relations)
state = create_state()
# Custom state
state = create_state(
leader=False,
relations=[database_relation, peer_relation],
secrets=[api_token_secret],
config={"authorization_enabled": True},
can_connect=False,
)
Supported kwargs: leader, secrets, relations, containers, config, can_connect, workload_version. The factory builds a complete testing.State with sensible defaults (leader=True, can_connect=True, default execs for CLI commands).
Mocking Rules
Autouse fixtures in conftest.py (apply to every test automatically):
mocked_k8s_resource_patch— MocksKubernetesComputeResourcesPatchusing two fixtures:mocked_resource_patch(patchesResourcePatcher) +mocked_k8s_resource_patch(patches viamocker.patch.multiple).mocked_openfga_integration— MocksOpenFGAIntegration.is_store_readyto returnTrue.mocked_subprocess_run— Mockssubprocess.runto prevent real cert updates.- For
collect-unit-statustests, use theall_satisfied_conditionsfixture that mocks all condition functions to return satisfied values.
Action Test Pattern
Each action test class should have at minimum:
test_success— happy path, assert CLI method called with correct argstest_failure— CLI raises Exception, assert action fails withtesting.ActionFailedtest_container_not_ready— disconnected container, assert action fails withtesting.ActionFailed
Important: Use pytest.raises(testing.ActionFailed, ...) for action failures, NOT generic Exception.
from unit.conftest import create_state
class TestCreateTenantAction:
def test_success(self, context: testing.Context, mocked_cli: MagicMock) -> None:
mocked_cli.return_value.create_tenant.return_value = "Tenant created"
state = create_state()
context.run(context.on.action("create-tenant", params={"name": "test"}), state)
mocked_cli.return_value.create_tenant.assert_called_once_with(name="test")
def test_failure(self, context: testing.Context, mocked_cli: MagicMock) -> None:
mocked_cli.return_value.create_tenant.side_effect = Exception("fail")
state = create_state()
with pytest.raises(testing.ActionFailed, match="Failed to create tenant"):
context.run(context.on.action("create-tenant", params={"name": "test"}), state)
def test_container_not_ready(self, context: testing.Context) -> None:
state = create_state(can_connect=False)
with pytest.raises(testing.ActionFailed, match="Workload container is not ready"):
context.run(context.on.action("create-tenant", params={"name": "test"}), state)
Integration Wrapper Test Pattern
Test wrappers in isolation using create_autospec() for library objects:
load()classmethods: test with/without relation datato_env_vars(): verify correct env var keys and valuesis_ready()/is_store_ready(): test true/false paths
These are pure mock tests — no create_state() needed.
Integration Tests (tests/integration/)
- Framework:
jubilantlibrary. - Lifecycle order: deploy → health check → scale up → actions → remove/re-add integrations → scale down → removal.
- Skippable: Deploy (
--no-deploy) and removal (--keep-models) must be skippable. - Use
conftest.pyfor model/charm fixtures,constants.pyfor app names,utils.pyfor helpers.