|
| 1 | +# Testing Guide |
| 2 | + |
| 3 | +How to run, write, and maintain tests in the socratic-study-mentor workspace. |
| 4 | + |
| 5 | +## Running Tests |
| 6 | + |
| 7 | +The workspace has two packages with independent test suites: |
| 8 | + |
| 9 | +| Package | Tests | Path | |
| 10 | +|---------|-------|------| |
| 11 | +| studyctl | 293 | `packages/studyctl/tests/` | |
| 12 | +| agent-session-tools | 357 | `packages/agent-session-tools/tests/` | |
| 13 | + |
| 14 | +### Prerequisites |
| 15 | + |
| 16 | +Before running tests, install all workspace packages: |
| 17 | + |
| 18 | +```bash |
| 19 | +uv sync --all-packages |
| 20 | +``` |
| 21 | + |
| 22 | +Plain `uv sync` only installs root deps — you will get `ModuleNotFoundError` on `click`, `typer`, etc. if you skip this. |
| 23 | + |
| 24 | +### Invocation Paths |
| 25 | + |
| 26 | +**From workspace root (recommended):** |
| 27 | + |
| 28 | +```bash |
| 29 | +uv run pytest # both packages, 650 tests |
| 30 | +uv run pytest packages/studyctl/tests/ # studyctl only |
| 31 | +uv run pytest packages/agent-session-tools/tests/ # agent-session-tools only |
| 32 | +``` |
| 33 | + |
| 34 | +The root `pyproject.toml` sets `--import-mode=importlib` which is **load-bearing** — it prevents namespace conflicts between the two packages. Do not remove or override this. |
| 35 | + |
| 36 | +**From inside a package directory:** |
| 37 | + |
| 38 | +```bash |
| 39 | +cd packages/studyctl |
| 40 | +uv run pytest # uses studyctl's own pyproject.toml config |
| 41 | +``` |
| 42 | + |
| 43 | +Note: when running from a member directory, the root config's `--import-mode=importlib` is NOT applied (the member's own `addopts` take precedence). This is fine for focused development but the workspace root is the authoritative invocation path. |
| 44 | + |
| 45 | +### Useful Options |
| 46 | + |
| 47 | +```bash |
| 48 | +uv run pytest -x # stop on first failure |
| 49 | +uv run pytest -k "test_review" # run tests matching a pattern |
| 50 | +uv run pytest -m "not integration" # skip integration tests |
| 51 | +uv run pytest --tb=long # verbose tracebacks |
| 52 | +uv run pytest -v # show individual test names |
| 53 | +``` |
| 54 | + |
| 55 | +## Why No conftest.py in studyctl |
| 56 | + |
| 57 | +This is the single most important architectural constraint in the test suite. |
| 58 | + |
| 59 | +**The problem:** When pytest collects both packages from the workspace root, it loads conftest files from both `packages/agent-session-tools/tests/conftest.py` and (if it existed) `packages/studyctl/tests/conftest.py`. These conftest files register as pytest plugins via pluggy. With `--import-mode=importlib`, the two conftest modules would occupy the same plugin namespace, causing a registration conflict. |
| 60 | + |
| 61 | +**The solution:** agent-session-tools has a conftest (it was here first and holds shared fixtures like `temp_db`, `migrated_db`, `populated_db`). studyctl does NOT have a conftest. All studyctl fixtures are either: |
| 62 | + |
| 63 | +1. **Inlined** in each test file (the current pattern for most tests) |
| 64 | +2. **Imported from `_helpers.py`** — a plain Python module with factory functions (not pytest fixtures) |
| 65 | + |
| 66 | +**Never create `packages/studyctl/tests/conftest.py`.** If you need shared utilities for studyctl tests, add them to `packages/studyctl/tests/_helpers.py`. |
| 67 | + |
| 68 | +## Fixture Patterns |
| 69 | + |
| 70 | +### agent-session-tools: Use conftest |
| 71 | + |
| 72 | +Shared fixtures live in `packages/agent-session-tools/tests/conftest.py`: |
| 73 | + |
| 74 | +- `temp_db` — temporary SQLite DB with base schema, yields `(conn, db_path)` |
| 75 | +- `migrated_db` — same as `temp_db` but with all migrations applied |
| 76 | +- `populated_db` — `migrated_db` with a sample session and message inserted |
| 77 | +- `temp_config_dir` — temporary config directory structure |
| 78 | +- `sample_session_data` — dict with canonical session fields |
| 79 | +- `sample_message_data` — dict with canonical message fields |
| 80 | + |
| 81 | +Use these directly in your test function signatures: |
| 82 | + |
| 83 | +```python |
| 84 | +def test_export_creates_session(migrated_db, tmp_path): |
| 85 | + conn, db_path = migrated_db |
| 86 | + # conn has all migrations applied, ready for exporter testing |
| 87 | +``` |
| 88 | + |
| 89 | +### studyctl: Use _helpers.py or Inline |
| 90 | + |
| 91 | +Import factory functions from `_helpers.py` and wrap them in `@pytest.fixture`: |
| 92 | + |
| 93 | +```python |
| 94 | +import pytest |
| 95 | +from _helpers import make_review_db, make_isolated_config |
| 96 | + |
| 97 | + |
| 98 | +@pytest.fixture() |
| 99 | +def review_db(tmp_path): |
| 100 | + return make_review_db(tmp_path) |
| 101 | + |
| 102 | + |
| 103 | +@pytest.fixture(autouse=True) |
| 104 | +def isolated_config(tmp_path, monkeypatch): |
| 105 | + return make_isolated_config(tmp_path, monkeypatch) |
| 106 | + |
| 107 | + |
| 108 | +def test_something(review_db): |
| 109 | + # review_db is a Path to a temp SQLite file with review tables |
| 110 | + ... |
| 111 | +``` |
| 112 | + |
| 113 | +**Available helpers:** |
| 114 | + |
| 115 | +| Function | Returns | What it does | |
| 116 | +|----------|---------|--------------| |
| 117 | +| `make_review_db(tmp_path)` | `Path` | Creates a temp SQLite DB with review schema (WAL mode) | |
| 118 | +| `make_isolated_config(tmp_path, monkeypatch)` | `Path` | Redirects `CONFIG_DIR` and `_CONFIG_PATH` to temp dir | |
| 119 | + |
| 120 | +For simple, one-off fixtures, just inline them in the test file. Only use `_helpers.py` when the pattern recurs across multiple files. |
| 121 | + |
| 122 | +## Mock Conventions |
| 123 | + |
| 124 | +Two tools, each for a specific purpose: |
| 125 | + |
| 126 | +### `monkeypatch` — for attributes and environment |
| 127 | + |
| 128 | +Use `monkeypatch.setattr` to replace module-level constants and object attributes. Use `monkeypatch.setenv` for environment variables. These auto-revert when the test ends. |
| 129 | + |
| 130 | +```python |
| 131 | +def test_custom_config_dir(monkeypatch, tmp_path): |
| 132 | + monkeypatch.setattr("studyctl.settings.CONFIG_DIR", tmp_path) |
| 133 | + monkeypatch.setenv("STUDYCTL_CONFIG", str(tmp_path / "config.yaml")) |
| 134 | + # settings module now reads from tmp_path |
| 135 | +``` |
| 136 | + |
| 137 | +### `unittest.mock.patch` — for callables |
| 138 | + |
| 139 | +Use `patch` when you need to replace a function AND verify it was called. The `MagicMock`/`AsyncMock` objects provide `.assert_called_once_with()`, `.call_args`, etc. |
| 140 | + |
| 141 | +```python |
| 142 | +from unittest.mock import patch |
| 143 | + |
| 144 | +def test_pypi_check_offline(tmp_path): |
| 145 | + with patch("studyctl.doctor.updates._fetch_pypi_version", return_value="2.1.0"): |
| 146 | + result = check_update_available() |
| 147 | + assert result.status == "pass" |
| 148 | +``` |
| 149 | + |
| 150 | +For async code, use `AsyncMock`: |
| 151 | + |
| 152 | +```python |
| 153 | +from unittest.mock import AsyncMock, patch |
| 154 | + |
| 155 | +@patch("studyctl.content.notebooklm_client.asyncio.sleep", new_callable=AsyncMock) |
| 156 | +async def test_rate_limit(mock_sleep): |
| 157 | + await generate_with_rate_limit() |
| 158 | + mock_sleep.assert_called() |
| 159 | +``` |
| 160 | + |
| 161 | +### When to use which |
| 162 | + |
| 163 | +| Scenario | Tool | |
| 164 | +|----------|------| |
| 165 | +| Replace a module constant (`CONFIG_DIR`, `DEFAULT_DB`) | `monkeypatch.setattr` | |
| 166 | +| Set/override an env var | `monkeypatch.setenv` | |
| 167 | +| Replace a function to control its return value | `patch` (context manager) | |
| 168 | +| Replace a function AND verify call arguments | `patch` (decorator or context manager) | |
| 169 | +| Replace an async function | `patch` with `new_callable=AsyncMock` | |
| 170 | +| Replace `shutil.which` for tool detection | `patch` | |
| 171 | +| Replace `subprocess.run` for command isolation | `patch` | |
| 172 | + |
| 173 | +## Test Markers |
| 174 | + |
| 175 | +### `@pytest.mark.integration` |
| 176 | + |
| 177 | +For tests that require external infrastructure (tmux, network, real databases). These are excluded from fast local runs: |
| 178 | + |
| 179 | +```python |
| 180 | +import pytest |
| 181 | + |
| 182 | +pytestmark = pytest.mark.integration # marks entire module |
| 183 | + |
| 184 | +# Or per-test: |
| 185 | +@pytest.mark.integration |
| 186 | +def test_tmux_session_lifecycle(): |
| 187 | + ... |
| 188 | +``` |
| 189 | + |
| 190 | +Run integration tests explicitly: |
| 191 | + |
| 192 | +```bash |
| 193 | +uv run pytest -m integration # only integration tests |
| 194 | +uv run pytest -m "not integration" # skip integration tests |
| 195 | +``` |
| 196 | + |
| 197 | +### `pytest.importorskip` — for optional dependencies |
| 198 | + |
| 199 | +Tests that require optional packages use `importorskip` at module level. The test file is collected normally but all tests in it are **skipped** at runtime if the dependency is missing: |
| 200 | + |
| 201 | +```python |
| 202 | +# At the top of the file, before any other imports from the optional package: |
| 203 | +pytest.importorskip("pymupdf") |
| 204 | +pytest.importorskip("fastapi") |
| 205 | +``` |
| 206 | + |
| 207 | +Currently skipped groups: `pymupdf` (content splitter), `notebooklm` (notebooklm client), `fastapi` (web app/artefacts), `mcp` (MCP tools). |
| 208 | + |
| 209 | +## Adding a New Test |
| 210 | + |
| 211 | +### For studyctl |
| 212 | + |
| 213 | +1. Create `packages/studyctl/tests/test_<module>.py` |
| 214 | +2. Import helpers if needed: `from _helpers import make_review_db` |
| 215 | +3. Define fixtures inline or wrap helpers in `@pytest.fixture` |
| 216 | +4. If testing optional-dep code, add `pytest.importorskip("package")` at module level |
| 217 | +5. Do NOT create a conftest.py |
| 218 | + |
| 219 | +### For agent-session-tools |
| 220 | + |
| 221 | +1. Create `packages/agent-session-tools/tests/test_<module>.py` |
| 222 | +2. Use conftest fixtures (`temp_db`, `migrated_db`, etc.) directly in test signatures |
| 223 | +3. For exporter tests, use `migrated_db` — it has all migrations applied |
| 224 | +4. Add new shared fixtures to conftest.py if they'll be used by 3+ test files |
| 225 | + |
| 226 | +### Naming |
| 227 | + |
| 228 | +- Test files: `test_<module_name>.py` |
| 229 | +- Test classes: `Test<Feature>` (optional — flat functions are fine) |
| 230 | +- Test functions: `test_<behaviour_under_test>` |
| 231 | +- Fixtures: descriptive nouns (`review_db`, `isolated_config`, `projects_dir`) |
| 232 | + |
| 233 | +## Known TODOs |
| 234 | + |
| 235 | +- **Coverage configuration** — `pytest-cov` is in dev deps but studyctl has no `[tool.coverage.run]` config. Agent-session-tools does. |
| 236 | +- **`addopts` split** — running from workspace root uses `--import-mode=importlib` only. Running from a member directory uses that member's `addopts`. The root path is authoritative. |
| 237 | +- **Worktree tests** — 8 test files in `.claude/worktrees/feat+live-session-dashboard/` need merging (blocked on 5 source modules landing in main first). |
0 commit comments