Skip to content

Commit d5f323f

Browse files
committed
cursor(rules) pytest best practices more
1 parent 6618c8e commit d5f323f

File tree

1 file changed

+79
-3
lines changed

1 file changed

+79
-3
lines changed

.cursor/rules/pytest-best-practices.mdc

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ alwaysApply: false
2525
## 3. Mocking Strategy: `monkeypatch` vs. `mocker`
2626
- **`monkeypatch` (pytest built-in):**
2727
- **Environment & Globals:** Use for modifying global settings, environment variables (`monkeypatch.setenv()`, `monkeypatch.delenv()`), the current working directory (`monkeypatch.chdir()`), or `sys.path`.
28-
- **Patching Attributes/Builtins:** Use `monkeypatch.setattr()` to modify attributes of classes/objects (e.g., `pathlib.Path.home`) or to replace functions/methods in external libraries or Python builtins.
28+
- **Patching Attributes/Builtins:** Use `monkeypatch.setattr()` to modify attributes of classes/objects (e.g., `pathlib.Path.home`) or to replace functions/methods in external libraries or Python builtins. When needing to control the home directory, prefer using established project fixtures like `user_path`, `home_path`, or `config_path`. These fixtures are responsible for correctly mocking `pathlib.Path.home()` internally, typically using `monkeypatch.setattr()`. Avoid direct `monkeypatch.setattr(pathlib.Path, "home", ...)` in individual tests if a suitable project fixture exists.
2929
- **Dictionary Items:** Use `monkeypatch.setitem()` and `monkeypatch.delitem()` for modifying dictionaries.
30-
- Refer to [Pytest Monkeypatch Documentation](https://docs.pytest.org/en/stable/how-to/monkeypatch.html).
30+
- Refer to [Pytest Monkeypatch Documentation](mdc:.dot-config/https:/docs.pytest.org/en/stable/how-to/monkeypatch.html).
3131
- **`mocker` (from `pytest-mock`):**
3232
- **Application Code:** Primarily use for patching functions, methods, or objects *within the `vcspull` application code itself* (e.g., `mocker.patch('vcspull.cli.add.some_function')`).
3333
- **Assertions:** Use `mocker` when you need to assert how a mock was called, its return values, or to simulate side effects for your application's internal logic.
@@ -44,6 +44,76 @@ alwaysApply: false
4444
- **Logging:** Use the `caplog` fixture to assert specific log messages when testing command output or internal logging.
4545
- **Error Handling:** Explicitly test for expected exceptions using `pytest.raises()`.
4646

47+
### Parameterized Test Structure
48+
For tests involving multiple scenarios managed by `@pytest.mark.parametrize`, use `typing.NamedTuple` to define the structure of each test case. This promotes readability and consistency.
49+
- Include a `test_id: str` field in the `NamedTuple` for clear test identification in pytest output.
50+
- Define a list of these `NamedTuple` instances for your test scenarios.
51+
- Use `pytest.mark.parametrize` with `ids=lambda tc: tc.test_id` (or similar) for descriptive test names.
52+
53+
```python
54+
# Example of Parameterized Test Structure
55+
import typing as t
56+
import pytest
57+
58+
class MyTestScenario(t.NamedTuple):
59+
test_id: str
60+
input_arg: str
61+
expected_output: str
62+
# ... other relevant parameters
63+
64+
TEST_SCENARIOS: list[MyTestScenario] = [
65+
MyTestScenario(test_id="case_alpha", input_arg="foo", expected_output="bar"),
66+
MyTestScenario(test_id="case_beta", input_arg="baz", expected_output="qux"),
67+
]
68+
69+
@pytest.mark.parametrize(
70+
MyTestScenario._fields,
71+
TEST_SCENARIOS,
72+
ids=[tc.test_id for tc in TEST_SCENARIOS] # Or ids=lambda tc: tc.test_id
73+
)
74+
def test_my_feature(
75+
input_arg: str, expected_output: str, # Corresponds to NamedTuple fields (test_id usually not passed)
76+
# ... other fixtures ...
77+
test_id: str, # if you need test_id inside the test, otherwise omit from signature
78+
) -> None:
79+
# ... test logic using input_arg and asserting against expected_output ...
80+
actual_output = f"processed_{input_arg}" # Replace with actual function call
81+
assert actual_output == expected_output
82+
# Note: test_id is automatically unpacked by parametrize if present in NamedTuple fields
83+
# and also passed as an argument if included in the test function signature.
84+
```
85+
86+
### Asserting CLI Output
87+
When testing CLI commands using `capsys` (for stdout/stderr) or `caplog` (for log messages), define expected output clearly. A common pattern is to check for the presence (or absence) of a list of substring "needles" within the captured output.
88+
89+
```python
90+
# Example of Asserting CLI Output
91+
# (within a test function that uses capsys or caplog)
92+
93+
# Assuming your NamedTuple for parameters includes:
94+
# expected_in_out: t.Optional[t.Union[str, list[str]]] = None
95+
# expected_not_in_out: t.Optional[t.Union[str, list[str]]] = None
96+
97+
# Example: Capturing stdout
98+
# captured_stdout = capsys.readouterr().out
99+
# output_to_check = captured_stdout
100+
101+
# Example: Capturing log messages
102+
# output_to_check = \"\\n\".join(rec.message for rec in caplog.records)
103+
104+
105+
# Generic checking logic:
106+
# if expected_in_out is not None:
107+
# needles = [expected_in_out] if isinstance(expected_in_out, str) else expected_in_out
108+
# for needle in needles:
109+
# assert needle in output_to_check, f"Expected '{needle}' in output"
110+
111+
# if expected_not_in_out is not None:
112+
# needles = [expected_not_in_out] if isinstance(expected_not_in_out, str) else expected_not_in_out
113+
# for needle in needles:
114+
# assert needle not in output_to_check, f"Did not expect '{needle}' in output"
115+
```
116+
47117
## 5. Code Coverage and Quality
48118
- **100% Coverage:** Aim for 100% test coverage for all new or modified code in `cli/add.py` and `cli/add_from_fs.py` (and any other modules).
49119
- **Test All Paths:** Ensure tests cover success cases, failure cases, edge conditions, and all logical branches within the code.
@@ -59,11 +129,17 @@ alwaysApply: false
59129
## 6. `vcspull`-Specific Considerations
60130
- **Configuration Files:**
61131
- When testing config loading, mock `find_home_config_files` appropriately.
62-
- Use helpers like `vcspull.tests.helpers.save_config_yaml` (which internally uses `write_config`) for creating test configuration files in a controlled manner.
132+
- **Always** use the project's helper functions (e.g., `vcspull.tests.helpers.write_config` or the higher-level `vcspull.tests.helpers.save_config_yaml`) to create temporary `.vcspull.yaml` files within your tests (typically in `tmp_path` or `config_path`). Avoid direct `yaml.dump` and `file.write_text` for config file creation to maintain consistency and reduce boilerplate.
63133
- **Path Expansion:** Be mindful of `expand_dir`. If testing logic that depends on its behavior, provide controlled mocks for it or ensure `home_path` / `cwd_path` fixtures correctly influence its resolution.
64134
- **Avoid Manual VCS Subprocesses:**
65135
- **Do not** use `subprocess.run(["git", ...])` or similar direct VCS command calls for setting up repository states in tests if a `libvcs` fixture or library function can achieve the same result.
66136
- The only exception is when testing a function that *itself* directly uses `subprocess` (e.g., `get_git_origin_url`).
67137
- Refactor tests like `test_add_from_fs_integration_with_libvcs` to use `create_git_remote_repo` from `libvcs` instead of manual `git init` calls via `subprocess`.
68138

139+
### Filesystem State Management and `shutil`
140+
- **`tmp_path` is Primary:** Rely on `pytest` fixtures like `tmp_path` (and derived fixtures such as `user_path`, `config_path`) for creating and managing temporary files and directories needed by tests. Pytest automatically handles the cleanup of these resources.
141+
- **Avoid Manual Cleanup of `tmp_path` Resources:** Manual cleanup of files/directories created within `tmp_path` using `shutil` (e.g., `shutil.rmtree()`) should generally be unnecessary, as pytest's fixture management handles this.
142+
- **`shutil` for Pre-conditions:** However, `shutil` operations (or standard library functions like `os.remove`, `pathlib.Path.unlink`, `pathlib.Path.mkdir`) can be legitimately used *during the setup phase of a test (before the code under test is executed)*. This is appropriate when you need to establish specific filesystem pre-conditions *within* the `tmp_path` environment that are crucial for the test scenario.
143+
- **Example:** Ensuring a directory does *not* exist before testing an operation that is expected to create it, or ensuring a specific file *does* exist. The usage of `if my_git_repo.is_dir(): shutil.rmtree(my_git_repo)` in `tests/test_cli.py` (within `test_sync_broken`) is an example of setting such a pre-condition: it ensures that the target directory for a repository is removed before `vcspull sync` is called, allowing tests to consistently observe cloning behavior.
144+
69145
By following these rules, we can ensure that new tests are robust, maintainable, consistent with the existing test suite, and effectively leverage the capabilities of `pytest` and `libvcs`.

0 commit comments

Comments
 (0)