Migrate Integration Tests to Jubilant + pytest-jubilant 2.0
Migrate a charm's integration tests to use Jubilant and pytest-jubilant 2.0. This skill handles two migration paths:
- (A) pytest-operator → jubilant 2.0: Full migration from python-libjuju async patterns
- (B) pytest-jubilant 1.x → 2.0: Namespace and API renames only
When to Use
- Test files import
pytest_operator, OpsTest, or from juju (python-libjuju) → path A
- Test files use
pytest-jubilant <2.0 fixtures (temp_model_factory, --keep-models, @pytest.mark.setup) → path B
- The charm's
pyproject.toml or tox.ini pins pytest-jubilant<2 or pytest-operator → either path
Before You Start: Read the Source
Install jubilant and pytest-jubilant and read the source code to understand the current API. This step is critical — it consistently produces better migrations than relying on documentation alone.
uv pip install jubilant pytest-jubilant
Then read these modules to understand the API surface:
jubilant.Juju — the main class, with deploy, wait, integrate, run, config, status, ssh, cli, etc.
jubilant module-level helpers — all_active, all_blocked, any_error, temp_model, etc.
jubilant.statustypes — Status, AppStatus, UnitStatus and their attributes
jubilant.Task — returned by juju.run() (actions) and juju.exec(), with .results, .status, .success (note that .run() will call .raise_on_failure() itself, so do not assert on .success)
pytest_jubilant — the juju fixture, juju_factory fixture, markers (juju_setup, juju_teardown)
Do not skip this step. Reading the source prevents hallucinated parameters and ensures you use the correct API signatures.
Step 1: Survey the Existing Tests
Before editing any files, read all integration test files, conftest.py, helper modules, and dependency files. Note:
- Which migration path applies (A or B)
- All test files that need changes
- Custom fixtures and helpers that wrap the old API
- Dependency declarations in
pyproject.toml, tox.ini, requirements.txt
- CI workflows that reference test dependencies or commands
- Any existing patterns worth preserving (multi-model, cross-model relations, custom wait conditions)
Step 2: Migrate
Work through files systematically. For each file, migrate completely before moving to the next.
Path A: pytest-operator → jubilant 2.0
See references/pytest-operator-mapping.md for the complete API mapping table.
Key changes:
Remove all async/await — Jubilant is synchronous. Remove async def, await, @pytest.mark.asyncio.
Replace the fixture — Delete any custom ops_test / OpsTest fixture. The juju fixture is provided by pytest-jubilant automatically when installed — do not recreate it with temp_model().
# Before
async def test_deploy(ops_test: OpsTest): ...
# After
def test_deploy(juju: jubilant.Juju): ...
Replace charm building — Jubilant does not build charms. The charm should be packed before tests run (via charmcraft pack or CI). Pass the .charm path to juju.deploy().
# Before
charm = await ops_test.build_charm(".")
await ops_test.model.deploy(charm)
# After — use a fixture or env var for the charm path
juju.deploy(charm)
Replace wait patterns — model.wait_for_idle() becomes juju.wait() with a predicate.
# Before
await ops_test.model.wait_for_idle(apps=["foo"], status="active", timeout=600)
# After
juju.wait(
jubilant.all_active, timeout=600
) # Generally, wait for all apps, but you can specifically wait for "foo" if needed.
Replace relation/integration calls:
# Before
await ops_test.model.add_relation("foo:db", "bar:db")
# After
juju.integrate("foo:db", "bar:db")
Replace action calls:
# Before
action = await unit.run_action("backup", **{"target": "/data"})
result = await action.wait()
assert result.results["status"] == "success"
# After
task = juju.run("foo/0", "backup", {"target": "/data"})
# The above call will raise if the action is not successful.
Replace status access:
# Before
app = ops_test.model.applications["foo"]
unit = app.units[0]
address = await unit.get_public_address()
# After
status = juju.status()
units = status.get_units("foo")
address = list(units.values())[0].address
Replace config calls:
# Before
await ops_test.model.applications["foo"].set_config({"key": "value"})
# After
juju.config("foo", {"key": "value"})
Replace markers:
@pytest.mark.abort_on_fail → remove (use pytest -x or --failfast at the CLI instead)
@pytest.mark.skip_if_deployed → @pytest.mark.juju_setup (skipped with --no-juju-setup)
Update dependencies — see Step 3.
Path B: pytest-jubilant 1.x → 2.0
The 2.0 release namespaces all options, fixtures, and markers under juju_. See references/jubilant-1x-to-2x.md for the complete mapping.
Key changes:
Fixture renames:
temp_model_factory → juju_factory
- The
juju fixture name is unchanged
CLI option renames:
--model → --juju-model
--keep-models → removed (use --no-juju-teardown instead)
Marker renames:
@pytest.mark.setup → @pytest.mark.juju_setup
@pytest.mark.teardown → @pytest.mark.juju_teardown
Removed helpers:
pytest_jubilant.pack() → removed; pack the charm before running tests
pytest_jubilant.get_resources() → removed; handle resources in your own fixtures
Update dependency pin: pytest-jubilant>=2,<3
Step 3: Update Dependencies
pyproject.toml
[dependency-groups]
integration = [
"jubilant",
"pytest>=9,<10",
"pytest-jubilant>=2,<3",
# Add any other test deps (requests, etc.)
]
Remove: pytest-operator, pytest-asyncio, juju (python-libjuju)
tox.ini
Update the integration test environment to use the new dependency group. If the project uses uv:
[testenv:integration]
runner = uv-venv-lock-runner
dependency_groups = integration
Lock files
After updating dependencies, regenerate lock files:
uv lock (if using uv)
poetry lock (if using Poetry)
Step 4: Verify
Run these checks after migrating. Fix any failures before proceeding.
Linting and formatting:
tox -e format # or: make format
tox -e lint # or: make lint
Import check — verify no remaining imports of old libraries:
grep -rn "from pytest_operator\|from juju\b\|from juju import\|import juju\b\|OpsTest" tests/
Integration tests (if a Juju environment is available):
tox -e integration
Review the diff — ensure changes are minimal and focused. The migration should not refactor test logic, add logging, or restructure files beyond what is needed.
Gotchas and Common Mistakes
These are the patterns that most frequently cause problems. Pay special attention to them.
Do not recreate the juju fixture — pytest-jubilant provides it automatically. If you see jubilant.temp_model() in a conftest.py fixture called juju, delete it. The built-in fixture handles model creation and teardown.
juju.wait() signature — The correct signature is juju.wait(ready, *, error=None, delay=1.0, timeout=None, successes=3). The ready argument is a callable that receives a Status and returns bool. The successes parameter requires N consecutive checks to pass (default 3). The timeout defaults to the Juju instance's wait_timeout (180s) when None.
jubilant.all_active checks ALL apps — If you only want to check specific apps, pass their names: jubilant.all_active(status, "foo", "bar"). Without app names, it checks every app in the model, which fails if a related app is still in "waiting" status.
juju.run() raises TaskError on failure — Unlike python-libjuju where you check result.status, Jubilant raises jubilant.TaskError if the action fails. Use pytest.raises(jubilant.TaskError) for expected failures.
juju.model is str | None — If you pass juju.model to something expecting str, you may get type errors. Check for None or assert first.
Multi-model testing — Use the juju_factory fixture (not temp_model_factory, which was the 1.x name). See the pytest-jubilant README for the pattern.
No build_charm() equivalent — Jubilant does not build charms. Pack the charm before tests run and pass the path via a fixture, environment variable, or CLI argument.
The --keep-models flag is gone in 2.0 — Use --no-juju-teardown instead. Tests that check for --keep-models need updating.
juju.cli() for escape hatches — For Juju operations not covered by the Jubilant API, use juju.cli("command", "arg1", "arg2"). Pass include_model=True if the command needs --model. Recommend to the user that they consider opening a Jubilant feature request for the missing functionality.
Quality Bar
The migration is complete when:
1---2name: migrate-to-jubilant3description: Migrate charm integration tests to Jubilant and pytest-jubilant 2.0. Handles migration from pytest-operator (python-libjuju) or from pytest-jubilant 1.x. Covers async removal, fixture replacement, API translation, dependency updates, and verification. license: Apache-2.0 compatibility: any version of pytest-operator, or v1.x of pytest-jubilant allowed-tools: Read4---56# Migrate Integration Tests to Jubilant + pytest-jubilant 2.078Migrate a charm's integration tests to use [Jubilant](https://documentation.ubuntu.com/jubilant/) and [pytest-jubilant](https://github.com/canonical/pytest-jubilant) 2.0. This skill handles two migration paths:910- **(A) pytest-operator → jubilant 2.0**: Full migration from python-libjuju async patterns11- **(B) pytest-jubilant 1.x → 2.0**: Namespace and API renames only1213## When to Use1415- Test files import `pytest_operator`, `OpsTest`, or `from juju` (python-libjuju) → path A16- Test files use `pytest-jubilant` <2.0 fixtures (`temp_model_factory`, `--keep-models`, `@pytest.mark.setup`) → path B17- The charm's `pyproject.toml` or `tox.ini` pins `pytest-jubilant<2` or `pytest-operator` → either path1819## Before You Start: Read the Source2021Install `jubilant` and `pytest-jubilant` and **read the source code** to understand the current API. This step is critical — it consistently produces better migrations than relying on documentation alone.2223```bash24uv pip install jubilant pytest-jubilant25```2627Then read these modules to understand the API surface:28- `jubilant.Juju` — the main class, with `deploy`, `wait`, `integrate`, `run`, `config`, `status`, `ssh`, `cli`, etc.29- `jubilant` module-level helpers — `all_active`, `all_blocked`, `any_error`, `temp_model`, etc.30- `jubilant.statustypes` — `Status`, `AppStatus`, `UnitStatus` and their attributes31- `jubilant.Task` — returned by `juju.run()` (actions) and `juju.exec()`, with `.results`, `.status`, `.success` (note that `.run()` will call `.raise_on_failure()` itself, so do not assert on `.success`)32- `pytest_jubilant` — the `juju` fixture, `juju_factory` fixture, markers (`juju_setup`, `juju_teardown`)3334Do not skip this step. Reading the source prevents hallucinated parameters and ensures you use the correct API signatures.3536## Step 1: Survey the Existing Tests3738Before editing any files, read all integration test files, `conftest.py`, helper modules, and dependency files. Note:39401. Which migration path applies (A or B)412. All test files that need changes423. Custom fixtures and helpers that wrap the old API434. Dependency declarations in `pyproject.toml`, `tox.ini`, `requirements.txt`445. CI workflows that reference test dependencies or commands456. Any existing patterns worth preserving (multi-model, cross-model relations, custom wait conditions)4647## Step 2: Migrate4849Work through files systematically. For each file, migrate completely before moving to the next.5051### Path A: pytest-operator → jubilant 2.05253See [references/pytest-operator-mapping.md](references/pytest-operator-mapping.md) for the complete API mapping table.5455Key changes:56571. **Remove all async/await** — Jubilant is synchronous. Remove `async def`, `await`, `@pytest.mark.asyncio`.58592. **Replace the fixture** — Delete any custom `ops_test` / `OpsTest` fixture. The `juju` fixture is provided by `pytest-jubilant` automatically when installed — do not recreate it with `temp_model()`.6061 ```python62 # Before63 async def test_deploy(ops_test: OpsTest): ...646566 # After67 def test_deploy(juju: jubilant.Juju): ...68 ```69703. **Replace charm building** — Jubilant does not build charms. The charm should be packed before tests run (via `charmcraft pack` or CI). Pass the `.charm` path to `juju.deploy()`.7172 ```python73 # Before74 charm = await ops_test.build_charm(".")75 await ops_test.model.deploy(charm)7677 # After — use a fixture or env var for the charm path78 juju.deploy(charm)79 ```80814. **Replace wait patterns** — `model.wait_for_idle()` becomes `juju.wait()` with a predicate.8283 ```python84 # Before85 await ops_test.model.wait_for_idle(apps=["foo"], status="active", timeout=600)8687 # After88 juju.wait(89 jubilant.all_active, timeout=60090 ) # Generally, wait for all apps, but you can specifically wait for "foo" if needed.91 ```92935. **Replace relation/integration calls**:94 ```python95 # Before96 await ops_test.model.add_relation("foo:db", "bar:db")9798 # After99 juju.integrate("foo:db", "bar:db")100 ```1011026. **Replace action calls**:103 ```python104 # Before105 action = await unit.run_action("backup", **{"target": "/data"})106 result = await action.wait()107 assert result.results["status"] == "success"108109 # After110 task = juju.run("foo/0", "backup", {"target": "/data"})111 # The above call will raise if the action is not successful.112 ```1131147. **Replace status access**:115 ```python116 # Before117 app = ops_test.model.applications["foo"]118 unit = app.units[0]119 address = await unit.get_public_address()120121 # After122 status = juju.status()123 units = status.get_units("foo")124 address = list(units.values())[0].address125 ```1261278. **Replace config calls**:128 ```python129 # Before130 await ops_test.model.applications["foo"].set_config({"key": "value"})131132 # After133 juju.config("foo", {"key": "value"})134 ```1351369. **Replace markers**:137 - `@pytest.mark.abort_on_fail` → remove (use `pytest -x` or `--failfast` at the CLI instead)138 - `@pytest.mark.skip_if_deployed` → `@pytest.mark.juju_setup` (skipped with `--no-juju-setup`)13914010. **Update dependencies** — see Step 3.141142### Path B: pytest-jubilant 1.x → 2.0143144The 2.0 release namespaces all options, fixtures, and markers under `juju_`. See [references/jubilant-1x-to-2x.md](references/jubilant-1x-to-2x.md) for the complete mapping.145146Key changes:1471481. **Fixture renames**:149 - `temp_model_factory` → `juju_factory`150 - The `juju` fixture name is unchanged1511522. **CLI option renames**:153 - `--model` → `--juju-model`154 - `--keep-models` → removed (use `--no-juju-teardown` instead)1551563. **Marker renames**:157 - `@pytest.mark.setup` → `@pytest.mark.juju_setup`158 - `@pytest.mark.teardown` → `@pytest.mark.juju_teardown`1591604. **Removed helpers**:161 - `pytest_jubilant.pack()` → removed; pack the charm before running tests162 - `pytest_jubilant.get_resources()` → removed; handle resources in your own fixtures1631645. **Update dependency pin**: `pytest-jubilant>=2,<3`165166## Step 3: Update Dependencies167168### pyproject.toml169170```toml171[dependency-groups]172integration = [173 "jubilant",174 "pytest>=9,<10",175 "pytest-jubilant>=2,<3",176 # Add any other test deps (requests, etc.)177]178```179180Remove: `pytest-operator`, `pytest-asyncio`, `juju` (python-libjuju)181182### tox.ini183184Update the integration test environment to use the new dependency group. If the project uses `uv`:185186```ini187[testenv:integration]188runner = uv-venv-lock-runner189dependency_groups = integration190```191192### Lock files193194After updating dependencies, regenerate lock files:195- `uv lock` (if using uv)196- `poetry lock` (if using Poetry)197198## Step 4: Verify199200Run these checks after migrating. Fix any failures before proceeding.2012021. **Linting and formatting**:203 ```bash204 tox -e format # or: make format205 tox -e lint # or: make lint206 ```2072082. **Import check** — verify no remaining imports of old libraries:209 ```bash210 grep -rn "from pytest_operator\|from juju\b\|from juju import\|import juju\b\|OpsTest" tests/211 ```2122133. **Integration tests** (if a Juju environment is available):214 ```bash215 tox -e integration216 ```2172184. **Review the diff** — ensure changes are minimal and focused. The migration should not refactor test logic, add logging, or restructure files beyond what is needed.219220## Gotchas and Common Mistakes221222These are the patterns that most frequently cause problems. Pay special attention to them.2232241. **Do not recreate the `juju` fixture** — `pytest-jubilant` provides it automatically. If you see `jubilant.temp_model()` in a `conftest.py` fixture called `juju`, delete it. The built-in fixture handles model creation and teardown.2252262. **`juju.wait()` signature** — The correct signature is `juju.wait(ready, *, error=None, delay=1.0, timeout=None, successes=3)`. The `ready` argument is a callable that receives a `Status` and returns `bool`. The `successes` parameter requires N consecutive checks to pass (default 3). The `timeout` defaults to the `Juju` instance's `wait_timeout` (180s) when `None`.2272283. **`jubilant.all_active` checks ALL apps** — If you only want to check specific apps, pass their names: `jubilant.all_active(status, "foo", "bar")`. Without app names, it checks every app in the model, which fails if a related app is still in "waiting" status.2292304. **`juju.run()` raises `TaskError` on failure** — Unlike python-libjuju where you check `result.status`, Jubilant raises `jubilant.TaskError` if the action fails. Use `pytest.raises(jubilant.TaskError)` for expected failures.2312325. **`juju.model` is `str | None`** — If you pass `juju.model` to something expecting `str`, you may get type errors. Check for `None` or assert first.2332346. **Multi-model testing** — Use the `juju_factory` fixture (not `temp_model_factory`, which was the 1.x name). See the pytest-jubilant README for the pattern.2352367. **No `build_charm()` equivalent** — Jubilant does not build charms. Pack the charm before tests run and pass the path via a fixture, environment variable, or CLI argument.2372388. **The `--keep-models` flag is gone in 2.0** — Use `--no-juju-teardown` instead. Tests that check for `--keep-models` need updating.2392409. **`juju.cli()` for escape hatches** — For Juju operations not covered by the Jubilant API, use `juju.cli("command", "arg1", "arg2")`. Pass `include_model=True` if the command needs `--model`. Recommend to the user that they consider opening a Jubilant feature request for the missing functionality.241242## Quality Bar243244The migration is complete when:245246- [ ] No imports of `pytest_operator`, `juju` (python-libjuju), or `pytest_asyncio` remain in test files247- [ ] No `async def` or `await` in test files (unless used for non-Juju async operations)248- [ ] The `juju` fixture comes from `pytest-jubilant`, not a custom `conftest.py` definition249- [ ] `pyproject.toml` / `tox.ini` pins `pytest-jubilant>=2,<3` and `jubilant`250- [ ] Old dependencies (`pytest-operator`, `juju`, `pytest-asyncio`) are removed251- [ ] Linting passes (`tox -e lint` or equivalent)252- [ ] The diff is minimal — no unnecessary refactoring, added logging, or structural changes253- [ ] Lock files are regenerated if the project uses them