Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/property-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: ops Property Tests

on:
push:
branches:
- main
pull_request:
workflow_dispatch:
schedule:
- cron: '17 15 * * *'

permissions: {}

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
- run: uv tool install tox --with tox-uv

# Pull requests and pushes get a small, derandomised run. The scheduled
# and manual runs get a large budget with fresh randomness, to look for
# inputs nobody has tried yet.
- name: Run property tests
run: tox -e property
env:
HYPOTHESIS_PROFILE: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && 'nightly' || 'ci' }}
timeout-minutes: 60

open-issue-on-failure-if-scheduled:
if: ${{ failure() && github.event_name == 'schedule' }}
needs: [test]
permissions:
issues: write
actions: read # for ai-failure-enrich.yaml
secrets:
# For ai-failure-enrich.yaml -- can only be read in the ai-failure-triage environment.
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
uses: $/.github/workflows/notify-scheduled-failure.yaml
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ juju-crashdump*.tar.xz

# Workshop
/.workshop.lock
.hypothesis/
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ The framework has some tests that interact with a real/live Pebble server, which
tox -e pebble
```

Property-based tests, which use [Hypothesis](https://hypothesis.readthedocs.io/), live in `test/property/` and run on their own rather than as part of the unit tests. Hypothesis is in its own `property` dependency group, so the unit tests don't need it.

```sh
tox -e property
```

Set `HYPOTHESIS_PROFILE` to `ci` for the small, repeatable run that pull requests get, or to `nightly` for the much longer run that's scheduled every night. If the nightly run finds a failing example, fix the bug and add the example to the test with `@example(...)`, so that every later run checks it.

## Using an `ops` branch in a charm

When making changes to `ops`, you'll commonly want to try those changes out in
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ coverage = [
benchmark = [
"pytest-benchmark~=5.0",
]
property = [
"hypothesis~=6.0",
]
integration = [
"ops[testing,tracing]",
"pytest~=9.0",
Expand Down
41 changes: 41 additions & 0 deletions test/property/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright 2026 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Hypothesis settings profiles for the property tests.

The profile is chosen with the ``HYPOTHESIS_PROFILE`` environment variable:

* ``dev`` (the default): Hypothesis's usual budget, for running locally.
* ``ci``: a small, derandomised budget, so a pull request's result doesn't
depend on luck.
* ``nightly``: a large budget with fresh randomness, for the scheduled run.

Tests shouldn't set ``max_examples`` themselves, so that the profile decides
how hard each run looks. When the nightly run finds a failing example, add it
to the test with ``@example(...)`` so that every later run checks it.
"""

from __future__ import annotations

import os

from hypothesis import settings

# Generation is cheap here, but CI machines are noisy, and a timing-based
# failure would say nothing about the code.
settings.register_profile('dev', deadline=None)
settings.register_profile('ci', deadline=None, derandomize=True, print_blob=True)
settings.register_profile('nightly', deadline=None, max_examples=10_000, print_blob=True)

settings.load_profile(os.environ.get('HYPOTHESIS_PROFILE', 'dev'))
89 changes: 89 additions & 0 deletions test/property/test_scenario_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright 2026 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Hypothesis property-based tests for scenario.state.

These test the _EntityStatus and Port conversions between scenario and ops.
Both are pure in-memory round-trips with a wide input space and a simple
invariant, which is where property-based testing earns its keep.
"""

from __future__ import annotations

from hypothesis import given
from hypothesis import strategies as st
from scenario.state import (
ActiveStatus,
BlockedStatus,
ErrorStatus,
ICMPPort,
MaintenanceStatus,
TCPPort,
UDPPort,
UnknownStatus,
WaitingStatus,
_EntityStatus,
)

# ── strategies ──────────────────────────────────────────────────────────────
#
# Strategy design is where Hypothesis pays off or doesn't.
# The choices below are deliberately broad so the tool can discover input
# combinations the authors wouldn't reach for by hand.

# Every status type. UnknownStatus takes no message, so it is the one fixed value.
_status_strategy = st.one_of(
st.builds(ActiveStatus, message=st.text()),
st.builds(BlockedStatus, message=st.text()),
st.builds(MaintenanceStatus, message=st.text()),
st.builds(WaitingStatus, message=st.text()),
st.builds(ErrorStatus, message=st.text()),
st.just(UnknownStatus()),
)

# All three concrete Port types; TCPPort/UDPPort accept validated port numbers.
_port_strategy = st.one_of(
st.integers(min_value=1, max_value=65535).map(TCPPort),
Comment thread
tonyandrewmeyer marked this conversation as resolved.
Resolved
st.integers(min_value=1, max_value=65535).map(UDPPort),
st.just(ICMPPort()),
)


# ── tests ────────────────────────────────────────────────────────────────────


@given(status=_status_strategy)
def test_entity_status_ops_roundtrip(status: _EntityStatus) -> None:
"""_EntityStatus → ops.StatusBase → _EntityStatus preserves name and message.

This bridges the scenario and ops status representations; any mismatch in
the name/message mapping would silently corrupt the visible status after a
charm event.
"""
round_tripped = _EntityStatus.from_ops(status._to_ops())
assert round_tripped == status
assert round_tripped.name == status.name
assert round_tripped.message == status.message


@given(port=_port_strategy)
def test_port_to_ops_preserves_attributes(port: TCPPort | UDPPort | ICMPPort) -> None:
"""Port._to_ops() faithfully copies port number and protocol to ops.Port.

If the mapping were to swap protocol strings or clamp the port number, ops
would open the wrong port at runtime.
"""
ops_port = port._to_ops()
assert ops_port.port == port.port
assert ops_port.protocol == port.protocol
14 changes: 13 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ commands =

[testenv:lint]
description = Check code style and type correctness
dependency_groups = lint, unit
dependency_groups = lint, unit, property
commands =
ruff check --preview
ruff format --preview --check
Expand All @@ -73,6 +73,7 @@ commands =
--ignore={[vars]tst_path}smoke \
--ignore={[vars]tst_path}integration \
--ignore={[vars]tst_path}benchmark \
--ignore={[vars]tst_path}property \
--ignore={[vars]testing_tst_path}benchmark \
--ignore={[vars]tracing_tst_path} \
--ignore={[vars]examples_path} \
Expand Down Expand Up @@ -108,6 +109,7 @@ commands =
--ignore={[vars]tst_path}smoke \
--ignore={[vars]tst_path}integration \
--ignore={[vars]tst_path}benchmark \
--ignore={[vars]tst_path}property \
--ignore={[vars]testing_tst_path}benchmark \
--ignore={[vars]tracing_tst_path} \
--ignore={[vars]examples_path} \
Expand Down Expand Up @@ -156,6 +158,16 @@ commands =
{[vars]testing_tst_path}benchmark \
{posargs}

[testenv:property]
description = Run the Hypothesis property tests
passenv =
HYPOTHESIS_PROFILE
dependency_groups = unit, property
commands =
pytest -v --tb native -W error \
{[vars]tst_path}property \
{posargs}

[testenv:py{3.10,3.12,3.14}-pebble]
runner = uv-venv-lock-runner
base=pebble
Expand Down
Loading
Loading