Skip to content

Commit c41dfde

Browse files
refactor: consolidate test infrastructure and document conventions (#1)
Dedup migrated_db fixture (7 copies → 1 in conftest), replace unused pytest.mark.live with integration marker, add studyctl pytest config, create _helpers.py for shared fixture factories, and write TESTING.md covering all conventions. 650 tests pass unchanged. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4cb1fc6 commit c41dfde

13 files changed

Lines changed: 325 additions & 64 deletions

docs/TESTING.md

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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).

packages/agent-session-tools/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ testpaths = ["tests"]
122122
pythonpath = ["src"]
123123
addopts = "-v --tb=short"
124124
markers = [
125-
"live: marks tests that require live database (deselect with '-m \"not live\"')",
125+
"integration: requires external infrastructure (tmux, real DB, network)",
126126
]
127127

128128
[tool.coverage.run]

packages/agent-session-tools/tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import pytest
88

9+
from agent_session_tools.migrations import migrate
10+
911

1012
@pytest.fixture
1113
def temp_db():
@@ -29,6 +31,14 @@ def temp_db():
2931
db_path.unlink(missing_ok=True)
3032

3133

34+
@pytest.fixture
35+
def migrated_db(temp_db):
36+
"""Return a temp_db with all migrations applied so exporter columns exist."""
37+
conn, db_path = temp_db
38+
migrate(conn)
39+
return conn, db_path
40+
41+
3242
@pytest.fixture
3343
def temp_config_dir(tmp_path):
3444
"""Create a temporary config directory."""

packages/agent-session-tools/tests/test_exporter_aider.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,13 @@
55
import pytest
66

77
from agent_session_tools.exporters.aider import AiderExporter
8-
from agent_session_tools.migrations import migrate
98

109

1110
# ---------------------------------------------------------------------------
1211
# Fixtures
1312
# ---------------------------------------------------------------------------
1413

1514

16-
@pytest.fixture()
17-
def migrated_db(temp_db):
18-
"""Return a (conn, db_path) tuple with all migrations applied."""
19-
conn, db_path = temp_db
20-
migrate(conn)
21-
return conn, db_path
22-
23-
2415
@pytest.fixture()
2516
def aider_tree(tmp_path: Path) -> Path:
2617
"""Create a fake project directory containing an Aider history file.

packages/agent-session-tools/tests/test_exporter_claude.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,13 @@
2121
import pytest
2222

2323
from agent_session_tools.exporters.claude import ClaudeCodeExporter
24-
from agent_session_tools.migrations import migrate
2524

2625

2726
# ---------------------------------------------------------------------------
2827
# Fixtures
2928
# ---------------------------------------------------------------------------
3029

3130

32-
@pytest.fixture()
33-
def migrated_db(temp_db):
34-
"""Return a temp_db with all migrations applied so exporter columns exist."""
35-
conn, db_path = temp_db
36-
migrate(conn)
37-
return conn, db_path
38-
39-
4031
@pytest.fixture()
4132
def projects_dir(tmp_path) -> Path:
4233
"""Create a fake Claude projects directory."""

packages/agent-session-tools/tests/test_exporter_gemini.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import agent_session_tools.exporters.gemini as gemini_mod
99
from agent_session_tools.exporters.gemini import GeminiCliExporter
10-
from agent_session_tools.migrations import migrate
1110

1211

1312
# ---------------------------------------------------------------------------
@@ -34,14 +33,6 @@ def _write_session_file(chats_dir: Path, session_id: str, messages: list[dict])
3433
# ---------------------------------------------------------------------------
3534

3635

37-
@pytest.fixture()
38-
def migrated_db(temp_db):
39-
"""Return (conn, db_path) with migrations applied."""
40-
conn, db_path = temp_db
41-
migrate(conn)
42-
return conn, db_path
43-
44-
4536
@pytest.fixture()
4637
def gemini_dir(tmp_path: Path, monkeypatch) -> Path:
4738
"""Create a fake Gemini CLI directory tree and point the module constant at it.

packages/agent-session-tools/tests/test_exporter_kiro.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
import pytest
1717

1818
from agent_session_tools.exporters.kiro import KiroCliExporter, _extract_text
19-
from agent_session_tools.migrations import migrate
2019

2120

2221
# ---------------------------------------------------------------------------
@@ -99,14 +98,6 @@ def _make_conversation(
9998
# ---------------------------------------------------------------------------
10099

101100

102-
@pytest.fixture()
103-
def migrated_db(temp_db):
104-
"""Return a temp_db with all migrations applied so exporter columns exist."""
105-
conn, db_path = temp_db
106-
migrate(conn)
107-
return conn, db_path
108-
109-
110101
@pytest.fixture()
111102
def kiro_db(tmp_path) -> Path:
112103
"""Create a fake Kiro CLI SQLite database with the v2 schema."""

packages/agent-session-tools/tests/test_exporter_litellm.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import pytest
88

99
from agent_session_tools.exporters.litellm import LitellmExporter
10-
from agent_session_tools.migrations import migrate
1110

1211

1312
# ---------------------------------------------------------------------------
@@ -137,14 +136,6 @@ def _create_empty_litellm_db(db_path: Path) -> None:
137136
# ---------------------------------------------------------------------------
138137

139138

140-
@pytest.fixture()
141-
def migrated_db(temp_db):
142-
"""Return (conn, db_path) with migrations applied."""
143-
conn, db_path = temp_db
144-
migrate(conn)
145-
return conn, db_path
146-
147-
148139
@pytest.fixture()
149140
def litellm_db(tmp_path: Path) -> Path:
150141
"""Create a fake LiteLLM metrics.db with one webhook_metrics row."""

0 commit comments

Comments
 (0)