Testing
UI-CLI has three tiers of tests, each answering a different question:
| Tier | Location | Question it answers | Needs | Speed |
|---|---|---|---|---|
| Unit | tests/unit/ |
Does each function behave correctly in isolation? | Nothing | < 1s |
| Synthetic regression | tests/regression/ |
Does the real client work end-to-end over HTTP? | Nothing | < 1s |
| Integration | tests/integration/ |
Does it work against real UniFi hardware and the cloud API? | Real credentials | Network-bound |
Running Tests
# Unit + synthetic regression (the default local loop — no setup needed)
pytest tests/unit tests/regression
# Everything, including integration (skips integration if credentials are missing)
pytest
# Integration only (requires a real controller / API key in .env)
pytest tests/integration
# With coverage
pytest --cov=ui_cli
No credentials, no problem
Unit and synthetic regression tests require no configuration — no
.env, no controller, no network beyond localhost. Integration tests
auto-skip when UNIFI_API_KEY, UNIFI_CONTROLLER_URL,
UNIFI_CONTROLLER_USERNAME, or UNIFI_CONTROLLER_PASSWORD are unset.
Unit Tests
tests/unit/ covers individual components with mocked boundaries:
| File | Covers |
|---|---|
test_local_client.py |
Local controller client: init, API prefixes, login payloads, MFA/TOTP error paths, session reuse |
test_site_manager_client.py |
Cloud (Site Manager) API client |
test_groups.py |
Client group management and auto-group rules |
test_formatting.py |
Data formatting helpers |
test_output.py |
Table/JSON/CSV output |
HTTP calls are mocked with unittest.mock (AsyncMock for async methods),
and settings are patched per-test, so nothing touches your real
configuration. Shared fixtures — canned API responses like
mock_local_clients_response — live in tests/conftest.py.
Synthetic Regression Tests
tests/regression/test_synthetic_udm.py is the middle tier: it exercises the
real, unmocked UniFiLocalClient end-to-end against a fake UDM
controller running in-process.
How it works
┌──────────────────────┐ real HTTP over ┌──────────────────────────┐
│ UniFiLocalClient │ localhost socket │ FakeUDMController │
│ (production code, │ ◄─────────────────► │ (stdlib http.server on │
│ nothing mocked) │ │ a random port) │
└──────────────────────┘ └──────────────────────────┘
FakeUDMController is a ThreadingHTTPServer bound to 127.0.0.1 on a
random port, speaking the actual UDM wire protocol:
- Answers the
/api/users/selfdetection probe with401(how real UDMs identify themselves) - Implements
/api/auth/login— checks the password, and when MFA mode is on, returns499 MFA_AUTH_REQUIREDunless the correct TOTPtokenis in the payload - Issues session cookies and an
X-CSRF-Tokenheader on successful login - Serves authenticated data endpoints (
/stat/sta,/stat/device) behind cookie validation - Records every login payload and API request, so tests assert on what actually went over the wire
Because the real client runs unmodified, these tests catch regressions that unit tests structurally cannot: wrong URL construction, cookie handling, header propagation, session file round-trips, and retry logic.
Covered scenarios
| Class | Scenario |
|---|---|
TestSyntheticLogin |
Full login + data fetch with UDM auto-detection and session persistence |
Login payload omits token when no TOTP is configured |
|
Wrong password → 403 → clear "Invalid username or password" error |
|
TestSyntheticMFA |
MFA enabled, no TOTP → "MFA token required" |
| MFA enabled, stale TOTP → "invalid or expired" | |
| MFA enabled, valid TOTP → full flow; token on the wire, CSRF echoed | |
TestSyntheticSessionCache |
New client instance reuses the cached session without re-login (TOTP needed only once) |
Server revokes sessions → client re-logins automatically on 401 |
Isolation guarantees
An autouse fixture keeps every test hermetic:
HOMEis pointed at a per-test temp directory, so the session cache goes to a throwaway path — your real~/.config/ui-cli/session.jsonis never read or writtenUNIFI_CONTROLLER_TOTPfrom your local.envis neutralized, so results don't depend on your environment
Adding a scenario
- Give
FakeUDMControllerany new behavior it needs (a new endpoint in the handler, or a toggle likemfa_enabled) - Build a client with the
make_client(fake_udm, ...)helper — it points the real client at the fake server - Assert on the outcome and on the recorded traffic
(
fake_udm.login_requests,fake_udm.api_requests) to pin the wire behavior, not just the return value
async def test_my_new_scenario(self, fake_udm):
fake_udm.mfa_enabled = True
client = make_client(fake_udm, totp="424242")
clients = await client.list_clients()
assert len(clients) == 2
assert fake_udm.login_requests[0]["token"] == "424242"
Integration Tests
tests/integration/ runs the same clients against real infrastructure:
a live UniFi controller on your network and the cloud Site Manager API.
They are marked @pytest.mark.integration and skip automatically unless the
required environment variables are set (see
Configuration).
Use these before a release or after changes to request/response handling — they are the only tier that can catch changes in UniFi's actual API behavior.
Integration tests touch real systems
They authenticate against your actual controller and read live data. Read-only endpoints are used, but run them against a controller you own.