Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .claude/rules
43 changes: 43 additions & 0 deletions dev/rules/python-testing-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
paths:
- "tests/integration/**/*.py"
---

# Integration test rules

Integration tests run against a real Infrahub instance via testcontainers. Use them for anything that depends on Infrahub's actual behaviour: node creation, branch operations, schema loading, queries. Do not use `httpx_mock` in integration tests.

## Test class structure

Inherit from `TestInfrahubDockerClient` and mix in a schema class when your tests require a custom schema. Use class-scoped fixtures for dataset setup so Infrahub is only populated once per test class:

```python
class TestInfrahubNode(TestInfrahubDockerClient, SchemaAnimal):
@pytest.fixture(scope="class")
async def base_dataset(
self,
client: InfrahubClient,
load_schema: None,
) -> None:
await client.branch.create(branch_name="branch01")

async def test_query_branches(self, client: InfrahubClient, base_dataset: None) -> None:
branches = await client.branch.all()
assert "main" in branches
```

## Client fixture

The `client` fixture provides an authenticated `InfrahubClient` connected to the testcontainer instance. Do not construct a client manually in integration tests.

## Cleanup

Clean up any nodes, branches, or schema changes created during a test class. Use class-scoped fixtures with `yield` to ensure teardown runs even on failure:

```python
@pytest.fixture(scope="class")
async def created_branch(self, client: InfrahubClient) -> AsyncGenerator[str, None]:
await client.branch.create(branch_name="test-branch")
yield "test-branch"
await client.branch.delete(branch_name="test-branch")
```
59 changes: 59 additions & 0 deletions dev/rules/python-testing-unit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
paths:
- "tests/unit/**/*.py"
---

# Unit test rules

Unit tests cover pure logic, data transformations, schema parsing, and error handling. Use `httpx_mock` to simulate HTTP responses at the transport boundary. Do not substitute a mocked unit test for behaviour that depends on Infrahub's actual server responses — write an integration test for that.

## HTTP mocking with `httpx_mock`

Add the module-level marker when any fixture or test in the file reuses mocked responses:

```python
pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True)
```

Use `is_reusable=True` on fixtures that serve multiple tests:

```python
@pytest.fixture
async def mock_branch_list(httpx_mock: HTTPXMock) -> HTTPXMock:
httpx_mock.add_response(
method="POST",
json={"data": {"Branch": [...]}},
match_headers={"X-Infrahub-Tracker": "query-branch-all"},
is_reusable=True,
)
return httpx_mock
```

## Testing both async and sync clients

Use the `BothClients` fixture from `tests/unit/sdk/conftest.py` when behaviour must be verified for both client variants. Parametrize over `["standard", "sync"]`:

```python
@pytest.mark.parametrize("client_type", ["standard", "sync"])
async def test_branch_list(clients: BothClients, client_type: str, mock_branch_list: HTTPXMock) -> None:
if client_type == "standard":
branches = await clients.standard.branch.all()
else:
branches = clients.sync.branch.all()
assert list(branches.keys()) == ["main", "branch01"]
```

Assert the actual expected value. Assertions like `assert result is not None` or `assert result` do not verify behaviour — they only confirm something was returned.

## Test file layout

Mirror the source structure:

```text
infrahub_sdk/client.py → tests/unit/sdk/test_client.py
infrahub_sdk/ctl/commands/get.py → tests/unit/ctl/test_get.py
```

## No external dependencies

Unit tests must not connect to external services, local file access is fine. If a test requires a running Infrahub instance, it belongs in `tests/integration/`.
69 changes: 69 additions & 0 deletions dev/rules/python-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
paths:
- "tests/**/*.py"
---

# Python testing rules

## No `unittest.mock`

Do not use `unittest.mock`, `MagicMock`, or `patch`. The only sanctioned mocking tools are:

- `httpx_mock` (pytest-httpx) — for intercepting HTTP calls at the transport layer
- `monkeypatch` — for patching stdlib functions (for example: `ssl.create_default_context`)

## Async tests

`asyncio_mode = "auto"` is configured globally. Do not add `@pytest.mark.asyncio`. Do not add loop scope markers manually — this is handled in `conftest.py`.

```python
# Correct
async def test_client_fetches_branch(httpx_mock: HTTPXMock) -> None:
...

# Wrong — decorator not needed
@pytest.mark.asyncio
async def test_client_fetches_branch(httpx_mock: HTTPXMock) -> None:
...
```

## Parametrized tests

Use a dataclass with `name` as the first field and pass it as the `id` in `pytest.param`. Always use keyword arguments when constructing cases:

```python
@dataclass
class BranchCase:
name: str
branch_name: str
expected_conflict: bool

BRANCH_CASES = [
BranchCase(name="no-conflict", branch_name="feature-x", expected_conflict=False),
BranchCase(name="conflict", branch_name="main", expected_conflict=True),
]

@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in BRANCH_CASES])
async def test_branch_conflict(case: BranchCase) -> None:
...
```

## Exception assertions

Always pass `match=` to `pytest.raises()`:

```python
with pytest.raises(NodeNotFoundError, match="Could not find node with id"):
await client.get(kind="NetworkDevice", id="missing")
```

## Fixtures and helpers

- Shared fixtures live in the nearest `conftest.py` to the tests that use them.
- JSON/YAML test data belongs in `tests/fixtures/` and is loaded via `read_fixture()` from `tests/helpers/fixtures.py`.
- Use `change_directory()` and `temp_repo_and_cd()` from `tests/helpers/utils.py` for filesystem-dependent tests.
- Do not duplicate fixture data inline when a fixture file already exists.

## Naming

Do not reference issue numbers, GitHub URLs, or ticket identifiers in test names or docstrings.
1 change: 1 addition & 0 deletions docs/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
1 change: 1 addition & 0 deletions infrahub_sdk/ctl/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
1 change: 1 addition & 0 deletions infrahub_sdk/pytest_plugin/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
1 change: 1 addition & 0 deletions tests/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md