Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ on:
branches:
- main
pull_request:
workflow_dispatch:
inputs:
e2e:
description: Run end-to-end tests
required: false
default: false
type: boolean

jobs:
unit:
Expand All @@ -16,20 +23,51 @@ jobs:
- uses: actions/checkout@v6
- name: Setup venv
uses: ./.github/actions/setup-venv
- name: Run tests
id: run-tests
- name: Run quick tests
id: run-quick-tests
run: >
uv run pytest \
--junitxml=pytest.xml \
--cov-report=term-missing:skip-covered \
--cov-report=xml:coverage.xml \
-m "not slow and not full_pipeline"
--junitxml=pytest-quick.xml \
--cov=src tests \
--log-level=DEBUG \
--verbose

- name: Run slow tests (manual trigger)
if: github.event_name == 'workflow_dispatch'
run: >
uv run pytest \
-m "slow and not full_pipeline" \
--junitxml=pytest-slow.xml \
--cov=src tests \
--cov-append \
--verbose

- name: Run full pipeline tests (manual trigger)
if: github.event_name == 'workflow_dispatch' && inputs.e2e == true
run: >
uv run pytest \
-m "full_pipeline" \
--junitxml=pytest-full.xml \
--cov=src tests \
--cov-append \
--verbose

- name: Generate coverage report
if: always()
run: >
uv run pytest \
--cov-report=term-missing:skip-covered \
--cov-report=xml:coverage.xml \
--cov=src \
--cov-report-only

- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
verbose: true

ruff:
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ docs = ["pdoc>=15.0.0"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
markers = [
"unit: Fast (<1s) unit tests (e.g. utilities, BIDS parsing, file operations",
"integration: Medium (1-5 min) integration tests with small/downsampled data",
"slow: Long (30+ min) tests; skip on CI",
"full_pipeline: End-to-end workflow tests; skip on CI"
]

[tool.coverage.report]
omit = ["src/rbc/core/resources/"]

[tool.mypy]
ignore_missing_imports = true
Expand Down
175 changes: 175 additions & 0 deletions tests/README.md
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe keep abridged version in README and move full guide to seperate markdown doc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or full guide as section in contributing md

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, make each less relevant subsection a dropdown. Not the biggest fan of that, but 🤷. I'll take a look to see what I did for other repos - probably not a lot 😂

Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Testing Guide

Quick guide for running tests in this project.

## Quick Start

```bash
# Install dependencies
pip install -e ".[dev]"

# Run fast tests (recommended during development)
pytest -m unit

# Run all tests (except slow ones)
pytest -m "not slow and not full_pipeline"

# Run everything
pytest
```

## Test Categories

| Marker | Speed |
| --------------- | ------------ |
| `unit` | <1s per test |
| `integration` | 1-5 min |
| `slow` | >5 min |
| `full_pipeline` | 30+ min |

## Common Commands

### Development Workflow

```bash
# Fast feedback loop (unit tests only)
pytest -m unit

# Test specific file
pytest tests/unit/test_bids_parsing.py

# Test specific function
pytest tests/unit/test_bids_parsing.py::test_parse_subject_id
```

### Before Committing

```bash
# Run unit + integration (skip slow tests)
pytest -m "not slow and not full_pipeline"

# With coverage report
pytest -m "not slow and not full_pipeline" --cov=src --cov-report=term-missing
```

## Useful Options

```bash
# Stop on first failure
pytest -x

# Show which tests are slowest
pytest --durations=10

# Run tests matching a pattern
pytest -k "motion_correction"
```

## Test Data

Test data is stored in `tests/data/`.

## Writing Tests

### Unit Test (Fast)

```python
import pytest

@pytest.mark.unit
def test_parse_bids_filename():
"""Test BIDS filename parsing."""
filename = "sub-01_task-rest_bold.nii.gz"
result = parse_bids_filename(filename)

assert result["sub"] == "01"
assert result["task"] == "rest"
```

### Integration Test (Uses Real Data)

```python
import pytest

@pytest.mark.integration
def test_motion_correction(sample_bold, temp_dir):
"""Test motion correction with real data."""
output = temp_dir / "corrected.nii.gz"

motion_correct(sample_bold, output)

assert output.exists()
img = nib.load(output)
assert img.shape[3] == 50 # Same number of volumes
```

## Available Fixtures

```python
# Mock data (fast, for unit tests)
def test_with_mock_data(mock_bold_image):
# Uses synthetic 10x10x10x5 volume
pass

# Real data (for integration tests)
def test_with_real_data(sample_bold):
# Uses actual 50-volume BOLD scan
pass

# Temporary directory
def test_with_temp_dir(temp_dir):
# Cleaned up automatically after test
output = temp_dir / "result.nii.gz"
pass
```

## Coverage Requirements

- **Overall:** >85%
- **Unit tests:** >90%
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious if this will be possible - maybe with the dry runner it will

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it will be, at least for unit testing...integration testing might take longer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant the 90% min

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shall find out. Arbitrary numbers 🤷‍♂️

- **Integration tests:** >80%

```bash
# Check coverage
pytest --cov=src --cov-report=term-missing

# Generate HTML report
pytest --cov=src --cov-report=html
```

## CI/CD

Tests run automatically on GitHub Actions:

- **On every push:** Unit tests + fast integration tests
- **Manual trigger:** Full pipeline tests (slow)

To run the same tests as CI locally:

```bash
pytest -m "not slow and not full_pipeline" \
--cov=src \
--cov-report=xml \
--junitxml=pytest.xml
```

## Directory Structure

```
tests/
├── conftest.py # Shared fixtures
├── unit/ # Fast tests (<1s)
├── integration/ # Medium tests (1-5 min)
├── full_pipeline/ # Slow tests (30+ min)
└── data/ # Test datasets
```

## Best Practices

1. **Run unit tests frequently** - They're fast (<1s)
2. **Run integration tests before committing** - Catch issues early
3. **Use appropriate markers** - Auto-applied based on file location
4. **Write descriptive test names** - `test_motion_correction_preserves_dimensions`
5. **One assertion per test** - When practical
6. **Use fixtures** - Don't repeat setup code
7. **Keep tests independent** - No shared state between tests
43 changes: 43 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Shared fixtures for tests data."""

from pathlib import Path
from types import SimpleNamespace

import pytest


@pytest.fixture
def test_dataset_dir() -> Path:
"""Return path to test dataset directory."""
return Path(__file__).parent / "data" / "ds000001"


@pytest.fixture
def test_subject(test_dataset_dir: Path) -> SimpleNamespace:
"""Return namespace containing file paths to test subject data."""
subject_id = "01"
task_id = "balloonanalogrisktask"

subject_dir = test_dataset_dir / f"sub-{subject_id}"
anat_dir = subject_dir / "anat"
func_dir = subject_dir / "func"

subject_data = SimpleNamespace(
subject_id=subject_id,
subject_dir=subject_dir,
t1w=anat_dir / f"sub-{subject_id}_T1w.nii.gz",
bold=func_dir / f"sub-{subject_id}_task-{task_id}_run-01_bold.nii.gz",
tasks=test_dataset_dir / f"task-{task_id}_bold.json",
events=func_dir / f"sub-{subject_id}_task-{task_id}_run-01_events.tsv",
)

required_files = {
"T1w": subject_data.t1w,
"BOLD": subject_data.bold,
"task": subject_data.tasks,
"events": subject_data.events,
}
for name, fpath in required_files.items():
if not fpath.exists():
raise FileNotFoundError(f"{name} file not found: {fpath}")
return subject_data
Loading