Skip to content

Commit 95999a8

Browse files
authored
Setup testing (#37)
* Add initial README doc for tests - Partially generated to set up framework * Set up initial test fixtures * Update gh actions workflow * Update dependencies; add test markers * Update readme * Update test coverage in actions * Add os matrix for testing
1 parent 612f70b commit 95999a8

4 files changed

Lines changed: 212 additions & 6 deletions

File tree

.github/workflows/test.yaml

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,68 @@ on:
88
branches:
99
- main
1010
pull_request:
11+
workflow_dispatch:
12+
inputs:
13+
e2e:
14+
description: Run end-to-end tests
15+
required: false
16+
default: false
17+
type: boolean
1118

1219
jobs:
1320
unit:
14-
runs-on: ubuntu-latest
21+
runs-on: ${{ matrix.os }}
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
os: [ubuntu-latest, macos-latest, windows-latest]
1526
steps:
1627
- uses: actions/checkout@v6
1728
- name: Setup venv
1829
uses: ./.github/actions/setup-venv
19-
- name: Run tests
20-
id: run-tests
30+
- name: Run quick tests
31+
id: run-quick-tests
2132
run: >
2233
uv run pytest \
23-
--junitxml=pytest.xml \
24-
--cov-report=term-missing:skip-covered \
25-
--cov-report=xml:coverage.xml \
34+
-m "not slow and not full_pipeline"
35+
--junitxml=pytest-quick.xml \
2636
--cov=src tests \
2737
--log-level=DEBUG \
2838
--verbose
39+
40+
- name: Run slow tests (manual trigger)
41+
if: github.event_name == 'workflow_dispatch'
42+
run: >
43+
uv run pytest \
44+
-m "slow and not full_pipeline" \
45+
--junitxml=pytest-slow.xml \
46+
--cov=src tests \
47+
--cov-append \
48+
--verbose
49+
50+
- name: Run full pipeline tests (manual trigger)
51+
if: github.event_name == 'workflow_dispatch' && inputs.e2e == true
52+
run: >
53+
uv run pytest \
54+
-m "full_pipeline" \
55+
--junitxml=pytest-full.xml \
56+
--cov=src tests \
57+
--cov-append \
58+
--verbose
59+
60+
- name: Generate coverage report
61+
run: >
62+
uv run pytest \
63+
--cov-report=term-missing:skip-covered \
64+
--cov-report=xml:coverage.xml \
65+
--cov=src \
66+
--cov-report-only
67+
2968
- name: Upload coverage to Codecov
3069
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de
3170
with:
3271
token: ${{ secrets.CODECOV_TOKEN }}
72+
files: ./coverage.xml
3373
verbose: true
3474

3575
ruff:

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ docs = ["pdoc>=15.0.0"]
2727
[tool.pytest.ini_options]
2828
pythonpath = ["src"]
2929
testpaths = ["tests"]
30+
markers = [
31+
"unit: Fast (<1s) unit tests (e.g. utilities, BIDS parsing, file operations",
32+
"integration: Medium (1-5 min) integration tests with small/downsampled data",
33+
"slow: Long (30+ min) tests; skip on CI",
34+
"full_pipeline: End-to-end workflow tests; skip on CI"
35+
]
36+
37+
[tool.coverage.report]
38+
omit = ["src/rbc/core/resources/"]
3039

3140
[tool.mypy]
3241
ignore_missing_imports = true

tests/README.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Testing Guide
2+
3+
Quick guide for running tests in this project.
4+
5+
## Quick Start
6+
7+
```bash
8+
# Install dependencies
9+
pip install -e ".[dev]"
10+
11+
# Run fast tests (recommended during development)
12+
pytest -m unit
13+
14+
# Run all tests (except slow ones)
15+
pytest -m "not slow and not full_pipeline"
16+
17+
# Run everything
18+
pytest
19+
```
20+
21+
## Test Categories
22+
23+
| Marker | Speed |
24+
| --------------- | ------------ |
25+
| `unit` | <1s per test |
26+
| `integration` | 1-5 min |
27+
| `slow` | >5 min |
28+
| `full_pipeline` | 30+ min |
29+
30+
## Common Commands
31+
32+
### Development Workflow
33+
34+
```bash
35+
# Fast feedback loop (unit tests only)
36+
pytest -m unit
37+
38+
# Test specific file
39+
pytest tests/unit/test_bids_parsing.py
40+
41+
# Test specific function
42+
pytest tests/unit/test_bids_parsing.py::test_parse_subject_id
43+
```
44+
45+
<details>
46+
<summary><h2>Useful Options</h2></summary>
47+
48+
```bash
49+
# Stop on first failure
50+
pytest -x
51+
52+
# Show which tests are slowest
53+
pytest --durations=10
54+
55+
# Run tests matching a pattern
56+
pytest -k "motion_correction"
57+
```
58+
59+
</details>
60+
61+
## Test Data
62+
63+
Test data is stored in `tests/data/`.
64+
65+
## Coverage Requirements
66+
67+
- **Overall:** >85%
68+
- **Unit tests:** >90%
69+
- **Integration tests:** >80%
70+
71+
```bash
72+
# Check coverage
73+
pytest --cov=src --cov-report=term-missing
74+
75+
# Generate HTML report
76+
pytest --cov=src --cov-report=html
77+
```
78+
79+
## CI/CD
80+
81+
Tests run automatically on GitHub Actions:
82+
83+
- **On every push:** Unit tests + fast integration tests
84+
- **Manual trigger:** Full pipeline tests (slow)
85+
86+
To run the same tests as CI locally:
87+
88+
```bash
89+
pytest -m "not slow and not full_pipeline" \
90+
--cov=src \
91+
--cov-report=xml \
92+
--junitxml=pytest.xml
93+
```
94+
95+
## Directory Structure
96+
97+
```
98+
tests/
99+
├── conftest.py # Shared fixtures
100+
├── unit/ # Fast tests (<1s)
101+
├── integration/ # Medium tests (1-5 min)
102+
├── full_pipeline/ # Slow tests (30+ min)
103+
└── data/ # Test datasets
104+
```
105+
106+
## Best Practices
107+
108+
1. **Run unit tests frequently** - They're fast (<1s)
109+
2. **Run integration tests before committing** - Catch issues early
110+
3. **Use appropriate markers** - Auto-applied based on file location
111+
4. **Write descriptive test names** - `test_motion_correction_preserves_dimensions`
112+
5. **One assertion per test** - When practical
113+
6. **Use fixtures** - Don't repeat setup code
114+
7. **Keep tests independent** - No shared state between tests

tests/conftest.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Shared fixtures for tests data."""
2+
3+
from pathlib import Path
4+
from types import SimpleNamespace
5+
6+
import pytest
7+
8+
9+
@pytest.fixture
10+
def test_dataset_dir() -> Path:
11+
"""Return path to test dataset directory."""
12+
return Path(__file__).parent / "data" / "ds000001"
13+
14+
15+
@pytest.fixture
16+
def test_subject(test_dataset_dir: Path) -> SimpleNamespace:
17+
"""Return namespace containing file paths to test subject data."""
18+
subject_id = "01"
19+
task_id = "balloonanalogrisktask"
20+
21+
subject_dir = test_dataset_dir / f"sub-{subject_id}"
22+
anat_dir = subject_dir / "anat"
23+
func_dir = subject_dir / "func"
24+
25+
subject_data = SimpleNamespace(
26+
subject_id=subject_id,
27+
subject_dir=subject_dir,
28+
t1w=anat_dir / f"sub-{subject_id}_T1w.nii.gz",
29+
bold=func_dir / f"sub-{subject_id}_task-{task_id}_run-01_bold.nii.gz",
30+
tasks=test_dataset_dir / f"task-{task_id}_bold.json",
31+
events=func_dir / f"sub-{subject_id}_task-{task_id}_run-01_events.tsv",
32+
)
33+
34+
required_files = {
35+
"T1w": subject_data.t1w,
36+
"BOLD": subject_data.bold,
37+
"task": subject_data.tasks,
38+
"events": subject_data.events,
39+
}
40+
for name, fpath in required_files.items():
41+
if not fpath.exists():
42+
raise FileNotFoundError(f"{name} file not found: {fpath}")
43+
return subject_data

0 commit comments

Comments
 (0)