Skip to content

Commit 82c2008

Browse files
authored
Add anatomical tests (#43)
* Make get_base_entities more flexible, add tests * Add fixture to conftest, add tests for utils.rename * Add all markers, setup niwrap for testing * Add e2e test for anatomical * Add integration tests * Also add __init__.py so test module names don't have to be unique * Add runner option to actions tests * Update actions workflow (#44) * Remove upload of coverage * Split up tests - Unit tests on all OSes - Integration (and e2e) only on ubuntu * Fix marker call for slow tests * switch to non-mutable iterable
1 parent 2fe3b11 commit 82c2008

12 files changed

Lines changed: 249 additions & 37 deletions

File tree

.github/workflows/test.yaml

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,54 +27,55 @@ jobs:
2727
- uses: actions/checkout@v6
2828
- name: Setup venv
2929
uses: ./.github/actions/setup-venv
30-
- name: Run quick tests
31-
id: run-quick-tests
30+
31+
- name: Run non-ubuntu quick tests
32+
id: run-unit-tests
33+
if: matrix.os != 'ubuntu-latest'
34+
shell: bash
35+
run: >
36+
uv run pytest \
37+
-m "unit" \
38+
--junitxml=pytest-quick.xml \
39+
--cov=src tests \
40+
--log-level=DEBUG \
41+
--verbose
42+
- name: Run ubuntu quick tests
43+
id: run-quick-ubuntu-tests
44+
if: matrix.os == 'ubuntu-latest'
3245
shell: bash
3346
run: >
3447
uv run pytest \
3548
-m "not slow and not full_pipeline" \
49+
--runner=docker \
3650
--junitxml=pytest-quick.xml \
3751
--cov=src tests \
3852
--log-level=DEBUG \
3953
--verbose
4054
41-
- name: Run slow tests (manual trigger)
42-
if: github.event_name == 'workflow_dispatch'
55+
- name: Run slow tests
56+
if: matrix.os == 'ubuntu-latest' && github.event_name == 'workflow_dispatch'
4357
shell: bash
4458
run: >
4559
uv run pytest \
46-
-m "slow and not full_pipeline" \
60+
-m "slow" \
61+
--runner=docker \
4762
--junitxml=pytest-slow.xml \
4863
--cov=src tests \
4964
--cov-append \
5065
--verbose
5166
52-
- name: Run full pipeline tests (manual trigger)
53-
if: github.event_name == 'workflow_dispatch' && inputs.e2e == true
67+
- name: Run full pipeline tests
68+
if: matrix.os == 'ubuntu-latest' && github.event_name == 'workflow_dispatch' && inputs.e2e == true
5469
shell: bash
5570
run: >
5671
uv run pytest \
5772
-m "full_pipeline" \
73+
--runner=docker \
5874
--junitxml=pytest-full.xml \
5975
--cov=src tests \
6076
--cov-append \
6177
--verbose
6278
63-
- name: Generate coverage report
64-
shell: bash
65-
run: >
66-
uv run pytest \
67-
--cov-report=term-missing:skip-covered \
68-
--cov-report=xml:coverage.xml \
69-
--cov=src
70-
71-
- name: Upload coverage to Codecov
72-
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de
73-
with:
74-
token: ${{ secrets.CODECOV_TOKEN }}
75-
files: ./coverage.xml
76-
verbose: true
77-
7879
ruff:
7980
runs-on: ubuntu-latest
8081
steps:

src/rbc/core/utils.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import shutil
44
import tempfile
5-
from collections.abc import Iterator
5+
from collections.abc import Iterable, Iterator
66
from contextlib import contextmanager
77
from pathlib import Path
88

@@ -29,20 +29,26 @@ def create_copy(in_file: str | Path) -> Iterator[Path]:
2929
shutil.rmtree(tmp_dir)
3030

3131

32-
def get_base_entities(in_file: Path) -> dict[str, str]:
32+
def get_base_entities(
33+
in_file: Path, base_entities: Iterable[str] = ("sub", "ses", "run")
34+
) -> dict[str, str]:
3335
"""Parse base BIDS entities to be used for file naming.
3436
3537
Args:
3638
in_file: Path to parse bids entities for.
39+
base_entities: List of base entities to extract (default: ['sub', 'ses' run'])
3740
3841
Returns:
3942
A string-mapping of BIDS entities to values.
4043
"""
4144
file_entities = parse_bids_entities(in_file)
42-
return {k: v for k, v in file_entities.items() if k in ["sub", "ses", "run"]}
45+
return {k: v for k, v in file_entities.items() if k in base_entities}
4346

4447

4548
def rename(in_file: str | Path, new_name: str | Path) -> Path:
4649
"""Rename a file, keeping it in the same directory."""
4750
in_file = Path(in_file)
48-
return in_file.rename(in_file.with_name(Path(new_name).name))
51+
new_path = in_file.with_name(Path(new_name).name)
52+
if new_path.exists():
53+
raise FileExistsError(f"Target file already exists: {new_path}")
54+
return in_file.rename(new_path)

tests/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Quick guide for running tests in this project.
44

5+
> [!NOTE]
6+
> Integration and full pipeline tests likely requires specific
7+
> neuroimaging tools that can be called with `niwrap`. To perform
8+
> these tests, also pass the `--runner` flag to `pytest`.
9+
510
## Quick Start
611

712
```bash
@@ -12,10 +17,10 @@ pip install -e ".[dev]"
1217
pytest -m unit
1318

1419
# Run all tests (except slow ones)
15-
pytest -m "not slow and not full_pipeline"
20+
pytest -m "not slow and not full_pipeline" --runner <local|docker|singularity>
1621

1722
# Run everything
18-
pytest
23+
pytest --runner <local|docker|singularity>
1924
```
2025

2126
## Test Categories

tests/conftest.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,54 @@
11
"""Shared fixtures for tests data."""
22

3+
import logging
34
from collections.abc import Sequence
45
from pathlib import Path
56
from types import SimpleNamespace
67

8+
import niwrap
79
import pytest
810

911

1012
def pytest_collection_modifyitems(items: Sequence[pytest.Item]) -> None:
1113
"""Apply appropriate markers based on test location."""
14+
markers = {"unit", "integration", "full_pipeline"}
15+
1216
for item in items:
1317
test_path = Path(item.fspath)
18+
for marker in markers & set(test_path.parts):
19+
item.add_marker(getattr(pytest.mark, marker))
20+
21+
22+
def pytest_addoption(parser: pytest.Parser) -> None:
23+
"""Add option(s) to pytest parser."""
24+
parser.addoption(
25+
"--runner",
26+
action="store",
27+
default="docker",
28+
help="Styx runner type to use: ['local', 'docker', 'singularity']",
29+
)
30+
1431

15-
if "unit" in test_path.parts:
16-
item.add_marker(pytest.mark.unit)
32+
@pytest.fixture(scope="session", autouse=True)
33+
def niwrap_runner(
34+
request: pytest.FixtureRequest,
35+
tmp_path_factory: pytest.TempPathFactory,
36+
) -> niwrap.Runner:
37+
"""Globally set test niwrap runner."""
38+
# Set up niwrap runner
39+
match request.config.getoption("--runner").lower():
40+
case "docker":
41+
niwrap.use_docker()
42+
case "singularity":
43+
niwrap.use_singularity()
44+
case _:
45+
niwrap.use_local()
46+
runner = niwrap.get_global_runner()
47+
runner.data_dir = tmp_path_factory.mktemp("styx_tmp")
48+
# Set up logging for debugging
49+
logger = logging.getLogger(runner.logger_name)
50+
logger.setLevel(logging.DEBUG)
51+
return runner
1752

1853

1954
@pytest.fixture

tests/full_pipeline/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Full pipeline (e2e) testing suite."""
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Full e2e tests for different pipelines."""
2+
3+
import pathlib as pl
4+
from types import SimpleNamespace
5+
6+
from rbc.workflows.anatomical import single_session
7+
8+
9+
def test_single_session(test_subject: SimpleNamespace, tmp_path: pl.Path) -> None:
10+
"""e2e test for single session anatomical workflow."""
11+
subject_id = f"sub-{test_subject.id}"
12+
expected_output_dir = tmp_path / subject_id / "anat"
13+
expected_fnames = [
14+
"desc-T1w_mask.nii.gz",
15+
"desc-brain_T1w.nii.gz",
16+
"desc-csf_mask.nii.gz",
17+
"desc-gm_mask.nii.gz",
18+
"desc-wm_mask.nii.gz",
19+
"from-T1w_to-template_mode-image_xfm.nii.gz",
20+
"from-template_to-T1w_mode-image_xfm.nii.gz",
21+
]
22+
single_session(test_subject.t1w, output_dir=tmp_path)
23+
24+
assert expected_output_dir.exists()
25+
assert all(
26+
(expected_output_dir / f"{subject_id}_{fname}").exists()
27+
for fname in expected_fnames
28+
)

tests/integration/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Integration testing suite."""
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Integration tests for AFNI methods used across modalities."""
2+
3+
from types import SimpleNamespace
4+
5+
import pytest
6+
7+
from rbc.core import anatomical
8+
9+
10+
@pytest.mark.slow
11+
def test_brain_extraction(test_subject: SimpleNamespace) -> None:
12+
"""Test brain extraction."""
13+
ants_bet_output = anatomical.ants_brain_extraction(
14+
in_file=test_subject.t1w, output_prefix="test"
15+
)
16+
# Test extracted brain image exists
17+
assert ants_bet_output.brain_extracted_image is not None
18+
assert ants_bet_output.brain_extracted_image.exists()
19+
# Test brain mask exists
20+
assert ants_bet_output.brain_mask is not None
21+
assert ants_bet_output.brain_mask.exists()
22+
23+
24+
@pytest.mark.slow
25+
def test_tissue_segmentation(test_subject: SimpleNamespace) -> None:
26+
"""Test tissue segmentation."""
27+
tissue_mask = anatomical.fsl_tissue_segmentation(
28+
in_file=test_subject.t1w, output_prefix="test"
29+
)
30+
assert tissue_mask.csf.exists()
31+
assert tissue_mask.gm.exists()
32+
assert tissue_mask.wm.exists()
33+
34+
35+
@pytest.mark.slow
36+
def test_registration(test_subject: SimpleNamespace) -> None:
37+
"""Test anatomical registration."""
38+
composite_xfms = anatomical.ants_registration(
39+
in_file=test_subject.t1w, output_prefix="test"
40+
)
41+
assert composite_xfms.forward.exists()
42+
assert composite_xfms.inverse.exists()

tests/integration/test_common.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Integration tests for AFNI methods used across modalities."""
2+
3+
from types import SimpleNamespace
4+
5+
from niwrap import afni
6+
7+
from rbc.core.common import reorient
8+
9+
10+
def test_reorient(test_subject: SimpleNamespace) -> None:
11+
"""Test deobliqueing and reorientation."""
12+
reoriented_file = reorient(test_subject.t1w, output_fname="test.nii.gz")
13+
assert (
14+
afni.v_3dinfo(dataset=[reoriented_file.out_file], orient=True).info[0] == "RPI"
15+
)

tests/unit/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Unit testing suite."""

0 commit comments

Comments
 (0)