Testing¶
ibcli uses pytest with httpx.MockTransport for its primary test suite. A real-grid integration suite lives in
tests/integration/ - see Real Grid Status and the Real-grid integration
tests section below.
Running the tests¶
# From the repo root
pytest python/tests/
# With verbose output
pytest python/tests/ -v
# Run a specific file
pytest python/tests/test_commands_zone.py -v
# Run tests matching a pattern
pytest python/tests/ -k "zone"
Test organisation¶
python/tests/
├── test_parser.py # Pure logic: abbrev, expand_word, expand_line, aliases
├── test_completer.py # IbcliCompleter: completion yields
├── test_all_handlers_sweep.py # Every registered handler: must not leak exceptions
├── test_dispatcher.py # process_line: aliases, errors, idempotent mode
├── test_commands_zone.py # Zone/record handlers against mocked WAPI
├── test_commands_network.py # Network handlers
├── test_commands_dhcp.py # DHCP handlers
├── test_commands_admin.py # Admin/RADIUS handlers
├── test_commands_grid.py # Grid handlers
├── test_commands_ea.py # Extensible attribute handlers
├── test_commands_fileops.py # File operation handlers
├── test_commands_cert.py # Certificate handlers
├── test_commands_template.py # Template handlers
└── test_repl.py # End-to-end: process_line through full stack
Parser tests (no network)¶
Parser tests need no mocking - they exercise pure Python logic:
from ibcli.parser import abbrev, expand_word, expand_line
def test_abbrev_unique():
result = abbrev(["configure", "show", "set"])
assert result["co"] == "configure"
assert result["sh"] == "show"
# "s" is ambiguous between show and set → not in result
assert "s" not in result
def test_expand_line_zone_add():
expanded, error, match_line = expand_line("co z a foo.com")
assert error == ""
assert expanded == "configure zone add foo.com"
assert match_line == "configure zone add <zone>"
def test_unknown_word():
expanded, error, match_line = expand_line("show badword")
assert "Unknown argument at marker" in error
SDK client tests (httpx.MockTransport)¶
The CLI talks to NIOS through ibx-nios-sdk, so tests inject a fake
transport rather than patching a session object. tests/conftest.py
provides make_client(handler), which builds a real NiosClient wired to
an httpx.MockTransport. The handler returns a response, or None to fall
through to the default that answers the SDK's login probe.
import httpx
import pytest
from ibx_nios_sdk import NiosError
from tests.conftest import make_client
async def test_create_returns_ref():
def handler(request: httpx.Request) -> httpx.Response | None:
if request.method == "POST" and request.url.path.endswith("/zone_auth"):
return httpx.Response(201, json="zone_auth/abc123")
return None # delegate to the default login handler
async with make_client(handler) as client:
ref = await client.dns.zone_auth.create({"fqdn": "foo.com"})
assert "zone_auth/abc123" in str(ref)
async def test_error_propagation():
def handler(request: httpx.Request) -> httpx.Response | None:
if request.method == "GET" and request.url.path.endswith("/zone_auth"):
return httpx.Response(400, json={
"Error": "IBDataError", "text": "Not found",
"code": "Client.Ibap.Data",
})
return None
async with make_client(handler) as client:
with pytest.raises(NiosError) as exc:
[z async for z in client.dns.zone_auth.list(fqdn="missing.com")]
assert "Not found" in str(exc.value)
List responses must use the paged shape the SDK expects -
{"result": [...]} - not a bare array. Most command test modules define a
_list() helper for this.
Command handler tests¶
Handler tests build a Context around a mocked client and call
process_line. asyncio_mode = "auto" is set in pyproject.toml, so
async def tests need no decorator.
from contextlib import asynccontextmanager
import httpx
from ibcli.context import Context
from ibcli.dispatcher import process_line
from tests.conftest import make_client
@asynccontextmanager
async def connected_ctx(handler=None):
async with make_client(handler) as client:
yield Context(client=client, online=True, host="grid.test")
async def test_add_zone():
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response | None:
seen.append(request)
if request.method == "POST" and "zone_auth" in request.url.path:
return httpx.Response(201, json="zone_auth/abc123")
return None
async with connected_ctx(handler) as ctx:
await process_line("configure zone add example.com", ctx)
posts = [r for r in seen if r.method == "POST" and "zone_auth" in r.url.path]
assert len(posts) == 1
async def test_show_zone(capsys):
def handler(request: httpx.Request) -> httpx.Response | None:
if request.method != "GET":
return None
if "zone_auth" in request.url.path:
return httpx.Response(200, json={"result": [
{"_ref": "zone_auth/abc", "fqdn": "example.com",
"view": "default"},
]})
# `show zone` also sweeps forward/delegated/stub - stub them empty.
return httpx.Response(200, json={"result": []})
async with connected_ctx(handler) as ctx:
await process_line("show zone example.com", ctx)
assert "fqdn=example.com" in capsys.readouterr().out
Two whole-surface sweeps back these up:
tests/test_all_handlers_sweep.py dispatches every registered handler
against a permissive mock and fails if any leaks an exception, and
tests/test_not_connected_sweep.py covers the ctx.client is None guard.
A new command is covered by both automatically.
Completer tests¶
from prompt_toolkit.document import Document
from ibcli.completer import IbcliCompleter
def completions(text):
c = IbcliCompleter()
doc = Document(text, len(text))
return [comp.text for comp in c.get_completions(doc, None)]
def test_empty_line():
result = completions("")
assert "configure" in result
assert "show" in result
def test_configure_prefix():
result = completions("co ")
assert "zone" in result
assert "server" in result
def test_unknown_word_no_completions():
result = completions("show unknownword ")
assert result == []
Test count¶
Current count: 3,284 tests passing in the mocked suite, all in ~3 seconds.
Real-grid integration tests¶
Overview¶
tests/integration/ contains tests that execute against a live NIOS grid.
They are skipped by default when the required env vars are absent, so
pytest tests/ always shows the full mocked count with no failures.
Every test in tests/integration/ is automatically marked real_grid.
Environment variables¶
| Variable | Description |
|---|---|
IBCLI_TEST_GRID |
Grid master IP or hostname |
IBCLI_TEST_USER |
WAPI user (usually admin) |
IBCLI_TEST_PASS |
WAPI password |
Running the integration suite¶
# Run all integration tests against a real grid
IBCLI_TEST_GRID=192.0.2.40 IBCLI_TEST_USER=admin IBCLI_TEST_PASS='<password>' \
pytest tests/integration -v
# Run only integration tests by marker (skipped if env vars not set)
pytest -m real_grid -v
# Show what would run without executing
pytest tests/integration --collect-only
What they cover¶
| Test file | What it locks in |
|---|---|
test_provision_workflow.py |
Member add (plain and pre-provisioned), gateway warning, license case, failover with FQDN peers, network+range+failover sequencing, fileop force-download header |
test_full_provision.py |
Full end-to-end: 2 members → failover → network with members → failover-backed range; all lessons exercised in sequence |
When to add a new integration test¶
Add a test in tests/integration/ whenever:
- A real-grid bug is fixed that mocked tests didn't catch (the mocked test should also be updated to match the real behaviour).
- A new command family is verified against a live grid for the first time.
- A NIOS version upgrade reveals a new API quirk.
The pattern to follow:
class TestMyNewFeature:
def test_thing_that_failed_on_real_grid(
self, grid_ctx, test_prefix, cleanup_<resource>, capsys
):
# ... create resource, assert, cleanup fixture handles deletion
process_line("configure ...", grid_ctx)
out = capsys.readouterr().out
assert "Error" not in out
# Verify via WAPI GET
results = grid_ctx.session.get("objtype", field=value)
assert len(results) == 1
Always use the test_prefix fixture for resource names to avoid collisions
between parallel runs, and register every created resource with a cleanup_*
fixture so the grid is left clean even when a test fails.
Pre-commit workflow¶
Mocked tests alone aren't enough
Every real-grid bug we've hit (415 Unsupported Media Type,
primary_server_type missing, FQDN-not-IP, pre_provisioning rejected
in POST, dict-vs-string ref responses) passed all the mocked tests.
Mocked tests verify we send what we think WAPI wants; only the
integration suite verifies we send what NIOS actually accepts.
Before committing changes that touch any of the following, run the integration suite against a lab grid:
python/src/ibcli/session.py- HTTP layer, auth, fileop, ref coercionpython/src/ibcli/commands/grid.py- member CRUD, pre-provisioningpython/src/ibcli/commands/dhcp.py- ranges, fixed, failoverpython/src/ibcli/commands/network.py- network CRUD, move, IPv4/IPv6 routingpython/src/ibcli/commands/fileops.py- upload/downloadpython/src/ibcli/commands/cert.py- certificate operations- Any code that constructs a WAPI request body shape
Concrete command:
cd python
IBCLI_TEST_GRID=<lab-ip> IBCLI_TEST_USER=<user> IBCLI_TEST_PASS=<pass> \
pytest tests/integration -v
Both must pass:
pytest tests/→ all mocked tests greenpytest tests/integration -vwith env vars → all integration tests green
If you don't have access to a lab grid, push your branch and ask a maintainer with lab access to run the suite before merging. Don't merge WAPI-shape changes based on mocked-only coverage.
Adding a test after fixing a real-grid bug¶
Every real-grid bug fix should come with a companion integration test. The reason: the next regression of that same bug won't be caught by the mocked suite (mocks were what let it ship in the first place). Flow:
- Reproduce the bug against the grid, capture the WAPI error.
- Fix the code; update the mocked test to match the corrected shape.
- Add an integration test in
tests/integration/that would have failed before the fix. Name it for the lesson, e.g.test_failover_uses_fqdn_not_ip. - Run both suites - mocked and integration - green before committing.
- Update
reference/real-grid-status.mdif this elevates a command from "mocked only" to "verified on real grid."
Live-grid audit tooling¶
Two harnesses in scripts/ complement the pytest suites and run only
against a live NIOS grid. Use them when you want coverage the mocked tests
can't provide: "do our commands actually work end-to-end?" and "do show
commands produce useful output?"
scripts/sweep-show.sh - show-command sweep¶
Generates the full list of no-arg show commands from ibcli -l, runs
them all against a live grid, and classifies each error as:
- friendly usage hint - our own
"Error: X required (usage: …)"messages for commands that need an arg - grid-config - feature disabled / service offline on the grid
- suspected CLI bug - everything else
The script exits 1 if any suspected bugs appear, making it suitable for CI once a lab grid is wired up.
scripts/sweep-show.sh -s <host> -u <user> -p <pw>
# or env-based
IBCLI_HOST=... IBCLI_USER=... IBCLI_PASS=... scripts/sweep-show.sh
Artifacts land in a per-run mktemp dir (overridable with -o <dir>):
sweep.ibcli (commands), sweep.out (raw output), summary.tsv
(per-command status + preview), report.txt (human-readable summary).
scripts/smoke/ - object-build smoke harness¶
Generates, executes, verifies, and tears down a comprehensive smoke
population of ~10,000 objects across ~30 object types. Parameterised by
scale (tiny ≈ 100 objects, small ≈ 1,000, full ≈ 10,000) and by
phase (1 = infra, 2 = DHCP, 3 = zones, …, 10 = DHCP extras + templates).
# Full cycle - build, verify counts, tear down
scripts/smoke/smoke.sh -s <host> -u <user> -p <pw> --scale full --tag v1 --all
# Build only (leave objects on grid for manual inspection)
scripts/smoke/smoke.sh -s <host> -u <user> -p <pw> --scale tiny --build
# Phase-by-phase
scripts/smoke/smoke.sh ... --phases 3,4 --build # zones + records only
# Point at a grid whose name isn't the default, and pre-provision 200 members
scripts/smoke/smoke.sh -s <host> -u <user> -p <pw> \
--scale full --grid Infoblox --members 200 --build
--grid must match what show grid reports on the target: phase 0 emits
configure grid <name> member add ..., which fails outright on a name
mismatch. It defaults to $IBCLI_SMOKE_GRID, then Migration.
--members overrides the scale's member count for build, verify and
teardown together - passing it to only one of the three leaves
pre-provisioned members behind. Pre-provisioned members are database
records on a dedicated subnet ($IBCLI_SMOKE_MEMBER_NET, default
10.63.50), so their addresses never need to be reachable and cannot
collide with the real grid master. Use --members 0 on a grid that cannot
admit members - see Pre-provisioning members needs a valid grid-wide
licence;
DHCP failover pairs are skipped automatically when there are fewer than two
members.
The harness runs ibcli from $IBCLI if set, else the repo's
.venv/, else poetry run - so it works without Poetry installed.
Members are torn down last for a reason
NIOS refuses to delete a member anything still points at - "cannot be
removed because it is the DHCP failover primary in <fo>" (phase 2) or
"is a primary server for NS Group <group>" (phase 1). The full
teardown order already accounts for this, but
--teardown --phases 0 on its own will fail on exactly those members.
teardown.py warns when phase 0 is requested without phases 1 and 2.
All smoke-created objects carry the smoke- name prefix, and objects that
support EAs additionally carry the Smoke=<tag> EA. The teardown phase is
reverse-dependency-ordered (notifications → admin → DTC → RPZ → hosts →
zones → DHCP extras → DHCP → infra → members), so cascades resolve
cleanly. Teardown is opt-in via --teardown or --all - it never runs by
default.
Why it's useful: the sweep-show harness verifies show commands work
on whatever state the grid happens to have. The smoke harness builds
known state so a re-run comparison is meaningful ("the same show
network that returned 40 rows after build returns 0 after teardown").
Every real CLI bug caught during this session (grammar gaps, missing
required WAPI fields, wrong WAPI object names, broken return-field lists)
was surfaced by running one of these two scripts.