diff --git a/.claude/rules b/.claude/rules new file mode 120000 index 00000000..068c692d --- /dev/null +++ b/.claude/rules @@ -0,0 +1 @@ +../dev/rules \ No newline at end of file diff --git a/dev/rules/python-testing-integration.md b/dev/rules/python-testing-integration.md new file mode 100644 index 00000000..b3f79bec --- /dev/null +++ b/dev/rules/python-testing-integration.md @@ -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") +``` diff --git a/dev/rules/python-testing-unit.md b/dev/rules/python-testing-unit.md new file mode 100644 index 00000000..6a27ca66 --- /dev/null +++ b/dev/rules/python-testing-unit.md @@ -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/`. diff --git a/dev/rules/python-testing.md b/dev/rules/python-testing.md new file mode 100644 index 00000000..ba97f547 --- /dev/null +++ b/dev/rules/python-testing.md @@ -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. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/infrahub_sdk/ctl/CLAUDE.md b/infrahub_sdk/ctl/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/infrahub_sdk/ctl/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/infrahub_sdk/pytest_plugin/CLAUDE.md b/infrahub_sdk/pytest_plugin/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/infrahub_sdk/pytest_plugin/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/tests/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md